Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ba445a0e | ||
|
|
bf0f49b2e3 | ||
|
|
c9ac14e1a4 | ||
|
|
c4f224b7a6 | ||
|
|
0da9ef68cf | ||
|
|
cc650fca3b | ||
|
|
9c080611b6 | ||
|
|
235ec01d8b | ||
|
|
ce1e42bb17 | ||
|
|
7440cfc72c | ||
|
|
dbb6be3814 | ||
|
|
9035dd95ff | ||
|
|
9a70c50f62 | ||
|
|
7d7d331195 | ||
|
|
55a7d78e3f | ||
|
|
377a2d55c9 | ||
|
|
82046700d7 | ||
|
|
0dc745bab9 | ||
|
|
3a52c7dc19 | ||
|
|
dcb4f5f2c0 | ||
|
|
a48247c2d2 | ||
|
|
0e73561b8e | ||
|
|
3ac4ab0746 | ||
|
|
1c6081a871 | ||
|
|
f1f25cf812 | ||
|
|
8b73f51267 | ||
|
|
b1a87b435a | ||
|
|
5c04d95da7 | ||
|
|
7b6cf27081 | ||
|
|
d86ea302cb | ||
|
|
4f0b255b96 | ||
|
|
def74cd4da | ||
|
|
3a4cb909fd | ||
|
|
495866fc8f | ||
|
|
408128a537 | ||
|
|
49878f943d | ||
|
|
ee6a952490 | ||
|
|
8e36c7a14c | ||
|
|
b5702f1a48 | ||
|
|
300f300a02 | ||
|
|
254906cf4c | ||
|
|
f51a14eaeb | ||
|
|
bf286f406e | ||
|
|
164706ff61 | ||
|
|
86d2816bed | ||
|
|
ae8f68e74f | ||
|
|
a27180f212 | ||
|
|
d0cf4c4f30 | ||
|
|
aa0663b187 | ||
|
|
8ab642b20d | ||
|
|
93fa55b7b7 | ||
|
|
2881458baf | ||
|
|
2595ccfe7b | ||
|
|
b7c4eac93b | ||
|
|
89543ab371 | ||
|
|
86aba9ea94 | ||
|
|
6d25680d43 | ||
|
|
068658df23 | ||
|
|
69306942ee | ||
|
|
e968b49349 | ||
|
|
6dc19229ae | ||
|
|
63d56b8467 | ||
|
|
53687647e9 | ||
|
|
fdf0589b68 | ||
|
|
f32dace3f8 | ||
|
|
ed056cb1f6 | ||
|
|
0e9b02d92c | ||
|
|
fc556dad77 | ||
|
|
5780bc09f9 | ||
|
|
d79940e494 | ||
|
|
1df6ec68db | ||
|
|
a06a72901a | ||
|
|
7e859b4ca9 | ||
|
|
9490a05586 | ||
|
|
b244115600 | ||
|
|
287d52fe1a | ||
|
|
21efcf07b1 | ||
|
|
aaa0eb12d5 | ||
|
|
1c5ece5a86 | ||
|
|
061601826e | ||
|
|
af51c0ae65 | ||
|
|
16b50c672b | ||
|
|
dbda9c0457 | ||
|
|
ff30e5ca90 | ||
|
|
2770c649c6 | ||
|
|
61f1f71343 | ||
|
|
99e4dfcfb5 | ||
|
|
cc12380e48 | ||
|
|
b5e223a333 | ||
|
|
f6db89c141 | ||
|
|
d2545c2de6 | ||
|
|
12a774c864 | ||
|
|
186847a455 | ||
|
|
8c44b70109 | ||
|
|
382c1a7e94 | ||
|
|
d7fb3091a6 | ||
|
|
1fbe60e646 | ||
|
|
841063739e | ||
|
|
8d4eb02202 | ||
|
|
f6ed147acc | ||
|
|
96a106cff7 | ||
|
|
98854e2da3 | ||
|
|
48c34cbc17 | ||
|
|
926b5c1b66 | ||
|
|
608bad07a9 | ||
|
|
64c6a32786 | ||
|
|
f33ca787a5 | ||
|
|
78aea9aa51 | ||
|
|
5e75d47149 | ||
|
|
c5a53d36bb | ||
|
|
0213692705 | ||
|
|
a112fde248 | ||
|
|
c207f3c08c | ||
|
|
1b8027473e | ||
|
|
976066b216 | ||
|
|
af229a1d40 | ||
|
|
d14126d8a6 | ||
|
|
d4ce0e6541 | ||
|
|
5ddbeb995e | ||
|
|
f26d43b299 | ||
|
|
ff998579f0 | ||
|
|
3cae921fba | ||
|
|
4682dddb4e | ||
|
|
2e6683d26f | ||
|
|
e97107ba1a | ||
|
|
2d96a57f72 | ||
|
|
9adcd3b133 | ||
|
|
79233e09e3 | ||
|
|
a96a4b2161 | ||
|
|
f736105873 | ||
|
|
2c1ea2c7c1 | ||
|
|
216e00cc25 | ||
|
|
ac1a015046 | ||
|
|
8f4af6bb08 | ||
|
|
acee1b7d79 | ||
|
|
99241c3089 | ||
|
|
8e764372d2 | ||
|
|
fa21a60cf3 | ||
|
|
02fa0ea06b | ||
|
|
8d1ad04625 | ||
|
|
a639baad8d | ||
|
|
2e24107018 | ||
|
|
750ce208b3 | ||
|
|
057e0a0e70 | ||
|
|
255f84a12d | ||
|
|
ac6d14394a | ||
|
|
cb77c2fc49 | ||
|
|
5c279c5240 | ||
|
|
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 |
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 engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
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
|
# WAHA_CLIENT_DEVICE_NAME=YourApp
|
||||||
|
|
||||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||||
@@ -53,6 +56,20 @@ WAHA_LOG_LEVEL=info
|
|||||||
# Don't print QR codes in logs
|
# Don't print QR codes in logs
|
||||||
WAHA_PRINT_QR=False
|
WAHA_PRINT_QR=False
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# ===== PROMETHEUS =====
|
||||||
|
# ======================
|
||||||
|
# Prometheus metrics endpoint - GET /metrics
|
||||||
|
# WAHA_PROMETHEUS_ENABLED=True
|
||||||
|
# WAHA_PROMETHEUS_PATH=/metrics
|
||||||
|
# WAHA_PROMETHEUS_METRIC_PREFIX=waha_
|
||||||
|
# WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS=0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30
|
||||||
|
# Events to count in waha_events_total ('*' for all) - the server actively processes listed events even with no webhooks
|
||||||
|
# WAHA_PROMETHEUS_TRACK_EVENTS=message.any
|
||||||
|
# Optional basic auth for the metrics endpoint (both must be set)
|
||||||
|
# WAHA_PROMETHEUS_USERNAME=admin
|
||||||
|
# WAHA_PROMETHEUS_PASSWORD=secret
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# ===== MEDIA STORAGE =====
|
# ===== MEDIA STORAGE =====
|
||||||
# =========================
|
# =========================
|
||||||
@@ -61,7 +78,17 @@ WAHA_MEDIA_STORAGE=LOCAL
|
|||||||
WHATSAPP_FILES_LIFETIME=0
|
WHATSAPP_FILES_LIFETIME=0
|
||||||
WHATSAPP_FILES_FOLDER=/app/.media
|
WHATSAPP_FILES_FOLDER=/app/.media
|
||||||
|
|
||||||
|
#
|
||||||
# Media download settings
|
# Media download settings
|
||||||
|
#
|
||||||
|
# Events (webhooks, websockets)
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
|
||||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ jobs:
|
|||||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||||
${{ matrix.tag }}
|
${{ matrix.tag }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# Chromium - x86
|
# Chromium - x86
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
- runner: 'ubuntu-latest'
|
||||||
tag: 'latest'
|
tag: 'latest'
|
||||||
platform: 'amd64'
|
platform: 'amd64'
|
||||||
browser: 'chromium'
|
browser: 'chromium'
|
||||||
@@ -23,7 +24,7 @@ jobs:
|
|||||||
goss: 'goss-linux-amd64'
|
goss: 'goss-linux-amd64'
|
||||||
|
|
||||||
# Chromium - ARM
|
# Chromium - ARM
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
- runner: 'ubuntu-24.04-arm'
|
||||||
tag: 'arm'
|
tag: 'arm'
|
||||||
platform: 'linux/arm64'
|
platform: 'linux/arm64'
|
||||||
browser: 'chromium'
|
browser: 'chromium'
|
||||||
@@ -31,7 +32,7 @@ jobs:
|
|||||||
goss: 'goss-linux-arm'
|
goss: 'goss-linux-arm'
|
||||||
|
|
||||||
# Chrome - x86
|
# Chrome - x86
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
- runner: 'ubuntu-latest'
|
||||||
tag: 'chrome'
|
tag: 'chrome'
|
||||||
platform: 'amd64'
|
platform: 'amd64'
|
||||||
browser: 'chrome'
|
browser: 'chrome'
|
||||||
@@ -39,14 +40,14 @@ jobs:
|
|||||||
goss: 'goss-linux-amd64'
|
goss: 'goss-linux-amd64'
|
||||||
|
|
||||||
# Chrome - ARM (Chrome is not available for ARM)
|
# Chrome - ARM (Chrome is not available for ARM)
|
||||||
#- runner: "buildjet-4vcpu-ubuntu-2204-arm"
|
#- runner: "ubuntu-24.04-arm"
|
||||||
# tag: "chrome-arm"
|
# tag: "chrome-arm"
|
||||||
# platform: "linux/arm64"
|
# platform: "linux/arm64"
|
||||||
# browser: "chrome"
|
# browser: "chrome"
|
||||||
# goss: 'goss-linux-arm'
|
# goss: 'goss-linux-arm'
|
||||||
|
|
||||||
# NOWEB - x86
|
# NOWEB - x86
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
- runner: 'ubuntu-latest'
|
||||||
tag: 'noweb'
|
tag: 'noweb'
|
||||||
platform: 'amd64'
|
platform: 'amd64'
|
||||||
browser: 'none'
|
browser: 'none'
|
||||||
@@ -54,7 +55,7 @@ jobs:
|
|||||||
goss: 'goss-linux-amd64'
|
goss: 'goss-linux-amd64'
|
||||||
|
|
||||||
# NOWEB - ARM
|
# NOWEB - ARM
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
- runner: 'ubuntu-24.04-arm'
|
||||||
tag: 'noweb-arm'
|
tag: 'noweb-arm'
|
||||||
platform: 'linux/arm64'
|
platform: 'linux/arm64'
|
||||||
browser: 'none'
|
browser: 'none'
|
||||||
@@ -62,7 +63,7 @@ jobs:
|
|||||||
goss: 'goss-linux-arm'
|
goss: 'goss-linux-arm'
|
||||||
|
|
||||||
# GOWS - x86
|
# GOWS - x86
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
- runner: 'ubuntu-latest'
|
||||||
tag: 'gows'
|
tag: 'gows'
|
||||||
platform: 'amd64'
|
platform: 'amd64'
|
||||||
browser: 'none'
|
browser: 'none'
|
||||||
@@ -70,7 +71,7 @@ jobs:
|
|||||||
goss: 'goss-linux-amd64'
|
goss: 'goss-linux-amd64'
|
||||||
|
|
||||||
# No browser - ARM
|
# No browser - ARM
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
- runner: 'ubuntu-24.04-arm'
|
||||||
tag: 'gows-arm'
|
tag: 'gows-arm'
|
||||||
platform: 'linux/arm64'
|
platform: 'linux/arm64'
|
||||||
browser: 'none'
|
browser: 'none'
|
||||||
@@ -100,7 +101,51 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
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
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -6,22 +6,63 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
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:
|
docker-build:
|
||||||
if: github.repository == 'devlikeapro/waha-plus'
|
if: github.repository == 'devlikeapro/waha'
|
||||||
|
# needs: unit-tests
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
name:
|
name:
|
||||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||||
${{ matrix.tag }}
|
${{ matrix.tag }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
# Chromium - x86
|
||||||
|
- runner: 'ubuntu-latest'
|
||||||
tag: 'dev'
|
tag: 'dev'
|
||||||
platform: 'amd64'
|
platform: 'amd64'
|
||||||
browser: 'chromium'
|
browser: 'chromium'
|
||||||
engine: 'WEBJS'
|
engine: 'WEBJS'
|
||||||
goss: 'goss-linux-amd64'
|
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'
|
tag: 'dev-arm'
|
||||||
platform: 'linux/arm64'
|
platform: 'linux/arm64'
|
||||||
browser: 'chromium'
|
browser: 'chromium'
|
||||||
@@ -32,6 +73,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
|
ref: dev
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check recent changes
|
- name: Check recent changes
|
||||||
@@ -71,8 +113,60 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build (attempt 1)
|
||||||
|
id: build1
|
||||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
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
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
openapi.json
|
||||||
tmp/*
|
tmp/*
|
||||||
src/core/engines/gows/proto
|
src/core/engines/gows/proto
|
||||||
.env
|
.env
|
||||||
|
|||||||
+27
@@ -1,5 +1,32 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- '**'
|
||||||
|
|
||||||
compressionLevel: mixed
|
compressionLevel: mixed
|
||||||
|
|
||||||
enableGlobalCache: false
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
npmMinimalAgeGate: 0
|
||||||
|
|
||||||
|
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
|
## Product & Variants
|
||||||
|
|
||||||
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
|
- WAHA ships in **Core** and **Plus** editions
|
||||||
only supports the default session plus minimal media features. Plus extends
|
- Core lives under `src/core` and supports the default session with minimal
|
||||||
core via `src/plus` to add multi-session orchestration, richer media handling,
|
media features
|
||||||
and external storage integrations.
|
- Plus extends core via `src/plus` to add multi-session orchestration, richer
|
||||||
- Core code must remain free from Plus-only references. A pre-commit hook
|
media handling, and external storage integrations
|
||||||
rejects the word `plus` inside core files; reuse abstractions exposed through
|
- Core code must remain free from Plus-only references (pre-commit hook rejects
|
||||||
`@waha/core/**` instead of importing Plus modules from Core.
|
"plus" in core files)
|
||||||
- Commit subjects are validated: changes that touch `src/plus` require a
|
- Commit subjects: changes that touch `src/plus` require `[PLUS] …` prefix;
|
||||||
`[PLUS] …` prefix and must not include non-Plus files; everything else must
|
everything else uses `[core] …`
|
||||||
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
|
|
||||||
unsure.
|
|
||||||
|
|
||||||
## Tech Stack Snapshot
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
|
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry)
|
||||||
scripts with Yarn.
|
- **Framework**: NestJS v11 with dependency injection and modular controllers in
|
||||||
- **Framework**: NestJS v11 with dependency injection, modular controllers in
|
`src/api`
|
||||||
`src/api`, and Pino-based logging via `nestjs-pino`.
|
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`,
|
||||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
|
`WPP`). Core uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus`
|
||||||
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
|
with extra storage backends (Mongo/Postgres/SQLite)
|
||||||
storage backends (Mongo/Postgres/SQLite).
|
|
||||||
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
|
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
|
||||||
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
|
`src/vendor/esm.ts`
|
||||||
once.
|
- **Utilities**: RxJS streams drive webhook event fan-out. Prefer existing
|
||||||
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
|
helpers in `src/utils` and `src/core/utils`
|
||||||
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
|
|
||||||
before adding bespoke logic.
|
|
||||||
|
|
||||||
## Repository Landmarks
|
## Key Paths
|
||||||
|
|
||||||
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
|
- `src/main.ts`: runtime entry point; dynamically loads AppModule (Core vs Plus)
|
||||||
(Core vs Plus) and configures global interceptors/filters.
|
- `src/api/**`: REST controllers and WebSocket gateway
|
||||||
- `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/core/**`: shared abstractions (config services, engine bootstrap,
|
- `src/core/**`: shared abstractions (config services, engine bootstrap,
|
||||||
storage, session management). Core only allows the `default` session and
|
storage, session management)
|
||||||
cleans up storage on boot.
|
|
||||||
- `src/plus/**`: multi-session orchestration, advanced media services, and
|
- `src/plus/**`: multi-session orchestration, advanced media services, and
|
||||||
external persistence layers (Mongo, Postgres). Reuse this layer when adding
|
external persistence layers
|
||||||
Plus-only capabilities.
|
|
||||||
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
|
|
||||||
services.
|
|
||||||
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
|
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
|
||||||
`domain.action`), helper utilities. Maintain naming consistency when
|
`domain.action`), helper utilities
|
||||||
introducing new events.
|
|
||||||
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
|
|
||||||
but keep existing tests working.
|
|
||||||
|
|
||||||
## Coding Expectations
|
## Coding Expectations
|
||||||
|
|
||||||
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
|
- Favor composability and long-lived solutions
|
||||||
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
|
- Reuse existing helpers (`parseBool`, `DefaultMap`, media factories) instead of
|
||||||
logic. Lodash ships with the project—prefer its utilities over hand-rolled
|
reinventing logic
|
||||||
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.
|
|
||||||
- Stick to NestJS patterns: inject dependencies through constructors, expose
|
- 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
|
- Logging goes through injected `PinoLogger` or helpers in
|
||||||
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
|
`src/utils/logging.ts`
|
||||||
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
|
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
|
||||||
consistent (use absolute aliases, not relative `../../../`).
|
- Prefer named function declarations over `const` arrow functions
|
||||||
- Avoid naming unused variables with a leading underscore; if a parameter is
|
- Avoid naming unused variables with a leading underscore
|
||||||
required by a signature, explicitly `void` it instead.
|
- Always use explicit property names in object literals — never shorthand: write
|
||||||
- For configs, prefer runtime configurability over constants. Environment keys
|
`{ key: value }`, not `{ value }` (even when the variable name matches the
|
||||||
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
|
key)
|
||||||
`session.config.*` for per-session overrides. If both env and config are
|
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
|
||||||
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
|
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
|
```bash
|
||||||
the codebase stays consistent for the global team.
|
export DEBUG=1
|
||||||
- Route user-visible strings through the existing i18n structure rather than
|
export WAHA_API_KEY=666
|
||||||
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
|
export WAHA_DASHBOARD_PASSWORD=666
|
||||||
English as the source locale before adding translations.
|
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
|
## Code Guidelines
|
||||||
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`).
|
|
||||||
|
|
||||||
## 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,
|
## MCP Tools
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
**When you change an existing API endpoint:**
|
||||||
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.
|
|
||||||
|
|
||||||
## 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`
|
- WEBJS: `../whatsapp-web.js`
|
||||||
- NOWEB: `../WhiskeySockets-Baileys`
|
- NOWEB: `../WhiskeySockets-Baileys` and `../whatsapp-rust-bridge`
|
||||||
- GOWS: `../gows` + `../whatsmeow`
|
- GOWS: `../gows` and `../whatsmeow`
|
||||||
|
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
|
||||||
- ChatWoot: `../chatwoot`
|
- ChatWoot: `../chatwoot`
|
||||||
|
|
||||||
Following this playbook keeps contributions aligned with WAHA’s structure,
|
|
||||||
automation hooks, and release process.
|
|
||||||
+21
-4
@@ -8,16 +8,18 @@ FROM node:${NODE_IMAGE_TAG} AS build
|
|||||||
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||||
|
|
||||||
# git + build toolchain for git deps
|
# 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
|
# npm packages
|
||||||
WORKDIR /git
|
WORKDIR /git
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
COPY yarn.lock .
|
COPY yarn.lock .
|
||||||
|
COPY .yarnrc.yml .
|
||||||
ENV YARN_CHECKSUM_BEHAVIOR=update
|
ENV YARN_CHECKSUM_BEHAVIOR=update
|
||||||
|
|
||||||
RUN npm install -g corepack && corepack enable
|
RUN npm install -g corepack && corepack enable
|
||||||
RUN yarn set version 4.9.2
|
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# App
|
# App
|
||||||
@@ -26,6 +28,21 @@ ADD . /git
|
|||||||
RUN yarn install
|
RUN yarn install
|
||||||
RUN yarn build && find ./dist -name "*.d.ts" -delete
|
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
|
# Dashboard
|
||||||
#
|
#
|
||||||
@@ -162,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
|
|||||||
# Install Chrome
|
# Install Chrome
|
||||||
# Available versions:
|
# Available versions:
|
||||||
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
|
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
|
||||||
ARG CHROME_VERSION="140.0.7339.80-1"
|
ARG CHROME_VERSION="152.0.7977.82-1"
|
||||||
ARG OPUSTAGS_VERSION="1.10.1"
|
ARG OPUSTAGS_VERSION="1.10.1"
|
||||||
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
||||||
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
|
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
|
||||||
@@ -230,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
|
|||||||
ENV WAHA_ZIPPER=ZIPUNZIP
|
ENV WAHA_ZIPPER=ZIPUNZIP
|
||||||
|
|
||||||
# GOWS - use libc DNS resolver
|
# GOWS - use libc DNS resolver
|
||||||
ENV GODEBUG netdns=cgo
|
ENV GODEBUG=netdns=cgo
|
||||||
|
|
||||||
# Run command, etc
|
# Run command, etc
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -32,16 +32,36 @@ push:
|
|||||||
docker push devlikeapro/waha
|
docker push devlikeapro/waha
|
||||||
|
|
||||||
for-swagger:
|
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:
|
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:
|
up-noweb-libsignal:
|
||||||
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||||
|
|
||||||
up-webjs:
|
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
|
||||||
|
|
||||||
|
link-webjs:
|
||||||
|
yarn up whatsapp-web.js@link:../whatsapp-web.js
|
||||||
|
|
||||||
|
unlink-webjs: up-webjs
|
||||||
|
|
||||||
|
rebuild-noweb:
|
||||||
|
cd ../Baileys && yarn build
|
||||||
|
|
||||||
|
link-noweb: rebuild-noweb
|
||||||
|
yarn up @adiwajshing/baileys@link:../Baileys
|
||||||
|
|
||||||
|
unlink-noweb: up-noweb
|
||||||
|
|
||||||
|
up-wpp:
|
||||||
|
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
|
||||||
|
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
|
||||||
|
|
||||||
|
up-rust-bridge:
|
||||||
|
yarn up -R whatsapp-rust-bridge
|
||||||
|
|
||||||
start-proxy:
|
start-proxy:
|
||||||
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
|
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
|
||||||
@@ -51,10 +71,23 @@ proto-gows:
|
|||||||
|
|
||||||
gows:
|
gows:
|
||||||
cd ../gows && \
|
cd ../gows && \
|
||||||
export PATH=${HOME}/go/bin:${PATH} && \
|
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
|
||||||
make all
|
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:
|
copy-dashboard:
|
||||||
cd ../waha-hub/ui && \
|
cd ../waha-hub/ui && \
|
||||||
make copy-waha
|
make copy-waha
|
||||||
|
|
||||||
@@ -147,7 +147,18 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
|
|||||||
## Start the project
|
## Start the project
|
||||||
1. Clone the repository
|
1. Clone the repository
|
||||||
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
|
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
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
yarn install
|
yarn install
|
||||||
|
|||||||
@@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL
|
|||||||
WHATSAPP_FILES_LIFETIME=1800
|
WHATSAPP_FILES_LIFETIME=1800
|
||||||
WHATSAPP_FILES_FOLDER=/app/.media
|
WHATSAPP_FILES_FOLDER=/app/.media
|
||||||
|
|
||||||
|
#
|
||||||
# Media download settings
|
# Media download settings
|
||||||
|
#
|
||||||
|
# Events (webhooks, websockets)
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
|
||||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
|
|
||||||
base: &base
|
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
|
env_file: .chatwoot.env ## Change this file for customized env variables
|
||||||
volumes:
|
volumes:
|
||||||
- chatwoot_storage:/app/storage
|
- chatwoot_storage:/app/storage
|
||||||
@@ -75,7 +75,6 @@ services:
|
|||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
# Please provide your own password.
|
# Please provide your own password.
|
||||||
- POSTGRES_PASSWORD=postgres
|
- POSTGRES_PASSWORD=postgres
|
||||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
|
|||||||
+6
-6
@@ -4,12 +4,12 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"plugins": ["@nestjs/swagger"],
|
"plugins": ["@nestjs/swagger"],
|
||||||
"assets": [
|
"assets": [
|
||||||
"dashboard/**",
|
{ "include": "dashboard/**", "watchAssets": false },
|
||||||
"core/engines/webjs/*",
|
{ "include": "core/engines/webjs/*", "watchAssets": true },
|
||||||
"plus/engines/webjs/*",
|
{ "include": "plus/engines/webjs/*", "watchAssets": true },
|
||||||
"apps/chatwoot/i18n/locales/*.yaml",
|
{ "include": "apps/chatwoot/i18n/locales/*.yaml", "watchAssets": true },
|
||||||
"apps/chatwoot/i18n/locales/*.yml"
|
{ "include": "apps/chatwoot/i18n/locales/*.yml", "watchAssets": true }
|
||||||
],
|
],
|
||||||
"watchAssets": true
|
"watchAssets": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+78
-29
@@ -16,25 +16,32 @@
|
|||||||
"start:prod-exit": "node dist/main",
|
"start:prod-exit": "node dist/main",
|
||||||
"lint": "oxlint --deny-warnings",
|
"lint": "oxlint --deny-warnings",
|
||||||
"lint-fix": "oxlint --fix --deny-warnings",
|
"lint-fix": "oxlint --fix --deny-warnings",
|
||||||
"test": "jest",
|
"test": "yarn test:unit",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --selectProjects unit --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --selectProjects unit --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"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:fetch": "node scripts/gows-proto.js fetch",
|
||||||
"gows:proto:build": "node scripts/gows-proto.js build",
|
"gows:proto:build": "node scripts/gows-proto.js build",
|
||||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@adiwajshing/keyed-db": "^0.2.4",
|
||||||
"@aws-sdk/client-s3": "^3.633.0",
|
"@aws-sdk/client-s3": "^3.633.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
||||||
"@bull-board/api": "^6.9.1",
|
"@bull-board/api": "^6.9.1",
|
||||||
"@bull-board/express": "^6.9.1",
|
"@bull-board/express": "^6.9.1",
|
||||||
"@bull-board/nestjs": "^6.9.1",
|
"@bull-board/nestjs": "^6.9.1",
|
||||||
|
"@casl/ability": "^6.8.0",
|
||||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||||
"@liaoliaots/nestjs-redis": "^9",
|
"@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/axios": "^3.0.2",
|
||||||
"@nestjs/bullmq": "^11.0.2",
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
@@ -47,17 +54,25 @@
|
|||||||
"@nestjs/swagger": "^7.1.11",
|
"@nestjs/swagger": "^7.1.11",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@nestjs/websockets": "^11.0.0",
|
"@nestjs/websockets": "^11.0.0",
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/instrumentation-http": "^0.221.0",
|
||||||
|
"@opentelemetry/instrumentation-pino": "^0.67.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.221.0",
|
||||||
|
"@opentelemetry/sdk-trace": "^2.10.0",
|
||||||
|
"@prometheus-io/client": "^0.16.1",
|
||||||
"@types/better-sqlite3": "^7.6.10",
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
"@types/lodash": "^4.14.194",
|
"@types/lodash": "^4.14.194",
|
||||||
"@types/mustache": "^4.2.5",
|
"@types/mustache": "^4.2.5",
|
||||||
"@types/passport": "^1.0.17",
|
"@types/passport": "^1.0.17",
|
||||||
"@types/sqlite3": "^5.1.0",
|
"@types/sqlite3": "^5.1.0",
|
||||||
"@types/ws": "^8.5.4",
|
"@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",
|
"adm-zip": "0.5.10",
|
||||||
"agentkeepalive": "^4.5.0",
|
"agentkeepalive": "^4.5.0",
|
||||||
"async-lock": "^1.4.1",
|
"async-lock": "^1.4.1",
|
||||||
"audio-decode": "^2.2.2",
|
"audio-decode": "^2.2.2",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.19.0",
|
||||||
"axios-retry": "^4.5.0",
|
"axios-retry": "^4.5.0",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"bullmq": "^5.48.1",
|
"bullmq": "^5.48.1",
|
||||||
@@ -87,29 +102,34 @@
|
|||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nestjs-pino": "^4.1.0",
|
"nestjs-pino": "^4.1.0",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
|
"node-webpmux": "^3.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-headerapikey": "^1.2.2",
|
"passport-headerapikey": "^1.2.2",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
|
"pg-copy-streams": "^6.0.0",
|
||||||
"pino-http": "^10.2.0",
|
"pino-http": "^10.2.0",
|
||||||
"pino-pretty": "^11.2.1",
|
"pino-pretty": "^11.2.1",
|
||||||
"pretty-bytes": "5.6.0",
|
"pretty-bytes": "5.6.0",
|
||||||
"promise-retry": "^2.0.1",
|
"promise-retry": "^2.0.1",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^25.10.0",
|
||||||
"qrcode": "^1.5.1",
|
"qrcode": "^1.5.1",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^4.3.0",
|
"rimraf": "^4.3.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.34.5",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"swagger-ui-express": "^4.1.4",
|
"swagger-ui-express": "^4.1.4",
|
||||||
|
"tapable": "^2.2.2",
|
||||||
"ulid": "^2.3.0",
|
"ulid": "^2.3.0",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
"uniqid": "^5.4.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",
|
"write-file-atomic": "^6.0.0",
|
||||||
"yaml": "^2.7.1"
|
"yaml": "^2.7.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"bufferutil": "^4.0.8"
|
"bufferutil": "^4.0.8"
|
||||||
@@ -118,9 +138,11 @@
|
|||||||
"typescript": "5.9.2",
|
"typescript": "5.9.2",
|
||||||
"bufferutil": "^4.0.8",
|
"bufferutil": "^4.0.8",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^25.10.0",
|
||||||
"whatwg-url": "13.0.0",
|
"whatwg-url": "13.0.0",
|
||||||
"axios": "^1.9.0"
|
"axios": "^1.19.0",
|
||||||
|
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2",
|
||||||
|
"sharp": "0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.1",
|
"@grpc/grpc-js": "^1.14.1",
|
||||||
@@ -134,6 +156,7 @@
|
|||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/supertest": "^2.0.8",
|
"@types/supertest": "^2.0.8",
|
||||||
|
"@types/user-agents": "^1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"grpc-tools": "^1.13.0",
|
"grpc-tools": "^1.13.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
@@ -148,21 +171,47 @@
|
|||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"projects": [
|
||||||
"js",
|
{
|
||||||
"json",
|
"displayName": "unit",
|
||||||
"ts"
|
"moduleFileExtensions": [
|
||||||
],
|
"js",
|
||||||
"rootDir": "src",
|
"json",
|
||||||
"moduleNameMapper": {
|
"ts"
|
||||||
"^@waha/(.*)$": "<rootDir>/$1"
|
],
|
||||||
},
|
"rootDir": "src",
|
||||||
"testRegex": ".test.ts$",
|
"moduleNameMapper": {
|
||||||
"transform": {
|
"^@waha/(.*)$": "<rootDir>/$1"
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
},
|
||||||
},
|
"testRegex": ".test.ts$",
|
||||||
"coverageDirectory": "../coverage",
|
"testPathIgnorePatterns": [
|
||||||
"testEnvironment": "node"
|
"/__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"
|
"packageManager": "yarn@4.17.1"
|
||||||
}
|
}
|
||||||
+17
-3
@@ -76,17 +76,31 @@ const updatedContent = exampleContent
|
|||||||
|
|
||||||
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
|
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 = [
|
const lines = [
|
||||||
'Credentials generated.',
|
'Credentials generated.',
|
||||||
'',
|
'',
|
||||||
'Dashboard and Swagger:',
|
'Generated env values:',
|
||||||
|
...generatedEnvValues.map((line) => ` - ${line}`),
|
||||||
|
'',
|
||||||
|
'Use these credentials to login in Dashboard or Swagger:',
|
||||||
' - Username: admin',
|
' - Username: admin',
|
||||||
` - Password: ${adminPassword}`,
|
` - Password: ${adminPassword}`,
|
||||||
'',
|
'',
|
||||||
'API key: ',
|
'Use this API key in the x-api-key header:',
|
||||||
` - ${apiKey}`,
|
` - ${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'));
|
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,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||||
import {
|
import {
|
||||||
QRCodeSessionParam,
|
QRCodeSessionParam,
|
||||||
@@ -18,16 +24,26 @@ import { SessionManager } from '../core/abc/manager.abc';
|
|||||||
import { WhatsappSession } from '../core/abc/session.abc';
|
import { WhatsappSession } from '../core/abc/session.abc';
|
||||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||||
import {
|
import {
|
||||||
|
PasskeyAssertionRequest,
|
||||||
|
PasskeyChallenge,
|
||||||
|
PasskeyConfirmationResponse,
|
||||||
QRCodeFormat,
|
QRCodeFormat,
|
||||||
QRCodeQuery,
|
QRCodeQuery,
|
||||||
QRCodeValue,
|
QRCodeValue,
|
||||||
RequestCodeRequest,
|
RequestCodeRequest,
|
||||||
} from '../structures/auth.dto';
|
} from '../structures/auth.dto';
|
||||||
import { Base64File } from '../structures/files.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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/auth')
|
@Controller('api/:session/auth')
|
||||||
@ApiTags('🔑 Auth')
|
@ApiTags('📱 Pairing')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
class AuthController {
|
class AuthController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@@ -60,6 +76,54 @@ class AuthController {
|
|||||||
) {
|
) {
|
||||||
return session.requestCode(request.phoneNumber, request.method, request);
|
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 };
|
export { AuthController };
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -14,10 +15,17 @@ import {
|
|||||||
import { SessionManager } from '../core/abc/manager.abc';
|
import { SessionManager } from '../core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '../core/abc/session.abc';
|
import { WhatsappSession } from '../core/abc/session.abc';
|
||||||
import { RejectCallRequest } from '../structures/calls.dto';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/calls')
|
@Controller('api/:session/calls')
|
||||||
@ApiTags('📞 Calls')
|
@ApiTags('📞 Calls')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
export class CallsController {
|
export class CallsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -39,10 +40,16 @@ import {
|
|||||||
parseChannelInviteLink,
|
parseChannelInviteLink,
|
||||||
WhatsappSession,
|
WhatsappSession,
|
||||||
} from '../core/abc/session.abc';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/channels')
|
@Controller('api/:session/channels')
|
||||||
@ApiTags('📢 Channels')
|
@ApiTags('📢 Channels')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class ChannelsController {
|
export class ChannelsController {
|
||||||
constructor(
|
constructor(
|
||||||
private manager: SessionManager,
|
private manager: SessionManager,
|
||||||
@@ -51,6 +58,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get list of know channels' })
|
@ApiOperation({ summary: 'Get list of know channels' })
|
||||||
async list(
|
async list(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -61,6 +69,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Post('')
|
@Post('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Create a new channel.' })
|
@ApiOperation({ summary: 'Create a new channel.' })
|
||||||
create(
|
create(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -72,6 +81,7 @@ export class ChannelsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdApiParam
|
@NewsletterIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Delete the channel.' })
|
@ApiOperation({ summary: 'Delete the channel.' })
|
||||||
delete(
|
delete(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -83,6 +93,7 @@ export class ChannelsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdOrInviteCodeApiParam
|
@NewsletterIdOrInviteCodeApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get the channel info',
|
summary: 'Get the channel info',
|
||||||
description:
|
description:
|
||||||
@@ -103,6 +114,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get(':id/messages/preview')
|
@Get(':id/messages/preview')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
@@ -123,20 +135,22 @@ export class ChannelsController {
|
|||||||
})
|
})
|
||||||
async previewChannelMessages(
|
async previewChannelMessages(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@Param('id') code: string,
|
@Param('id') codeOrId: string,
|
||||||
@Query() query: PreviewChannelMessages,
|
@Query() query: PreviewChannelMessages,
|
||||||
): Promise<ChannelMessage[]> {
|
): Promise<ChannelMessage[]> {
|
||||||
if (isJidNewsletter(code)) {
|
let code = null;
|
||||||
const channel = await session.channelsGetChannel(code);
|
if (isJidNewsletter(codeOrId)) {
|
||||||
|
const channel = await session.channelsGetChannel(codeOrId);
|
||||||
code = parseChannelInviteLink(channel.invite);
|
code = parseChannelInviteLink(channel.invite);
|
||||||
}
|
}
|
||||||
const inviteCode = parseChannelInviteLink(code);
|
const inviteCode = parseChannelInviteLink(code || codeOrId);
|
||||||
return session.previewChannelMessages(inviteCode, query);
|
return session.previewChannelMessages(inviteCode, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/follow')
|
@Post(':id/follow')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdApiParam
|
@NewsletterIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Follow the channel.' })
|
@ApiOperation({ summary: 'Follow the channel.' })
|
||||||
follow(
|
follow(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -148,6 +162,7 @@ export class ChannelsController {
|
|||||||
@Post(':id/unfollow')
|
@Post(':id/unfollow')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdApiParam
|
@NewsletterIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Unfollow the channel.' })
|
@ApiOperation({ summary: 'Unfollow the channel.' })
|
||||||
unfollow(
|
unfollow(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -159,6 +174,7 @@ export class ChannelsController {
|
|||||||
@Post(':id/mute')
|
@Post(':id/mute')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdApiParam
|
@NewsletterIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Mute the channel.' })
|
@ApiOperation({ summary: 'Mute the channel.' })
|
||||||
mute(
|
mute(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -170,6 +186,7 @@ export class ChannelsController {
|
|||||||
@Post(':id/unmute')
|
@Post(':id/unmute')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@NewsletterIdApiParam
|
@NewsletterIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Unmute the channel.' })
|
@ApiOperation({ summary: 'Unmute the channel.' })
|
||||||
unmute(
|
unmute(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -181,6 +198,7 @@ export class ChannelsController {
|
|||||||
@Post('/search/by-view')
|
@Post('/search/by-view')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
@ApiOperation({ summary: 'Search for channels (by view)' })
|
@ApiOperation({ summary: 'Search for channels (by view)' })
|
||||||
async searchByView(
|
async searchByView(
|
||||||
@@ -193,6 +211,7 @@ export class ChannelsController {
|
|||||||
@Post('/search/by-text')
|
@Post('/search/by-text')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
@ApiOperation({ summary: 'Search for channels (by text)' })
|
@ApiOperation({ summary: 'Search for channels (by text)' })
|
||||||
async searchByText(
|
async searchByText(
|
||||||
@@ -204,6 +223,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get('/search/views')
|
@Get('/search/views')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get list of views for channel search' })
|
@ApiOperation({ summary: 'Get list of views for channel search' })
|
||||||
getSearchViews(): Promise<ChannelView[]> {
|
getSearchViews(): Promise<ChannelView[]> {
|
||||||
return this.channelsInfoService.getViews();
|
return this.channelsInfoService.getViews();
|
||||||
@@ -211,6 +231,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get('/search/countries')
|
@Get('/search/countries')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get list of countries for channel search' })
|
@ApiOperation({ summary: 'Get list of countries for channel search' })
|
||||||
getSearchCountries(): Promise<ChannelCountry[]> {
|
getSearchCountries(): Promise<ChannelCountry[]> {
|
||||||
return this.channelsInfoService.getCountries();
|
return this.channelsInfoService.getCountries();
|
||||||
@@ -218,6 +239,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get('/search/categories')
|
@Get('/search/categories')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get list of categories for channel search' })
|
@ApiOperation({ summary: 'Get list of categories for channel search' })
|
||||||
getSearchCategories(): Promise<ChannelCategory[]> {
|
getSearchCategories(): Promise<ChannelCategory[]> {
|
||||||
return this.channelsInfoService.getCategories();
|
return this.channelsInfoService.getCategories();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -24,15 +25,15 @@ import { WhatsappSession } from '../core/abc/session.abc';
|
|||||||
import {
|
import {
|
||||||
ChatPictureQuery,
|
ChatPictureQuery,
|
||||||
ChatPictureResponse,
|
ChatPictureResponse,
|
||||||
ChatsPaginationParams,
|
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
GetChatMessageQuery,
|
GetChatMessageQuery,
|
||||||
GetChatMessagesFilter,
|
GetChatMessagesFilter,
|
||||||
GetChatMessagesQuery,
|
GetChatMessagesQuery,
|
||||||
|
GetChatsOverviewParams,
|
||||||
|
GetChatsParams,
|
||||||
MessageSortField,
|
MessageSortField,
|
||||||
OverviewBodyRequest,
|
OverviewBodyRequest,
|
||||||
OverviewFilter,
|
OverviewFilter,
|
||||||
OverviewPaginationParams,
|
|
||||||
PinMessageRequest,
|
PinMessageRequest,
|
||||||
ReadChatMessagesQuery,
|
ReadChatMessagesQuery,
|
||||||
ReadChatMessagesResponse,
|
ReadChatMessagesResponse,
|
||||||
@@ -40,26 +41,34 @@ import {
|
|||||||
} from '../structures/chats.dto';
|
} from '../structures/chats.dto';
|
||||||
import { EditMessageRequest } from '../structures/chatting.dto';
|
import { EditMessageRequest } from '../structures/chatting.dto';
|
||||||
import { SortOrder } from '@waha/structures/pagination.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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/chats')
|
@Controller('api/:session/chats')
|
||||||
@ApiTags('💬 Chats')
|
@ApiTags('💬 Chats')
|
||||||
@UsePipes(new ValidationPipe({ transform: true }))
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
class ChatsController {
|
class ChatsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get chats' })
|
@ApiOperation({ summary: 'Get chats' })
|
||||||
getChats(
|
getChats(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@Query() pagination: ChatsPaginationParams,
|
@Query() pagination: GetChatsParams,
|
||||||
) {
|
) {
|
||||||
return session.getChats(pagination);
|
return session.getChats(pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('overview')
|
@Get('overview')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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',
|
'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 }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
getChatsOverview(
|
getChatsOverview(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@Query() pagination: OverviewPaginationParams,
|
@Query() pagination: GetChatsOverviewParams,
|
||||||
@Query() filter: OverviewFilter,
|
@Query() filter: OverviewFilter,
|
||||||
): Promise<ChatSummary[]> {
|
): Promise<ChatSummary[]> {
|
||||||
return session.getChatsOverview(pagination, filter);
|
return session.getChatsOverview(pagination, filter);
|
||||||
@@ -75,6 +84,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post('overview')
|
@Post('overview')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
|
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
|
||||||
@@ -89,6 +99,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Delete(':chatId')
|
@Delete(':chatId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Deletes the chat' })
|
@ApiOperation({ summary: 'Deletes the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
deleteChat(
|
deleteChat(
|
||||||
@@ -100,6 +111,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Get(':chatId/picture')
|
@Get(':chatId/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Gets chat picture' })
|
@ApiOperation({ summary: 'Gets chat picture' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getChatPicture(
|
async getChatPicture(
|
||||||
@@ -113,6 +125,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Get(':chatId/messages')
|
@Get(':chatId/messages')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Gets messages in the chat' })
|
@ApiOperation({ summary: 'Gets messages in the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
@@ -133,6 +146,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/messages/read')
|
@Post(':chatId/messages/read')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Read unread messages in the chat' })
|
@ApiOperation({ summary: 'Read unread messages in the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
@@ -146,6 +160,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Get(':chatId/messages/:messageId')
|
@Get(':chatId/messages/:messageId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Gets message by id' })
|
@ApiOperation({ summary: 'Gets message by id' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
async getChatMessage(
|
async getChatMessage(
|
||||||
@@ -163,6 +178,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/messages/:messageId/pin')
|
@Post(':chatId/messages/:messageId/pin')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Pins a message in the chat' })
|
@ApiOperation({ summary: 'Pins a message in the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
async pinMessage(
|
async pinMessage(
|
||||||
@@ -177,6 +193,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/messages/:messageId/unpin')
|
@Post(':chatId/messages/:messageId/unpin')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Unpins a message in the chat' })
|
@ApiOperation({ summary: 'Unpins a message in the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
async unpinMessage(
|
async unpinMessage(
|
||||||
@@ -190,6 +207,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Delete(':chatId/messages')
|
@Delete(':chatId/messages')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Clears all messages from the chat' })
|
@ApiOperation({ summary: 'Clears all messages from the chat' })
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
clearMessages(
|
clearMessages(
|
||||||
@@ -201,6 +219,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Delete(':chatId/messages/:messageId')
|
@Delete(':chatId/messages/:messageId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@MessageIdApiParam
|
@MessageIdApiParam
|
||||||
@ApiOperation({ summary: 'Deletes a message from the chat' })
|
@ApiOperation({ summary: 'Deletes a message from the chat' })
|
||||||
@@ -214,6 +233,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Put(':chatId/messages/:messageId')
|
@Put(':chatId/messages/:messageId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@MessageIdApiParam
|
@MessageIdApiParam
|
||||||
@ApiOperation({ summary: 'Edits a message in the chat' })
|
@ApiOperation({ summary: 'Edits a message in the chat' })
|
||||||
@@ -228,6 +248,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/archive')
|
@Post(':chatId/archive')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@ApiOperation({ summary: 'Archive the chat' })
|
@ApiOperation({ summary: 'Archive the chat' })
|
||||||
archiveChat(
|
archiveChat(
|
||||||
@@ -239,6 +260,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/unarchive')
|
@Post(':chatId/unarchive')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@ApiOperation({ summary: 'Unarchive the chat' })
|
@ApiOperation({ summary: 'Unarchive the chat' })
|
||||||
unarchiveChat(
|
unarchiveChat(
|
||||||
@@ -250,6 +272,7 @@ class ChatsController {
|
|||||||
|
|
||||||
@Post(':chatId/unread')
|
@Post(':chatId/unread')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
@ApiOperation({ summary: 'Unread the chat' })
|
@ApiOperation({ summary: 'Unread the chat' })
|
||||||
unreadChat(
|
unreadChat(
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||||
import {
|
import {
|
||||||
GetChatMessagesFilter,
|
GetChatMessagesFilter,
|
||||||
@@ -36,10 +37,12 @@ import {
|
|||||||
MessageReactionRequest,
|
MessageReactionRequest,
|
||||||
MessageReplyRequest,
|
MessageReplyRequest,
|
||||||
MessageStarRequest,
|
MessageStarRequest,
|
||||||
|
MessageStickerRequest,
|
||||||
MessageTextQuery,
|
MessageTextQuery,
|
||||||
MessageTextRequest,
|
MessageTextRequest,
|
||||||
MessageVideoRequest,
|
MessageVideoRequest,
|
||||||
MessageVoiceRequest,
|
MessageVoiceRequest,
|
||||||
|
NewMessageIDResponse,
|
||||||
SendSeenRequest,
|
SendSeenRequest,
|
||||||
WANumberExistResult,
|
WANumberExistResult,
|
||||||
} from '../structures/chatting.dto';
|
} from '../structures/chatting.dto';
|
||||||
@@ -48,15 +51,32 @@ import {
|
|||||||
mentionsAll,
|
mentionsAll,
|
||||||
validateRequestMentions,
|
validateRequestMentions,
|
||||||
} from '@waha/core/utils/mentions.all';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api')
|
@Controller('api')
|
||||||
@ApiTags('📤 Chatting')
|
@ApiTags('📤 Chatting')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class ChattingController {
|
export class ChattingController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Post('/sendText')
|
@Post('/sendText')
|
||||||
@ApiOperation({ summary: 'Send a text message' })
|
@ApiOperation({ summary: 'Send a text message' })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
|
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
if (mentionsAll(request)) {
|
if (mentionsAll(request)) {
|
||||||
@@ -72,6 +92,7 @@ export class ChattingController {
|
|||||||
description:
|
description:
|
||||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
'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) {
|
async sendImage(@Body() request: MessageImageRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
if (mentionsAll(request)) {
|
if (mentionsAll(request)) {
|
||||||
@@ -87,6 +108,7 @@ export class ChattingController {
|
|||||||
description:
|
description:
|
||||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
'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) {
|
async sendFile(@Body() request: MessageFileRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
if (mentionsAll(request)) {
|
if (mentionsAll(request)) {
|
||||||
@@ -102,6 +124,7 @@ export class ChattingController {
|
|||||||
description:
|
description:
|
||||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
'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) {
|
async sendVoice(@Body() request: MessageVoiceRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendVoice(request);
|
return whatsapp.sendVoice(request);
|
||||||
@@ -113,6 +136,7 @@ export class ChattingController {
|
|||||||
description:
|
description:
|
||||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
'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) {
|
async sendVideo(@Body() request: MessageVideoRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
if (mentionsAll(request)) {
|
if (mentionsAll(request)) {
|
||||||
@@ -122,12 +146,53 @@ export class ChattingController {
|
|||||||
return whatsapp.sendVideo(request);
|
return whatsapp.sendVideo(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/sendSticker')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Send a sticker',
|
||||||
|
description:
|
||||||
|
'The image will not be converted. It must already be in WebP format. PNG and JPEG are not supported.',
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: MessageStickerRequest,
|
||||||
|
examples: {
|
||||||
|
url: {
|
||||||
|
summary: 'From URL',
|
||||||
|
value: {
|
||||||
|
session: 'default',
|
||||||
|
chatId: '11111111111@c.us',
|
||||||
|
reply_to: null,
|
||||||
|
file: {
|
||||||
|
url: 'https://www.gstatic.com/webp/gallery/1.webp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base64webp: {
|
||||||
|
summary: 'From base64 WebP',
|
||||||
|
value: {
|
||||||
|
session: 'default',
|
||||||
|
chatId: '11111111111@c.us',
|
||||||
|
reply_to: null,
|
||||||
|
file: {
|
||||||
|
mimetype: 'image/webp',
|
||||||
|
data: 'your-base64-data-of-webp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
|
async sendSticker(@Body() request: MessageStickerRequest) {
|
||||||
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
|
return whatsapp.sendSticker(request);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/send/link-custom-preview')
|
@Post('/send/link-custom-preview')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Send a text message with a CUSTOM link preview.',
|
summary: 'Send a text message with a CUSTOM link preview.',
|
||||||
description:
|
description:
|
||||||
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
|
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendLinkCustomPreview(
|
async sendLinkCustomPreview(
|
||||||
@Body() request: MessageLinkCustomPreviewRequest,
|
@Body() request: MessageLinkCustomPreviewRequest,
|
||||||
@@ -147,6 +212,7 @@ export class ChattingController {
|
|||||||
description: 'Send Buttons',
|
description: 'Send Buttons',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendButtons(@Body() request: SendButtonsRequest) {
|
async sendButtons(@Body() request: SendButtonsRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
@@ -158,6 +224,7 @@ export class ChattingController {
|
|||||||
summary: 'Send a list message (interactive)',
|
summary: 'Send a list message (interactive)',
|
||||||
description: 'Send a List message with sections and rows',
|
description: 'Send a List message with sections and rows',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendList(@Body() request: SendListRequest) {
|
async sendList(@Body() request: SendListRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
@@ -165,6 +232,7 @@ export class ChattingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/forwardMessage')
|
@Post('/forwardMessage')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async forwardMessage(
|
async forwardMessage(
|
||||||
@Body() request: MessageForwardRequest,
|
@Body() request: MessageForwardRequest,
|
||||||
): Promise<WAMessage> {
|
): Promise<WAMessage> {
|
||||||
@@ -173,6 +241,7 @@ export class ChattingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/sendSeen')
|
@Post('/sendSeen')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async sendSeen(@Body() chat: SendSeenRequest) {
|
async sendSeen(@Body() chat: SendSeenRequest) {
|
||||||
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
|
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
|
||||||
if (!hasMessageId) {
|
if (!hasMessageId) {
|
||||||
@@ -188,6 +257,7 @@ export class ChattingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/startTyping')
|
@Post('/startTyping')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async startTyping(@Body() chat: ChatRequest) {
|
async startTyping(@Body() chat: ChatRequest) {
|
||||||
// It's infinitive action
|
// It's infinitive action
|
||||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||||
@@ -196,6 +266,7 @@ export class ChattingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/stopTyping')
|
@Post('/stopTyping')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async stopTyping(@Body() chat: ChatRequest) {
|
async stopTyping(@Body() chat: ChatRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||||
await whatsapp.stopTyping(chat);
|
await whatsapp.stopTyping(chat);
|
||||||
@@ -204,6 +275,7 @@ export class ChattingController {
|
|||||||
|
|
||||||
@Put('/reaction')
|
@Put('/reaction')
|
||||||
@ApiOperation({ summary: 'React to a message with an emoji' })
|
@ApiOperation({ summary: 'React to a message with an emoji' })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async setReaction(@Body() request: MessageReactionRequest) {
|
async setReaction(@Body() request: MessageReactionRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.setReaction(request);
|
return whatsapp.setReaction(request);
|
||||||
@@ -211,6 +283,7 @@ export class ChattingController {
|
|||||||
|
|
||||||
@Put('/star')
|
@Put('/star')
|
||||||
@ApiOperation({ summary: 'Star or unstar a message' })
|
@ApiOperation({ summary: 'Star or unstar a message' })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async setStar(@Body() request: MessageStarRequest) {
|
async setStar(@Body() request: MessageStarRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
await whatsapp.setStar(request);
|
await whatsapp.setStar(request);
|
||||||
@@ -222,6 +295,8 @@ export class ChattingController {
|
|||||||
summary: 'Send a poll with options',
|
summary: 'Send a poll with options',
|
||||||
description: 'You can use it as buttons or list replacement',
|
description: 'You can use it as buttons or list replacement',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendPoll(@Body() request: MessagePollRequest) {
|
async sendPoll(@Body() request: MessagePollRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendPoll(request);
|
return whatsapp.sendPoll(request);
|
||||||
@@ -232,6 +307,7 @@ export class ChattingController {
|
|||||||
summary: 'Vote on a poll',
|
summary: 'Vote on a poll',
|
||||||
description: 'Cast vote(s) on an existing poll message',
|
description: 'Cast vote(s) on an existing poll message',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendPollVote(@Body() request: MessagePollVoteRequest) {
|
async sendPollVote(@Body() request: MessagePollVoteRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
@@ -239,12 +315,14 @@ export class ChattingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/sendLocation')
|
@Post('/sendLocation')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async sendLocation(@Body() request: MessageLocationRequest) {
|
async sendLocation(@Body() request: MessageLocationRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendLocation(request);
|
return whatsapp.sendLocation(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/sendContactVcard')
|
@Post('/sendContactVcard')
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendContactVCard(request);
|
return whatsapp.sendContactVCard(request);
|
||||||
@@ -254,6 +332,7 @@ export class ChattingController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Reply on a button message',
|
summary: 'Reply on a button message',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async sendButtonsReply(@Body() request: MessageButtonReply) {
|
async sendButtonsReply(@Body() request: MessageButtonReply) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
@@ -262,6 +341,7 @@ export class ChattingController {
|
|||||||
|
|
||||||
@Get('/sendText')
|
@Get('/sendText')
|
||||||
@ApiOperation({ summary: 'Send a text message', deprecated: true })
|
@ApiOperation({ summary: 'Send a text message', deprecated: true })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromQuery('session')))
|
||||||
async sendTextGet(@Query() query: MessageTextQuery) {
|
async sendTextGet(@Query() query: MessageTextQuery) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||||
const msg = new MessageTextRequest();
|
const msg = new MessageTextRequest();
|
||||||
@@ -276,6 +356,7 @@ export class ChattingController {
|
|||||||
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
|
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getMessages(
|
async getMessages(
|
||||||
@Query() query: GetMessageQuery,
|
@Query() query: GetMessageQuery,
|
||||||
@@ -292,6 +373,7 @@ export class ChattingController {
|
|||||||
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
|
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||||
async DEPRECATED_checkNumberStatus(
|
async DEPRECATED_checkNumberStatus(
|
||||||
@Query() request: CheckNumberStatusQuery,
|
@Query() request: CheckNumberStatusQuery,
|
||||||
): Promise<WANumberExistResult> {
|
): Promise<WANumberExistResult> {
|
||||||
@@ -305,6 +387,7 @@ export class ChattingController {
|
|||||||
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
|
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async reply(@Body() request: MessageReplyRequest) {
|
async reply(@Body() request: MessageReplyRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.reply(request);
|
return whatsapp.reply(request);
|
||||||
@@ -312,8 +395,20 @@ export class ChattingController {
|
|||||||
|
|
||||||
@Post('/sendLinkPreview')
|
@Post('/sendLinkPreview')
|
||||||
@ApiOperation({ deprecated: true })
|
@ApiOperation({ deprecated: true })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
|
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendLinkPreview(request);
|
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,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -21,15 +22,22 @@ import {
|
|||||||
ContactRequest,
|
ContactRequest,
|
||||||
ContactsPaginationParams,
|
ContactsPaginationParams,
|
||||||
} from '../structures/contacts.dto';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/contacts')
|
@Controller('api/contacts')
|
||||||
@ApiTags('👤 Contacts')
|
@ApiTags('👤 Contacts')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class ContactsController {
|
export class ContactsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Get('/all')
|
@Get('/all')
|
||||||
@ApiOperation({ summary: 'Get all contacts' })
|
@ApiOperation({ summary: 'Get all contacts' })
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getAll(
|
async getAll(
|
||||||
@Query() query: SessionQuery,
|
@Query() query: SessionQuery,
|
||||||
@@ -45,6 +53,7 @@ export class ContactsController {
|
|||||||
description:
|
description:
|
||||||
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
|
'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) {
|
async get(@Query() query: ContactQuery) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||||
return whatsapp.getContact(query);
|
return whatsapp.getContact(query);
|
||||||
@@ -52,6 +61,7 @@ export class ContactsController {
|
|||||||
|
|
||||||
@Get('/check-exists')
|
@Get('/check-exists')
|
||||||
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
|
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||||
async checkExists(
|
async checkExists(
|
||||||
@Query() request: CheckNumberStatusQuery,
|
@Query() request: CheckNumberStatusQuery,
|
||||||
): Promise<WANumberExistResult> {
|
): Promise<WANumberExistResult> {
|
||||||
@@ -65,6 +75,7 @@ export class ContactsController {
|
|||||||
description:
|
description:
|
||||||
'Returns null if you do not have permission to read their status.',
|
'Returns null if you do not have permission to read their status.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||||
async getAbout(@Query() query: ContactQuery) {
|
async getAbout(@Query() query: ContactQuery) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||||
return whatsapp.getContactAbout(query);
|
return whatsapp.getContactAbout(query);
|
||||||
@@ -76,6 +87,7 @@ export class ContactsController {
|
|||||||
description:
|
description:
|
||||||
'If privacy settings do not allow to get the picture, the method will return null.',
|
'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 }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
|
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||||
@@ -88,6 +100,7 @@ export class ContactsController {
|
|||||||
|
|
||||||
@Post('/block')
|
@Post('/block')
|
||||||
@ApiOperation({ summary: 'Block contact' })
|
@ApiOperation({ summary: 'Block contact' })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async block(@Body() request: ContactRequest) {
|
async block(@Body() request: ContactRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.blockContact(request);
|
return whatsapp.blockContact(request);
|
||||||
@@ -95,6 +108,7 @@ export class ContactsController {
|
|||||||
|
|
||||||
@Post('/unblock')
|
@Post('/unblock')
|
||||||
@ApiOperation({ summary: 'Unblock contact' })
|
@ApiOperation({ summary: 'Unblock contact' })
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
async unblock(@Body() request: ContactRequest) {
|
async unblock(@Body() request: ContactRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.unblockContact(request);
|
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,
|
Controller,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -19,10 +20,17 @@ import {
|
|||||||
EventMessageRequest,
|
EventMessageRequest,
|
||||||
} from '../structures/events.dto';
|
} from '../structures/events.dto';
|
||||||
import { WAMessage } from '../structures/responses.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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/events')
|
@Controller('api/:session/events')
|
||||||
@ApiTags('📅 Events')
|
@ApiTags('📅 Events')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
export class EventsController {
|
export class EventsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -31,24 +32,36 @@ import {
|
|||||||
CreateGroupRequest,
|
CreateGroupRequest,
|
||||||
DescriptionRequest,
|
DescriptionRequest,
|
||||||
GroupField,
|
GroupField,
|
||||||
|
GroupJoinRequest,
|
||||||
|
GroupJoinRequestResult,
|
||||||
GroupParticipant,
|
GroupParticipant,
|
||||||
GroupsListFields,
|
GroupsListFields,
|
||||||
GroupsPaginationParams,
|
GroupsPaginationParams,
|
||||||
JoinGroupRequest,
|
JoinGroupRequest,
|
||||||
JoinGroupResponse,
|
JoinGroupResponse,
|
||||||
ParticipantsRequest,
|
ParticipantsRequest,
|
||||||
|
SettingsMemberAddMode,
|
||||||
|
SettingsMemberShareHistoryMode,
|
||||||
|
SettingsMembershipApproval,
|
||||||
SettingsSecurityChangeInfo,
|
SettingsSecurityChangeInfo,
|
||||||
SubjectRequest,
|
SubjectRequest,
|
||||||
} from '../structures/groups.dto';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/groups')
|
@Controller('api/:session/groups')
|
||||||
@ApiTags('👥 Groups')
|
@ApiTags('👥 Groups')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class GroupsController {
|
export class GroupsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Post('')
|
@Post('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Create a new group.' })
|
@ApiOperation({ summary: 'Create a new group.' })
|
||||||
createGroup(
|
createGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -59,6 +72,7 @@ export class GroupsController {
|
|||||||
|
|
||||||
@Get('join-info')
|
@Get('join-info')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get info about the group before joining.' })
|
@ApiOperation({ summary: 'Get info about the group before joining.' })
|
||||||
async joinInfoGroup(
|
async joinInfoGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -71,6 +85,7 @@ export class GroupsController {
|
|||||||
@Post('join')
|
@Post('join')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Join group via code' })
|
@ApiOperation({ summary: 'Join group via code' })
|
||||||
async joinGroup(
|
async joinGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -83,6 +98,7 @@ export class GroupsController {
|
|||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get all groups.' })
|
@ApiOperation({ summary: 'Get all groups.' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getGroups(
|
async getGroups(
|
||||||
@@ -97,6 +113,7 @@ export class GroupsController {
|
|||||||
|
|
||||||
@Get('/count')
|
@Get('/count')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get the number of groups.' })
|
@ApiOperation({ summary: 'Get the number of groups.' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getGroupsCount(
|
async getGroupsCount(
|
||||||
@@ -112,6 +129,7 @@ export class GroupsController {
|
|||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Refresh groups from the server.' })
|
@ApiOperation({ summary: 'Refresh groups from the server.' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async refreshGroups(@WorkingSessionParam session: WhatsappSession) {
|
async refreshGroups(@WorkingSessionParam session: WhatsappSession) {
|
||||||
@@ -121,6 +139,7 @@ export class GroupsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get the group.' })
|
@ApiOperation({ summary: 'Get the group.' })
|
||||||
getGroup(
|
getGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -132,6 +151,7 @@ export class GroupsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Delete the group.' })
|
@ApiOperation({ summary: 'Delete the group.' })
|
||||||
deleteGroup(
|
deleteGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -144,6 +164,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Leave the group.' })
|
@ApiOperation({ summary: 'Leave the group.' })
|
||||||
leaveGroup(
|
leaveGroup(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -155,6 +176,7 @@ export class GroupsController {
|
|||||||
@Get(':id/picture')
|
@Get(':id/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get group picture' })
|
@ApiOperation({ summary: 'Get group picture' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async getChatPicture(
|
async getChatPicture(
|
||||||
@@ -169,6 +191,7 @@ export class GroupsController {
|
|||||||
@Put(':id/picture')
|
@Put(':id/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Set group picture' })
|
@ApiOperation({ summary: 'Set group picture' })
|
||||||
async setPicture(
|
async setPicture(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -182,6 +205,7 @@ export class GroupsController {
|
|||||||
@Delete(':id/picture')
|
@Delete(':id/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Delete group picture' })
|
@ApiOperation({ summary: 'Delete group picture' })
|
||||||
async deletePicture(
|
async deletePicture(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -199,6 +223,7 @@ export class GroupsController {
|
|||||||
})
|
})
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
setDescription(
|
setDescription(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -210,6 +235,7 @@ export class GroupsController {
|
|||||||
@Put(':id/subject')
|
@Put(':id/subject')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Updates the group subject',
|
summary: 'Updates the group subject',
|
||||||
description:
|
description:
|
||||||
@@ -226,6 +252,7 @@ export class GroupsController {
|
|||||||
@Put(':id/settings/security/info-admin-only')
|
@Put(':id/settings/security/info-admin-only')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Updates the group "info admin only" settings.',
|
summary: 'Updates the group "info admin only" settings.',
|
||||||
description:
|
description:
|
||||||
@@ -242,6 +269,7 @@ export class GroupsController {
|
|||||||
@Get(':id/settings/security/info-admin-only')
|
@Get(':id/settings/security/info-admin-only')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get the group's 'info admin only' settings.",
|
summary: "Get the group's 'info admin only' settings.",
|
||||||
description:
|
description:
|
||||||
@@ -257,6 +285,7 @@ export class GroupsController {
|
|||||||
@Put(':id/settings/security/messages-admin-only')
|
@Put(':id/settings/security/messages-admin-only')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update settings - who can send messages',
|
summary: 'Update settings - who can send messages',
|
||||||
description:
|
description:
|
||||||
@@ -273,6 +302,7 @@ export class GroupsController {
|
|||||||
@Get(':id/settings/security/messages-admin-only')
|
@Get(':id/settings/security/messages-admin-only')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get settings - who can send messages',
|
summary: 'Get settings - who can send messages',
|
||||||
description: 'The group settings to only allow admins to send messages.',
|
description: 'The group settings to only allow admins to send messages.',
|
||||||
@@ -284,9 +314,163 @@ export class GroupsController {
|
|||||||
return session.getMessagesAdminsOnly(id);
|
return session.getMessagesAdminsOnly(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/settings/security/member-add-mode')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update settings - who can add new members',
|
||||||
|
description:
|
||||||
|
'Updates the group settings for who can add new members to the group - all members or admins only.',
|
||||||
|
})
|
||||||
|
setMemberAddMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: SettingsMemberAddMode,
|
||||||
|
) {
|
||||||
|
return session.setMemberAddMode(id, request.membersCanAddNewMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/settings/security/member-add-mode')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get settings - who can add new members',
|
||||||
|
description:
|
||||||
|
'The group settings for who can add new members to the group - all members or admins only.',
|
||||||
|
})
|
||||||
|
getMemberAddMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<SettingsMemberAddMode> {
|
||||||
|
return session.getMemberAddMode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/settings/security/member-share-history-mode')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Update settings - members can send message history to new members',
|
||||||
|
description:
|
||||||
|
'Updates the group settings for whether members can share message history with new members, or only admins can.',
|
||||||
|
})
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
setMemberShareHistoryMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: SettingsMemberShareHistoryMode,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return session.setMemberShareHistoryMode(
|
||||||
|
id,
|
||||||
|
request.membersCanShareHistory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/settings/security/member-share-history-mode')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get settings - members can send message history to new members',
|
||||||
|
description:
|
||||||
|
'The group settings for whether members can share message history with new members, or only admins can.',
|
||||||
|
})
|
||||||
|
getMemberShareHistoryMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<SettingsMemberShareHistoryMode> {
|
||||||
|
return session.getMemberShareHistoryMode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/settings/security/membership-approval')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update settings - approve new members',
|
||||||
|
description:
|
||||||
|
'Enables or disables admin approval for users requesting to join the group.',
|
||||||
|
})
|
||||||
|
setMembershipApprovalMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: SettingsMembershipApproval,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return session.setMembershipApprovalMode(
|
||||||
|
id,
|
||||||
|
request.newMembersApprovalRequired,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/settings/security/membership-approval')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get settings - approve new members',
|
||||||
|
description:
|
||||||
|
'Returns whether admin approval is required for users requesting to join the group.',
|
||||||
|
})
|
||||||
|
getMembershipApprovalMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<SettingsMembershipApproval> {
|
||||||
|
return session.getMembershipApprovalMode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/participants/join-requests')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get pending requests to join the group',
|
||||||
|
})
|
||||||
|
getGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<GroupJoinRequest[]> {
|
||||||
|
return session.getGroupJoinRequests(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/participants/join-requests/approve')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Approve pending requests to join the group',
|
||||||
|
})
|
||||||
|
approveGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: ParticipantsRequest,
|
||||||
|
): Promise<GroupJoinRequestResult[]> {
|
||||||
|
return session.approveGroupJoinRequests(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/participants/join-requests/reject')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Reject pending requests to join the group',
|
||||||
|
})
|
||||||
|
rejectGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: ParticipantsRequest,
|
||||||
|
): Promise<GroupJoinRequestResult[]> {
|
||||||
|
return session.rejectGroupJoinRequests(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/invite-code')
|
@Get(':id/invite-code')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Gets the invite code for the group.' })
|
@ApiOperation({ summary: 'Gets the invite code for the group.' })
|
||||||
getInviteCode(
|
getInviteCode(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -299,6 +483,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Invalidates the current group invite code and generates a new one.',
|
'Invalidates the current group invite code and generates a new one.',
|
||||||
@@ -313,6 +498,7 @@ export class GroupsController {
|
|||||||
@Get(':id/participants/')
|
@Get(':id/participants/')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get participants' })
|
@ApiOperation({ summary: 'Get participants' })
|
||||||
getParticipants(
|
getParticipants(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -324,6 +510,7 @@ export class GroupsController {
|
|||||||
@Get(':id/participants/v2')
|
@Get(':id/participants/v2')
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get group participants.' })
|
@ApiOperation({ summary: 'Get group participants.' })
|
||||||
getGroupParticipants(
|
getGroupParticipants(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -336,6 +523,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Add participants' })
|
@ApiOperation({ summary: 'Add participants' })
|
||||||
addParticipants(
|
addParticipants(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -349,6 +537,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Remove participants',
|
summary: 'Remove participants',
|
||||||
})
|
})
|
||||||
@@ -364,6 +553,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Promote participants to admin users.' })
|
@ApiOperation({ summary: 'Promote participants to admin users.' })
|
||||||
promoteToAdmin(
|
promoteToAdmin(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -377,6 +567,7 @@ export class GroupsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Demotes participants to regular users.' })
|
@ApiOperation({ summary: 'Demotes participants to regular users.' })
|
||||||
demoteToAdmin(
|
demoteToAdmin(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@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 { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import { HealthCheck } from '@nestjs/terminus';
|
import { HealthCheck } from '@nestjs/terminus';
|
||||||
|
|
||||||
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
@ApiTags('🔍 Observability')
|
@ApiTags('🔍 Observability')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanServer(Action.Manage))
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private wahaHealth: WAHAHealthCheckService) {}
|
constructor(private wahaHealth: WAHAHealthCheckService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -26,15 +27,22 @@ import {
|
|||||||
import * as lodash from 'lodash';
|
import * as lodash from 'lodash';
|
||||||
|
|
||||||
import { SessionManager } from '../core/abc/manager.abc';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/labels')
|
@Controller('api/:session/labels')
|
||||||
@ApiTags('🏷️ Labels')
|
@ApiTags('🏷️ Labels')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class LabelsController {
|
export class LabelsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get all labels' })
|
@ApiOperation({ summary: 'Get all labels' })
|
||||||
getAll(@WorkingSessionParam session: WhatsappSession): Promise<Label[]> {
|
getAll(@WorkingSessionParam session: WhatsappSession): Promise<Label[]> {
|
||||||
return session.getLabels();
|
return session.getLabels();
|
||||||
@@ -42,6 +50,7 @@ export class LabelsController {
|
|||||||
|
|
||||||
@Post('/')
|
@Post('/')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Create a new label' })
|
@ApiOperation({ summary: 'Create a new label' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async create(
|
async create(
|
||||||
@@ -67,6 +76,7 @@ export class LabelsController {
|
|||||||
|
|
||||||
@Put('/:labelId')
|
@Put('/:labelId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Update a label' })
|
@ApiOperation({ summary: 'Update a label' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async update(
|
async update(
|
||||||
@@ -98,6 +108,7 @@ export class LabelsController {
|
|||||||
|
|
||||||
@Delete('/:labelId')
|
@Delete('/:labelId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Delete a label' })
|
@ApiOperation({ summary: 'Delete a label' })
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async delete(
|
async delete(
|
||||||
@@ -115,6 +126,7 @@ export class LabelsController {
|
|||||||
@Get('/chats/:chatId')
|
@Get('/chats/:chatId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get labels for the chat' })
|
@ApiOperation({ summary: 'Get labels for the chat' })
|
||||||
getChatLabels(
|
getChatLabels(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -126,6 +138,7 @@ export class LabelsController {
|
|||||||
@Put('/chats/:chatId')
|
@Put('/chats/:chatId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Save labels for the chat' })
|
@ApiOperation({ summary: 'Save labels for the chat' })
|
||||||
putChatLabels(
|
putChatLabels(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -137,6 +150,7 @@ export class LabelsController {
|
|||||||
|
|
||||||
@Get('/:labelId/chats')
|
@Get('/:labelId/chats')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get chats by label' })
|
@ApiOperation({ summary: 'Get chats by label' })
|
||||||
getChatsByLabel(
|
getChatsByLabel(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
|||||||
|
|
||||||
import { SessionManager } from '../core/abc/manager.abc';
|
import { SessionManager } from '../core/abc/manager.abc';
|
||||||
import { isLidUser } from '@waha/core/utils/jids';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/lids')
|
@Controller('api/:session/lids')
|
||||||
@ApiTags('👤 Contacts')
|
@ApiTags('👤 Contacts')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
export class LidsController {
|
export class LidsController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -21,10 +22,17 @@ import {
|
|||||||
|
|
||||||
import { WhatsappSession } from '../core/abc/session.abc';
|
import { WhatsappSession } from '../core/abc/session.abc';
|
||||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/media')
|
@Controller('api/:session/media')
|
||||||
@ApiTags('🖼️ Media')
|
@ApiTags('🖼️ Media')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
class MediaController {
|
class MediaController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
|
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
|
||||||
@@ -20,15 +21,22 @@ import {
|
|||||||
WAHAChatPresences,
|
WAHAChatPresences,
|
||||||
WAHASessionPresence,
|
WAHASessionPresence,
|
||||||
} from '../structures/presence.dto';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/presence')
|
@Controller('api/:session/presence')
|
||||||
@ApiTags('✅ Presence')
|
@ApiTags('✅ Presence')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class PresenceController {
|
export class PresenceController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Post('')
|
@Post('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Set session presence' })
|
@ApiOperation({ summary: 'Set session presence' })
|
||||||
setPresence(
|
setPresence(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -59,6 +67,7 @@ export class PresenceController {
|
|||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get all subscribed presence information.' })
|
@ApiOperation({ summary: 'Get all subscribed presence information.' })
|
||||||
getPresenceAll(
|
getPresenceAll(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -69,6 +78,7 @@ export class PresenceController {
|
|||||||
@Get(':chatId')
|
@Get(':chatId')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Get the presence for the chat id. If it hasn't been subscribed - it also subscribes to it.",
|
"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')
|
@Post(':chatId/subscribe')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ChatIdApiParam
|
@ChatIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Subscribe to presence events for the chat.',
|
summary: 'Subscribe to presence events for the chat.',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -22,15 +23,22 @@ import {
|
|||||||
ProfilePictureRequest,
|
ProfilePictureRequest,
|
||||||
ProfileStatusRequest,
|
ProfileStatusRequest,
|
||||||
} from '@waha/structures/profile.dto';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/profile')
|
@Controller('api/:session/profile')
|
||||||
@ApiTags('🆔 Profile')
|
@ApiTags('🆔 Profile')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class ProfileController {
|
export class ProfileController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Get my profile' })
|
@ApiOperation({ summary: 'Get my profile' })
|
||||||
async getMyProfile(
|
async getMyProfile(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -49,6 +57,7 @@ export class ProfileController {
|
|||||||
|
|
||||||
@Put('/name')
|
@Put('/name')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
@ApiOperation({ summary: 'Set my profile name' })
|
@ApiOperation({ summary: 'Set my profile name' })
|
||||||
async setProfileName(
|
async setProfileName(
|
||||||
@@ -61,6 +70,7 @@ export class ProfileController {
|
|||||||
|
|
||||||
@Put('/status')
|
@Put('/status')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
@ApiOperation({ summary: 'Set profile status (About)' })
|
@ApiOperation({ summary: 'Set profile status (About)' })
|
||||||
async setProfileStatus(
|
async setProfileStatus(
|
||||||
@@ -73,6 +83,7 @@ export class ProfileController {
|
|||||||
|
|
||||||
@Put('/picture')
|
@Put('/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Set profile picture' })
|
@ApiOperation({ summary: 'Set profile picture' })
|
||||||
async setProfilePicture(
|
async setProfilePicture(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -84,6 +95,7 @@ export class ProfileController {
|
|||||||
|
|
||||||
@Delete('/picture')
|
@Delete('/picture')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Delete profile picture' })
|
@ApiOperation({ summary: 'Delete profile picture' })
|
||||||
async deleteProfilePicture(
|
async deleteProfilePicture(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
|||||||
@@ -4,23 +4,35 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import { SessionManager } from '../core/abc/manager.abc';
|
import { SessionManager } from '../core/abc/manager.abc';
|
||||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||||
import { SessionQuery } from '../structures/base.dto';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api')
|
@Controller('api')
|
||||||
@ApiTags('🖼️ Screenshot')
|
@ApiTags('📱 Pairing')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromQuery('session')))
|
||||||
export class ScreenshotController {
|
export class ScreenshotController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Get('/screenshot')
|
@Get('/screenshot')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)',
|
||||||
|
})
|
||||||
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
|
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
|
||||||
@ApiFileAcceptHeader('image/jpeg')
|
@ApiFileAcceptHeader('image/jpeg')
|
||||||
async screenshot(
|
async screenshot(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -22,10 +23,16 @@ import {
|
|||||||
import { sleep } from '@waha/utils/promiseTimeout';
|
import { sleep } from '@waha/utils/promiseTimeout';
|
||||||
import { VERSION } from '@waha/version';
|
import { VERSION } from '@waha/version';
|
||||||
import * as lodash from 'lodash';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/server')
|
@Controller('api/server')
|
||||||
@ApiTags('🔍 Observability')
|
@ApiTags('🔍 Observability')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class ServerController {
|
export class ServerController {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@@ -35,12 +42,14 @@ export class ServerController {
|
|||||||
|
|
||||||
@Get('version')
|
@Get('version')
|
||||||
@ApiOperation({ summary: 'Get the version of the server' })
|
@ApiOperation({ summary: 'Get the version of the server' })
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
get(): WAHAEnvironment {
|
get(): WAHAEnvironment {
|
||||||
return VERSION;
|
return VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('environment')
|
@Get('environment')
|
||||||
@ApiOperation({ summary: 'Get the server environment' })
|
@ApiOperation({ summary: 'Get the server environment' })
|
||||||
|
@CheckPolicies(CanServer(Action.Manage))
|
||||||
environment(
|
environment(
|
||||||
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
|
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
@@ -67,6 +76,7 @@ export class ServerController {
|
|||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
@ApiOperation({ summary: 'Get the server status' })
|
@ApiOperation({ summary: 'Get the server status' })
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
async status(): Promise<ServerStatusResponse> {
|
async status(): Promise<ServerStatusResponse> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const uptime = Math.floor(process.uptime() * 1000);
|
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, " +
|
"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',
|
'so you can use this endpoint to restart the server',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanServer(Action.Manage))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async stop(@Body() request: StopRequest): Promise<StopResponse> {
|
async stop(@Body() request: StopRequest): Promise<StopResponse> {
|
||||||
const timeout = 1_000;
|
const timeout = 1_000;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Query,
|
Query,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -25,10 +26,17 @@ import {
|
|||||||
CpuProfileQuery,
|
CpuProfileQuery,
|
||||||
} from '@waha/structures/server.debug.dto';
|
} from '@waha/structures/server.debug.dto';
|
||||||
import { createReadStream } from 'fs';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/server/debug')
|
@Controller('api/server/debug')
|
||||||
@ApiTags('🔍 Observability')
|
@ApiTags('🔍 Observability')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanServer(Action.Manage))
|
||||||
export class ServerDebugController {
|
export class ServerDebugController {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private readonly enabled: boolean;
|
private readonly enabled: boolean;
|
||||||
|
|||||||
+102
-155
@@ -3,69 +3,76 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Inject,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
|
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 {
|
import {
|
||||||
SessionApiParam,
|
SessionApiParam,
|
||||||
SessionParam,
|
SessionParam,
|
||||||
} from '@waha/nestjs/params/SessionApiParam';
|
} from '@waha/nestjs/params/SessionApiParam';
|
||||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||||
import {
|
|
||||||
AppsService,
|
|
||||||
IAppsService,
|
|
||||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
|
||||||
import {
|
import {
|
||||||
SessionLogoutDeprecatedRequest,
|
SessionLogoutDeprecatedRequest,
|
||||||
SessionStartDeprecatedRequest,
|
SessionStartDeprecatedRequest,
|
||||||
SessionStopDeprecatedRequest,
|
SessionStopDeprecatedRequest,
|
||||||
} from '@waha/structures/sessions.deprecated.dto';
|
} 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 { WhatsappSession } from '../core/abc/session.abc';
|
||||||
import {
|
import {
|
||||||
ListSessionsQuery,
|
ListSessionsQuery,
|
||||||
MeInfo,
|
MeInfo,
|
||||||
SessionExpand,
|
MessageCappingData,
|
||||||
SessionInfoQuery,
|
ReachoutTimelockData,
|
||||||
SessionCreateRequest,
|
SessionCreateRequest,
|
||||||
SessionDTO,
|
SessionDTO,
|
||||||
|
SessionExpand,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
|
SessionInfoQuery,
|
||||||
|
SessionLogoutRequest,
|
||||||
SessionUpdateRequest,
|
SessionUpdateRequest,
|
||||||
} from '../structures/sessions.dto';
|
} from '../structures/sessions.dto';
|
||||||
import { SessionExamples } from './sessions.examples';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/sessions')
|
@Controller('api/sessions')
|
||||||
@ApiTags('🖥️ Sessions')
|
@ApiTags('🖥️ Sessions')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
class SessionsController {
|
class SessionsController {
|
||||||
constructor(
|
constructor(private readonly sessionService: SessionService) {}
|
||||||
private manager: SessionManager,
|
|
||||||
@Inject(AppsService) private appsService: IAppsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private withLock(name: string, fn: () => any) {
|
|
||||||
return this.manager.withLock(name, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@ApiOperation({ summary: 'List all sessions' })
|
@ApiOperation({ summary: 'List all sessions' })
|
||||||
|
@CheckPolicies(CanSession(Action.List))
|
||||||
|
@ApiOAuth2(['read:items'])
|
||||||
async list(
|
async list(
|
||||||
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
|
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
|
||||||
|
@Req() req,
|
||||||
): Promise<SessionInfo[]> {
|
): 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)) {
|
if (query.expand?.includes(SessionExpand.apps)) {
|
||||||
for (const session of sessions) {
|
await this.sessionService.expandSessionApps(sessions);
|
||||||
session.apps = await this.appsService.list(this.manager, session.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
@@ -73,17 +80,15 @@ class SessionsController {
|
|||||||
@Get('/:session')
|
@Get('/:session')
|
||||||
@ApiOperation({ summary: 'Get session information' })
|
@ApiOperation({ summary: 'Get session information' })
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async get(
|
async get(
|
||||||
@Param('session') name: string,
|
@Param('session') name: string,
|
||||||
@Query() query: SessionInfoQuery,
|
@Query() query: SessionInfoQuery,
|
||||||
): Promise<SessionInfo> {
|
): Promise<SessionInfo> {
|
||||||
const session = await this.manager.getSessionInfo(name);
|
const session = await this.sessionService.getSession(name);
|
||||||
if (session === null) {
|
|
||||||
throw new NotFoundException('Session not found');
|
|
||||||
}
|
|
||||||
if (query.expand?.includes(SessionExpand.apps)) {
|
if (query.expand?.includes(SessionExpand.apps)) {
|
||||||
session.apps = await this.appsService.list(this.manager, name);
|
await this.sessionService.expandSessionApps([session]);
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -91,8 +96,42 @@ class SessionsController {
|
|||||||
@Get(':session/me')
|
@Get(':session/me')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ApiOperation({ summary: 'Get information about the authenticated account' })
|
@ApiOperation({ summary: 'Get information about the authenticated account' })
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
|
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
|
||||||
return session.getSessionMeInfo();
|
return this.sessionService.getSessionMe(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':session/capping')
|
||||||
|
@SessionApiParam
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Fetch the account new-chat message capping (per-cycle quota)',
|
||||||
|
description:
|
||||||
|
'Fetch a fresh new-chat message capping (quota) state from WhatsApp. ' +
|
||||||
|
'The same value is also available under me.messageCapping in the session ' +
|
||||||
|
'info, and changes are pushed through the session.status event.',
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
fetchMessageCapping(
|
||||||
|
@SessionParam session: WhatsappSession,
|
||||||
|
): Promise<MessageCappingData> {
|
||||||
|
return session.fetchMessageCapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':session/timelock')
|
||||||
|
@SessionApiParam
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Fetch the account reachout timelock state',
|
||||||
|
description:
|
||||||
|
'Fetch a fresh reachout timelock state from WhatsApp - the restriction ' +
|
||||||
|
'behind "server returned error 463" when messaging new contacts. ' +
|
||||||
|
'The same value is also available under me.reachoutTimelock in the session ' +
|
||||||
|
'info, and changes are pushed through the session.status event.',
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
fetchReachoutTimelock(
|
||||||
|
@SessionParam session: WhatsappSession,
|
||||||
|
): Promise<ReachoutTimelockData> {
|
||||||
|
return session.fetchReachoutTimelock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('')
|
@Post('')
|
||||||
@@ -102,72 +141,23 @@ class SessionsController {
|
|||||||
'Create session a new session (and start it at the same time if required).',
|
'Create session a new session (and start it at the same time if required).',
|
||||||
})
|
})
|
||||||
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
|
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
|
||||||
|
@CheckPolicies(CanSession(Action.Create))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
|
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
|
||||||
const name = request.name || generatePrefixedId('session');
|
return this.sessionService.createSession(request);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':session')
|
@Put(':session')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Update a session' })
|
||||||
summary: 'Update a session',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
|
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
|
||||||
|
@CheckPolicies(CanSession(Action.Setting, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
|
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
|
||||||
async update(
|
async update(
|
||||||
@Param('session') name: string,
|
@Param('session') name: string,
|
||||||
@Body() request: SessionUpdateRequest,
|
@Body() request: SessionUpdateRequest,
|
||||||
): Promise<SessionDTO> {
|
): Promise<SessionDTO> {
|
||||||
await this.withLock(name, async () => {
|
return this.sessionService.updateSession(name, request);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':session')
|
@Delete(':session')
|
||||||
@@ -177,15 +167,10 @@ class SessionsController {
|
|||||||
description:
|
description:
|
||||||
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
|
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async delete(@Param('session') name: string): Promise<void> {
|
async delete(@Param('session') name: string): Promise<void> {
|
||||||
await this.withLock(name, async () => {
|
return this.sessionService.deleteSession(name);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':session/start')
|
@Post(':session/start')
|
||||||
@@ -195,17 +180,10 @@ class SessionsController {
|
|||||||
description:
|
description:
|
||||||
'Start the session with the given name. The session must exist. Idempotent operation.',
|
'Start the session with the given name. The session must exist. Idempotent operation.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async start(@Param('session') name: string): Promise<SessionDTO> {
|
async start(@Param('session') name: string): Promise<SessionDTO> {
|
||||||
await this.withLock(name, async () => {
|
return this.sessionService.startSession(name);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':session/stop')
|
@Post(':session/stop')
|
||||||
@@ -214,13 +192,10 @@ class SessionsController {
|
|||||||
summary: 'Stop the session',
|
summary: 'Stop the session',
|
||||||
description: 'Stop the session with the given name. Idempotent operation.',
|
description: 'Stop the session with the given name. Idempotent operation.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async stop(@Param('session') name: string): Promise<SessionDTO> {
|
async stop(@Param('session') name: string): Promise<SessionDTO> {
|
||||||
await this.withLock(name, async () => {
|
return this.sessionService.stopSession(name);
|
||||||
await this.manager.unassign(name);
|
|
||||||
await this.manager.stop(name, false);
|
|
||||||
});
|
|
||||||
return await this.manager.getSessionInfo(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':session/logout')
|
@Post(':session/logout')
|
||||||
@@ -229,22 +204,14 @@ class SessionsController {
|
|||||||
summary: 'Logout from the session',
|
summary: 'Logout from the session',
|
||||||
description: 'Logout the session, restart a session if it was not STOPPED',
|
description: 'Logout the session, restart a session if it was not STOPPED',
|
||||||
})
|
})
|
||||||
|
@ApiBody({ type: SessionLogoutRequest, required: false })
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async logout(@Param('session') name: string): Promise<SessionDTO> {
|
async logout(
|
||||||
await this.withLock(name, async () => {
|
@Param('session') name: string,
|
||||||
const exists = await this.manager.exists(name);
|
@Body() request?: SessionLogoutRequest,
|
||||||
if (!exists) {
|
): Promise<SessionDTO> {
|
||||||
throw new NotFoundException('Session not found');
|
return this.sessionService.logoutSession(name, request);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':session/restart')
|
@Post(':session/restart')
|
||||||
@@ -253,10 +220,10 @@ class SessionsController {
|
|||||||
summary: 'Restart the session',
|
summary: 'Restart the session',
|
||||||
description: 'Restart the session with the given name.',
|
description: 'Restart the session with the given name.',
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async restart(@Param('session') name: string): Promise<SessionDTO> {
|
async restart(@Param('session') name: string): Promise<SessionDTO> {
|
||||||
await this.manager.restart(name);
|
return this.sessionService.restartSession(name);
|
||||||
return await this.manager.getSessionInfo(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/start/')
|
@Post('/start/')
|
||||||
@@ -266,24 +233,22 @@ class SessionsController {
|
|||||||
'Create session (if not exists) or update a config (if exists) and start it.',
|
'Create session (if not exists) or update a config (if exists) and start it.',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||||
async DEPRACATED_start(
|
async DEPRACATED_start(
|
||||||
@Body() request: SessionStartDeprecatedRequest,
|
@Body() request: SessionStartDeprecatedRequest,
|
||||||
): Promise<SessionDTO> {
|
): Promise<SessionDTO> {
|
||||||
const name = request.name;
|
|
||||||
if (!request.name) {
|
if (!request.name) {
|
||||||
throw new UnprocessableEntityException('Session name is required');
|
throw new UnprocessableEntityException('Session name is required');
|
||||||
}
|
}
|
||||||
if (this.manager.isRunning(name)) {
|
if (this.sessionService.isSessionRunning(request.name)) {
|
||||||
const msg = `Session '${name}' is already started.`;
|
throw new UnprocessableEntityException(
|
||||||
throw new UnprocessableEntityException(msg);
|
`Session '${request.name}' is already started.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return this.sessionService.upsertAndStartSession(
|
||||||
return await this.withLock(name, async () => {
|
request.name,
|
||||||
const config = request.config;
|
request.config,
|
||||||
await this.manager.upsert(name, config);
|
);
|
||||||
await this.manager.assign(name);
|
|
||||||
return await this.manager.start(name);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/stop/')
|
@Post('/stop/')
|
||||||
@@ -292,29 +257,18 @@ class SessionsController {
|
|||||||
description: 'Stop session and Logout by default.',
|
description: 'Stop session and Logout by default.',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||||
async DEPRECATED_stop(
|
async DEPRECATED_stop(
|
||||||
@Body() request: SessionStopDeprecatedRequest,
|
@Body() request: SessionStopDeprecatedRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!request.name) {
|
if (!request.name) {
|
||||||
throw new UnprocessableEntityException('Session name is required');
|
throw new UnprocessableEntityException('Session name is required');
|
||||||
}
|
}
|
||||||
const name = request.name;
|
|
||||||
if (request.logout) {
|
if (request.logout) {
|
||||||
// Old API did remove the session complete
|
await this.sessionService.deleteSession(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);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await this.withLock(name, async () => {
|
await this.sessionService.stopSession(request.name);
|
||||||
await this.manager.unassign(name);
|
|
||||||
await this.manager.stop(name, false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/logout/')
|
@Post('/logout/')
|
||||||
@@ -323,21 +277,14 @@ class SessionsController {
|
|||||||
description: 'Stop, Logout and Delete session.',
|
description: 'Stop, Logout and Delete session.',
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||||
async DEPRECATED_logout(
|
async DEPRECATED_logout(
|
||||||
@Body() request: SessionLogoutDeprecatedRequest,
|
@Body() request: SessionLogoutDeprecatedRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!request.name) {
|
if (!request.name) {
|
||||||
throw new UnprocessableEntityException('Session name is required');
|
throw new UnprocessableEntityException('Session name is required');
|
||||||
}
|
}
|
||||||
const name = request.name;
|
await this.sessionService.deleteSession(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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
|
||||||
export const SessionExamples = {
|
export const SessionExamples = {
|
||||||
basic: {
|
basic: {
|
||||||
|
|||||||
@@ -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 { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
SessionApiParam,
|
SessionApiParam,
|
||||||
@@ -15,15 +15,22 @@ import {
|
|||||||
VideoStatus,
|
VideoStatus,
|
||||||
VoiceStatus,
|
VoiceStatus,
|
||||||
} from '../structures/status.dto';
|
} 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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/:session/status')
|
@Controller('api/:session/status')
|
||||||
@ApiTags('🟢 Status')
|
@ApiTags('🟢 Status')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
class StatusController {
|
class StatusController {
|
||||||
constructor(private manager: SessionManager) {}
|
constructor(private manager: SessionManager) {}
|
||||||
|
|
||||||
@Post('text')
|
@Post('text')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Send text status' })
|
@ApiOperation({ summary: 'Send text status' })
|
||||||
sendTextStatus(
|
sendTextStatus(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -34,6 +41,7 @@ class StatusController {
|
|||||||
|
|
||||||
@Post('image')
|
@Post('image')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Send image status' })
|
@ApiOperation({ summary: 'Send image status' })
|
||||||
sendImageStatus(
|
sendImageStatus(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -44,6 +52,7 @@ class StatusController {
|
|||||||
|
|
||||||
@Post('voice')
|
@Post('voice')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Send voice status' })
|
@ApiOperation({ summary: 'Send voice status' })
|
||||||
sendVoiceStatus(
|
sendVoiceStatus(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -54,6 +63,7 @@ class StatusController {
|
|||||||
|
|
||||||
@Post('video')
|
@Post('video')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'Send video status' })
|
@ApiOperation({ summary: 'Send video status' })
|
||||||
sendVideoStatus(
|
sendVideoStatus(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -64,6 +74,7 @@ class StatusController {
|
|||||||
|
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({ summary: 'DELETE sent status' })
|
@ApiOperation({ summary: 'DELETE sent status' })
|
||||||
deleteStatus(
|
deleteStatus(
|
||||||
@WorkingSessionParam session: WhatsappSession,
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
@@ -74,6 +85,7 @@ class StatusController {
|
|||||||
|
|
||||||
@Get('new-message-id')
|
@Get('new-message-id')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate message ID you can use to batch contacts',
|
summary: 'Generate message ID you can use to batch contacts',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
ApiExtraModels,
|
|
||||||
ApiOperation,
|
|
||||||
ApiSecurity,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { WAHAEnvironment } from '../structures/environment.dto';
|
import { WAHAEnvironment } from '../structures/environment.dto';
|
||||||
import { VERSION } from '../version';
|
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')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/version')
|
@Controller('api/version')
|
||||||
@ApiTags('🔍 Observability')
|
@ApiTags('🔍 Observability')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
export class VersionController {
|
export class VersionController {
|
||||||
@Get('')
|
@Get('')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
|
|||||||
import { EventWildUnmask } from '@waha/utils/events';
|
import { EventWildUnmask } from '@waha/utils/events';
|
||||||
import { generatePrefixedId } from '@waha/utils/ids';
|
import { generatePrefixedId } from '@waha/utils/ids';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import * as url from 'url';
|
import { URL } from 'url';
|
||||||
import { Server } from 'ws';
|
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 {
|
export enum WebSocketCloseCode {
|
||||||
NORMAL = 1000,
|
NORMAL = 1000,
|
||||||
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
|
|||||||
constructor(
|
constructor(
|
||||||
private manager: SessionManager,
|
private manager: SessionManager,
|
||||||
private auth: WebSocketAuth,
|
private auth: WebSocketAuth,
|
||||||
|
private readonly casl: CaslAbilityFactory,
|
||||||
) {
|
) {
|
||||||
this.logger = new Logger('WebsocketGateway');
|
this.logger = new Logger('WebsocketGateway');
|
||||||
this.heartbeat = new WebsocketHeartbeatJob(
|
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
|
// wsc - websocket client
|
||||||
socket.id = generatePrefixedId('wsc');
|
socket.id = generatePrefixedId('wsc');
|
||||||
|
|
||||||
if (!this.auth.validateRequest(request)) {
|
const user = await this.auth.validateRequest(request);
|
||||||
|
if (!user) {
|
||||||
// Not authorized - close connection
|
// Not authorized - close connection
|
||||||
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
|
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -75,10 +83,21 @@ export class WebsocketGatewayCore
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
|
||||||
const params = this.getParams(request);
|
const params = this.getParams(request);
|
||||||
const session: string = params.session;
|
let session: string = params.session;
|
||||||
const events: WAHAEvents[] = params.events;
|
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 = params.events as WAHAEvents[];
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
||||||
);
|
);
|
||||||
@@ -86,12 +105,17 @@ export class WebsocketGatewayCore
|
|||||||
const sub = this.manager
|
const sub = this.manager
|
||||||
.getSessionEvents(session, events)
|
.getSessionEvents(session, events)
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
|
setImmediate(() => {
|
||||||
socket.send(JSON.stringify(data), (err) => {
|
this.logger.debug(
|
||||||
if (!err) {
|
`Sending data to client, event.id: ${data.id}`,
|
||||||
return;
|
data,
|
||||||
}
|
);
|
||||||
this.logger.error(`Error sending data to client: ${err}`);
|
socket.send(JSON.stringify(data), (err) => {
|
||||||
|
if (!err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.error(`Error sending data to client: ${err}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
@@ -101,15 +125,19 @@ export class WebsocketGatewayCore
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getParams(request: IncomingMessage) {
|
private getParams(request: IncomingMessage) {
|
||||||
const query = url.parse(request.url, true).query;
|
// We need only search params, so localhost is fine here
|
||||||
const session = (query.session as string) || '*';
|
const query = new URL(request.url, 'http://localhost').searchParams;
|
||||||
let paramsEvents = (query.events as string[]) || '*';
|
const session = query.get('session') || '*';
|
||||||
// if params events string - split by ","
|
const paramsEvents = query.getAll('events');
|
||||||
if (typeof paramsEvents === 'string') {
|
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
|
||||||
paramsEvents = paramsEvents.split(',');
|
const eventsList = eventsRaw.flatMap((value) => value.split(','));
|
||||||
|
const result = this.eventUnmask.unmask(eventsList);
|
||||||
|
if (result.unknown.length > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Ignoring unknown websocket events: ${result.unknown.join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const events = this.eventUnmask.unmask(paramsEvents);
|
return { session, events: result.events };
|
||||||
return { session, events };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(socket: WebSocket): any {
|
handleDisconnect(socket: WebSocket): any {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class AxiosLogging {
|
|||||||
|
|
||||||
// Log debug for 2xx/3xx responses
|
// Log debug for 2xx/3xx responses
|
||||||
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
|
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
|
||||||
if (type.startsWith('application/json')) {
|
if (type?.startsWith('application/json')) {
|
||||||
const data = JSON.stringify(response.data);
|
const data = JSON.stringify(response.data);
|
||||||
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
|
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class JobLoggerWrapper implements ILogger {
|
|||||||
this.job
|
this.job
|
||||||
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
|
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logger.error('Error logging message to job', err);
|
this.logger.error(err, 'Error logging message to job');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -9,31 +10,49 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import {
|
import {
|
||||||
AppsService,
|
AppsService,
|
||||||
IAppsService,
|
IAppsService,
|
||||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
} from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
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,
|
||||||
|
FromParam,
|
||||||
|
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 { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||||
|
|
||||||
|
import { GetApp } from '../apps/registry';
|
||||||
import { App } from '../dto/app.dto';
|
import { App } from '../dto/app.dto';
|
||||||
import { ListAppsQuery } from '../dto/query.dto';
|
import { ListAppsQuery } from '../dto/query.dto';
|
||||||
|
|
||||||
@ApiSecurity('api_key')
|
@ApiSecurity('api_key')
|
||||||
@Controller('api/apps')
|
@Controller('api/apps')
|
||||||
@ApiTags('🧩 Apps')
|
@ApiTags('🧩 Apps')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
export class AppsController {
|
export class AppsController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AppsService)
|
@Inject(AppsService)
|
||||||
private appsService: IAppsService,
|
private appsService: IAppsService,
|
||||||
private manager: SessionManager,
|
private manager: SessionManager,
|
||||||
|
private resolver: UniqueAppResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@ApiOperation({ summary: 'List all apps for a session' })
|
@ApiOperation({ summary: 'List all apps for a session' })
|
||||||
|
@CheckPolicies(CanSession(Action.App, FromQuery('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async list(
|
async list(
|
||||||
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
|
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
|
||||||
@@ -43,11 +62,16 @@ export class AppsController {
|
|||||||
|
|
||||||
@Post('/')
|
@Post('/')
|
||||||
@ApiOperation({ summary: 'Create a new app' })
|
@ApiOperation({ summary: 'Create a new app' })
|
||||||
|
@CheckPolicies(CanSession(Action.App, FromBody('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async create(@Body() app: App): Promise<App> {
|
async create(@Body() app: App): Promise<App> {
|
||||||
const result = await this.appsService.create(this.manager, app);
|
const result = await this.appsService.create(this.manager, app);
|
||||||
const isRunning = this.manager.isRunning(app.session);
|
const isRunning = this.manager.isRunning(app.session);
|
||||||
if (isRunning && app.enabled) {
|
if (
|
||||||
|
isRunning &&
|
||||||
|
app.enabled &&
|
||||||
|
GetApp(app.app)?.definition.restartOnChange
|
||||||
|
) {
|
||||||
await this.manager.restart(app.session);
|
await this.manager.restart(app.session);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -55,15 +79,39 @@ export class AppsController {
|
|||||||
|
|
||||||
@Get('/:id')
|
@Get('/:id')
|
||||||
@ApiOperation({ summary: 'Get app by ID' })
|
@ApiOperation({ summary: 'Get app by ID' })
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async get(@Param('id') id: string): Promise<App> {
|
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
|
||||||
return await this.appsService.get(this.manager, id);
|
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')
|
@Put('/:id')
|
||||||
@ApiOperation({ summary: 'Update an existing app' })
|
@ApiOperation({ summary: 'Update an existing app' })
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@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) {
|
if (!app.id) {
|
||||||
app.id = id;
|
app.id = id;
|
||||||
} else if (app.id !== id) {
|
} else if (app.id !== id) {
|
||||||
@@ -73,21 +121,65 @@ export class AppsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.appsService.upsert(this.manager, app);
|
const result = await this.appsService.upsert(this.manager, app);
|
||||||
const isRunning = this.manager.isRunning(app.session);
|
const isRunning = this.manager.isRunning(result.session);
|
||||||
if (isRunning) {
|
if (isRunning && GetApp(result.app)?.definition.restartOnChange) {
|
||||||
await this.manager.restart(app.session);
|
await this.manager.restart(result.session);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('/:id')
|
@Delete('/:id')
|
||||||
@ApiOperation({ summary: 'Delete an app' })
|
@ApiOperation({ summary: 'Delete an app' })
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@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 app = await this.appsService.delete(this.manager, id);
|
||||||
const isRunning = this.manager.isRunning(app.session);
|
const isRunning = this.manager.isRunning(app.session);
|
||||||
if (isRunning) {
|
if (isRunning && GetApp(app.app)?.definition.restartOnChange) {
|
||||||
await this.manager.restart(app.session);
|
await this.manager.restart(app.session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/:id/purge')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Purge app storage by app ID',
|
||||||
|
description:
|
||||||
|
"Delete the app's stored data (database rows, caches) while keeping the app configured.",
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
|
async purge(@Param('id') id: string, @Req() req: any): Promise<App> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
return await this.appsService.purge(this.manager, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/:app/:session/purge')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Purge app storage by app name and session',
|
||||||
|
description:
|
||||||
|
"Delete the unique app's stored data for the session. The app must be enabled.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'app', enum: AppName })
|
||||||
|
@CheckPolicies(CanSession(Action.App, FromParam('session')))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
|
async purgeUniqueApp(
|
||||||
|
@Param('app') appName: string,
|
||||||
|
@Param('session') session: string,
|
||||||
|
): Promise<App> {
|
||||||
|
const app = await this.resolver.getEnabledApp(session, appName);
|
||||||
|
return await this.appsService.purge(this.manager, app.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
|
||||||
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
|
export interface ApiTag {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI tags for all registered apps - one "🧩 Apps: {Title}" tag per app,
|
||||||
|
* so app-specific endpoints get their own section in Swagger.
|
||||||
|
* Includes disabled apps too (only their openapi metadata is used) - some app
|
||||||
|
* controllers are served even when apps are disabled ('disabledControllers');
|
||||||
|
* their description is prefixed with "DISABLED" to make the state visible.
|
||||||
|
*/
|
||||||
|
export function GetAppsApiTags(): ApiTag[] {
|
||||||
|
return GetApps().map((app) => {
|
||||||
|
const enabled = AppRuntimeConfig.HasApp(app.name);
|
||||||
|
const description = enabled
|
||||||
|
? app.openapi.description
|
||||||
|
: `DISABLED - ${app.openapi.description}`;
|
||||||
|
return {
|
||||||
|
name: AppApiTag(app.openapi),
|
||||||
|
description: description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { AppEnv } from '@waha/apps/app_sdk/env';
|
import { AppEnv } from '@waha/apps/app_sdk/env';
|
||||||
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
class AppRuntimeConfigC {
|
class AppRuntimeConfigC {
|
||||||
private constructor(private apps: AppDefinition[] | null) {}
|
private constructor(private apps: AppModule[] | null) {}
|
||||||
|
|
||||||
static FromEnv(env: typeof AppEnv) {
|
static FromEnv(env: typeof AppEnv) {
|
||||||
if (!env.enabled) {
|
if (!env.enabled) {
|
||||||
return new AppRuntimeConfigC(null);
|
return new AppRuntimeConfigC(null);
|
||||||
}
|
}
|
||||||
let apps = Object.values(APPS);
|
let apps: AppModule[] = GetApps();
|
||||||
// Include
|
// Include
|
||||||
if (env.on && env.on.length > 0) {
|
if (env.on && env.on.length > 0) {
|
||||||
apps = apps.filter((app) => env.on!.includes(app.name));
|
apps = apps.filter((app) => env.on!.includes(app.name));
|
||||||
@@ -30,18 +30,18 @@ class AppRuntimeConfigC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GetAppsWithMigration() {
|
GetAppsWithMigration() {
|
||||||
return this.GetApps().filter((app) => app.migrations);
|
return this.GetApps().filter((app) => app.definition.migrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetAppsRequiringPlainKey() {
|
GetAppsRequiringPlainKey() {
|
||||||
return this.GetApps().filter((app) => app.plainkey);
|
return this.GetApps().filter((app) => app.definition.plainkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetAppsRequiringQueue() {
|
GetAppsRequiringQueue() {
|
||||||
return this.GetApps().filter((app) => app.queue);
|
return this.GetApps().filter((app) => app.definition.queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
HasApp(name: AppName) {
|
HasApp(name: string) {
|
||||||
return this.GetApps().some((app) => app.name === name);
|
return this.GetApps().some((app) => app.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
|
||||||
|
import { Type } from '@nestjs/common';
|
||||||
|
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
|
||||||
|
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||||
|
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||||
|
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
|
||||||
|
import { MexicanPhoneNumbersAppConfig } from '@waha/apps/mexican-phone-numbers/dto';
|
||||||
|
import { PhoneNumbersAppConfig } from '@waha/apps/phone-numbers/dto/config.dto';
|
||||||
|
|
||||||
|
export enum AppName {
|
||||||
|
argentinePhoneNumbers = 'argentine-phone-numbers',
|
||||||
|
brazilianPhoneNumbers = 'brazilian-phone-numbers',
|
||||||
|
chatwoot = 'chatwoot',
|
||||||
|
calls = 'calls',
|
||||||
|
mcp = 'mcp',
|
||||||
|
mexicanPhoneNumbers = 'mexican-phone-numbers',
|
||||||
|
phoneNumbers = 'phone-numbers',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO classes used to transform and validate App.config, by app name.
|
||||||
|
* Kept separate from the registry so DTOs (imported by core structures) can resolve config classes
|
||||||
|
* without pulling in every app module (controllers, services, queues) - that creates require cycles.
|
||||||
|
*/
|
||||||
|
export const AppConfigClasses: Record<AppName, Type<any>> = {
|
||||||
|
[AppName.argentinePhoneNumbers]: ArgentinePhoneNumbersAppConfig,
|
||||||
|
[AppName.brazilianPhoneNumbers]: BrazilianPhoneNumbersAppConfig,
|
||||||
|
[AppName.phoneNumbers]: PhoneNumbersAppConfig,
|
||||||
|
[AppName.calls]: CallsAppConfig,
|
||||||
|
[AppName.chatwoot]: ChatWootAppConfig,
|
||||||
|
[AppName.mcp]: McpAppConfig,
|
||||||
|
[AppName.mexicanPhoneNumbers]: MexicanPhoneNumbersAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GetAppConfigClass(name: AppName): Type<any> {
|
||||||
|
return AppConfigClasses[name] ?? Object;
|
||||||
|
}
|
||||||
@@ -1,28 +1,63 @@
|
|||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { Type } from '@nestjs/common';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { AppOpenAPI } from '@waha/apps/app_sdk/apps/openapi';
|
||||||
|
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
|
|
||||||
export interface AppDefinition {
|
export interface AppDefinition {
|
||||||
// App name
|
|
||||||
name: AppName;
|
|
||||||
// If app requires WAHA_API_KEY_PLAIN to work
|
// If app requires WAHA_API_KEY_PLAIN to work
|
||||||
plainkey: boolean;
|
plainkey: boolean;
|
||||||
// If app requires queue to work
|
// If app requires queue to work
|
||||||
queue: boolean;
|
queue: boolean;
|
||||||
// If app has any migrations
|
// If app has any migrations
|
||||||
migrations: boolean;
|
migrations: boolean;
|
||||||
|
// If adding, updating, or removing this app requires a session restart
|
||||||
|
restartOnChange: boolean;
|
||||||
|
// If only one instance of this app is allowed per session
|
||||||
|
unique: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All Apps
|
// NestJS module parts, included when the app is enabled in runtime configuration
|
||||||
export const APPS: Record<AppName, AppDefinition> = {
|
export interface AppNestJS {
|
||||||
[AppName.calls]: {
|
imports: any[];
|
||||||
name: AppName.calls,
|
controllers: any[];
|
||||||
plainkey: false,
|
providers: any[];
|
||||||
queue: false,
|
}
|
||||||
migrations: false,
|
|
||||||
},
|
/**
|
||||||
[AppName.chatwoot]: {
|
* Contract for the default export of 'src/apps/<name>/app.module.ts'.
|
||||||
name: AppName.chatwoot,
|
* Each app self-describes with this and gets listed in the registry ('apps/registry.ts').
|
||||||
plainkey: true,
|
*/
|
||||||
queue: true,
|
export interface AppModule {
|
||||||
migrations: true,
|
// App name
|
||||||
},
|
name: AppName;
|
||||||
};
|
// OpenAPI metadata (tag title, description) from 'src/apps/<name>/openapi.ts'
|
||||||
|
openapi: AppOpenAPI;
|
||||||
|
// Static app metadata (requirements, behavior flags)
|
||||||
|
definition: AppDefinition;
|
||||||
|
// NestJS module parts
|
||||||
|
nestjs: AppNestJS;
|
||||||
|
// Service implementing app lifecycle hooks; must also be listed in 'nestjs.providers'
|
||||||
|
Service: Type<IAppService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueApp(name: AppName): boolean {
|
||||||
|
return GetApp(name)?.definition.unique === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the first unique AppName that appears more than once in the list, or null
|
||||||
|
export function findDuplicateUniqueApp(
|
||||||
|
apps: Array<{ app: AppName }>,
|
||||||
|
): AppName | null {
|
||||||
|
const seen = new Set<AppName>();
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!isUniqueApp(app.app)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(app.app)) {
|
||||||
|
return app.app;
|
||||||
|
}
|
||||||
|
seen.add(app.app);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export enum AppName {
|
|
||||||
chatwoot = 'chatwoot',
|
|
||||||
calls = 'calls',
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI metadata for an app, defined inline in the app's AppModule ('openapi' field).
|
||||||
|
* The registry applies the "🧩 Apps: {Title}" tag to every controller in 'nestjs.controllers',
|
||||||
|
* so app controllers do not declare @ApiTags themselves - a new app only touches its own folder.
|
||||||
|
*/
|
||||||
|
export interface AppOpenAPI {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAPI tag for app-specific endpoints - "🧩 Apps: {Title}"
|
||||||
|
export function AppApiTag(openapi: AppOpenAPI): string {
|
||||||
|
return `🧩 Apps: ${openapi.title}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import ArgentinePhoneNumbersAppModule from '@waha/apps/argentine-phone-numbers/app.module';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
|
||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
|
||||||
|
import CallsAppModule from '@waha/apps/calls/app.module';
|
||||||
|
import ChatWootAppModule from '@waha/apps/chatwoot/app.module';
|
||||||
|
import McpAppModule from '@waha/apps/mcp/app.module';
|
||||||
|
import MexicanPhoneNumbersAppModule from '@waha/apps/mexican-phone-numbers/app.module';
|
||||||
|
import PhoneNumbersAppModule from '@waha/apps/phone-numbers/app.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of available apps.
|
||||||
|
* Add new apps here - the rest of app_sdk works off this list.
|
||||||
|
*/
|
||||||
|
const APPS: AppModule[] = [
|
||||||
|
ArgentinePhoneNumbersAppModule,
|
||||||
|
BrazilianPhoneNumbersAppModule,
|
||||||
|
CallsAppModule,
|
||||||
|
ChatWootAppModule,
|
||||||
|
McpAppModule,
|
||||||
|
MexicanPhoneNumbersAppModule,
|
||||||
|
PhoneNumbersAppModule,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tag every app controller with the app's OpenAPI tag ("🧩 Apps: {Title}") from AppModule.openapi,
|
||||||
|
// so controllers do not declare @ApiTags themselves (they cannot import their own app.module - require cycle).
|
||||||
|
for (const app of APPS) {
|
||||||
|
for (const controller of app.nestjs.controllers) {
|
||||||
|
ApiTags(AppApiTag(app.openapi))(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetApps(): AppModule[] {
|
||||||
|
return APPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetApp(name: string): AppModule | undefined {
|
||||||
|
return APPS.find((app) => app.name === name);
|
||||||
|
}
|
||||||
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
|
|||||||
|
|
||||||
export const JobRemoveOptions = jobRemoveOptions();
|
export const JobRemoveOptions = jobRemoveOptions();
|
||||||
|
|
||||||
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
|
function exponentialRetriesJobOptions(): DefaultJobOptions {
|
||||||
attempts: 3,
|
return {
|
||||||
backoff: {
|
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
|
||||||
type: 'exponential',
|
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
|
||||||
delay: 1000,
|
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 = {
|
export const NoRetriesJobOptions: DefaultJobOptions = {
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
|
||||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -9,12 +7,13 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import {
|
||||||
|
AppConfigClasses,
|
||||||
|
AppName,
|
||||||
|
GetAppConfigClass,
|
||||||
|
} from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
|
||||||
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
|
export class App<T = any> {
|
||||||
|
|
||||||
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
|
|
||||||
export class App<T extends AllowedAppConfig = any> {
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@@ -37,29 +36,14 @@ export class App<T extends AllowedAppConfig = any> {
|
|||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type((options) => {
|
@Type((options) => {
|
||||||
if (options && options.object && options.object.app) {
|
const name = options?.object?.app;
|
||||||
switch (options.object.app) {
|
if (!name) {
|
||||||
case AppName.chatwoot:
|
return Object;
|
||||||
return ChatWootAppConfig;
|
|
||||||
case AppName.calls:
|
|
||||||
return CallsAppConfig;
|
|
||||||
default:
|
|
||||||
return Object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Object;
|
return GetAppConfigClass(name);
|
||||||
})
|
})
|
||||||
config: T;
|
config: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatWootAppDto extends App<ChatWootAppConfig> {
|
// Swagger models for app configs
|
||||||
@Type(() => ChatWootAppConfig)
|
ApiExtraModels(...Object.values(AppConfigClasses))(App);
|
||||||
config: ChatWootAppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CallsAppDto extends App<CallsAppConfig> {
|
|
||||||
@Type(() => CallsAppConfig)
|
|
||||||
config: CallsAppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppDto = ChatWootAppDto | CallsAppDto;
|
|
||||||
+33
-5
@@ -1,4 +1,10 @@
|
|||||||
import { parseBool } from '@waha/helpers';
|
import { parseBool } from '@waha/helpers';
|
||||||
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
|
// Apps that don't require queue (Redis) - derived from AppDefinition
|
||||||
|
const IN_MEMORY_APPS = GetApps()
|
||||||
|
.filter((app) => !app.definition.queue)
|
||||||
|
.map((app) => app.name);
|
||||||
|
|
||||||
function parseCommaSeparatedList(value: string | undefined): string[] {
|
function parseCommaSeparatedList(value: string | undefined): string[] {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -7,8 +13,30 @@ function parseCommaSeparatedList(value: string | undefined): string[] {
|
|||||||
return value.split(',').map((item) => item.trim());
|
return value.split(',').map((item) => item.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppEnv = {
|
function isMongoDB(): boolean {
|
||||||
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
|
return !!process.env.WHATSAPP_SESSIONS_MONGO_URL;
|
||||||
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
|
}
|
||||||
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
|
|
||||||
};
|
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();
|
throw new AppsIsDisabledError();
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(manager: SessionManager, appId: string): Promise<App> {
|
async get(manager: SessionManager, appId: string): Promise<App | null> {
|
||||||
throw new AppsIsDisabledError();
|
throw new AppsIsDisabledError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +41,14 @@ export class AppsDisabledService implements IAppsService {
|
|||||||
throw new AppsIsDisabledError();
|
throw new AppsIsDisabledError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purge(manager: SessionManager, appId: string): Promise<App> {
|
||||||
|
throw new AppsIsDisabledError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeBySession(manager: SessionManager, session: string) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async removeBySession(manager: SessionManager, session: string) {
|
async removeBySession(manager: SessionManager, session: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { migrate } from '@waha/apps/app_sdk/migrations';
|
import { migrate } from '@waha/apps/app_sdk/migrations';
|
||||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
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 { DataStore } from '@waha/core/abc/DataStore';
|
import { DataStore } from '@waha/core/abc/DataStore';
|
||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
@@ -18,8 +16,12 @@ import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { App } from '../dto/app.dto';
|
import { App } from '../dto/app.dto';
|
||||||
import { AppRepository } from '../storage/AppRepository';
|
import { AppRepository } from '../storage/AppRepository';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
|
||||||
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
|
import {
|
||||||
|
findDuplicateUniqueApp,
|
||||||
|
isUniqueApp,
|
||||||
|
} from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
export class AppDisableError extends UnprocessableEntityException {
|
export class AppDisableError extends UnprocessableEntityException {
|
||||||
constructor(app: string) {
|
constructor(app: string) {
|
||||||
@@ -29,22 +31,32 @@ export class AppDisableError extends UnprocessableEntityException {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AppUniquePerSessionError extends UnprocessableEntityException {
|
||||||
|
constructor(app: string, session: string, existingAppId: string) {
|
||||||
|
super(
|
||||||
|
`Only one '${app}' app is allowed per session. ` +
|
||||||
|
`Session '${session}' already has a '${app}' app with ID '${existingAppId}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppsEnabledService implements IAppsService {
|
export class AppsEnabledService implements IAppsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectPinoLogger('AppsService')
|
@InjectPinoLogger('AppsService')
|
||||||
protected logger: PinoLogger,
|
protected logger: PinoLogger,
|
||||||
@Optional() protected readonly chatwootService: ChatWootAppService,
|
private readonly moduleRef: ModuleRef,
|
||||||
@Optional() protected readonly callsAppService: CallsAppService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(manager: SessionManager, session: string): Promise<App[]> {
|
async list(manager: SessionManager, session: string): Promise<App[]> {
|
||||||
const knex = manager.store.getWAHADatabase();
|
const knex = manager.store.getWAHADatabase();
|
||||||
const repo = new AppRepository(knex);
|
const repo = new AppRepository(knex);
|
||||||
const apps = await repo.getAllBySession(session);
|
const apps = await repo.getAllBySession(session);
|
||||||
apps.forEach((app) => {
|
for (const app of apps) {
|
||||||
delete app.pk;
|
delete app.pk;
|
||||||
});
|
const service = this.getAppService(app);
|
||||||
|
await service?.enrich(manager, app);
|
||||||
|
}
|
||||||
return apps;
|
return apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,40 +72,26 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
throw new Error(`App with ID '${app.id}' already exists.`);
|
throw new Error(`App with ID '${app.id}' already exists.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingApps: App[] = [];
|
// Validate only one instance of a unique app per session
|
||||||
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
|
if (isUniqueApp(app.app)) {
|
||||||
existingApps = await repo.getAllBySession(app.session);
|
const existingApps = await repo.getAllBySession(app.session);
|
||||||
}
|
const duplicateApp = existingApps.find(
|
||||||
// Validate only one Chatwoot app per session
|
(existingApp) => existingApp.app === app.app,
|
||||||
if (app.app === AppName.chatwoot) {
|
|
||||||
const existingChatwootApp = existingApps.find(
|
|
||||||
(existingApp) => existingApp.app === AppName.chatwoot,
|
|
||||||
);
|
);
|
||||||
|
if (duplicateApp) {
|
||||||
if (existingChatwootApp) {
|
throw new AppUniquePerSessionError(
|
||||||
throw new Error(
|
app.app,
|
||||||
`Only one Chatwoot app is allowed per session. Session '${app.session}' already has a Chatwoot app with ID '${existingChatwootApp.id}'.`,
|
app.session,
|
||||||
);
|
duplicateApp.id,
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validate only one Calls app per session
|
|
||||||
if (app.app === AppName.calls) {
|
|
||||||
const existingCallsApp = existingApps.find(
|
|
||||||
(existingApp) => existingApp.app === AppName.calls,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingCallsApp) {
|
|
||||||
throw new Error(
|
|
||||||
`Only one Calls app is allowed per session. Session '${app.session}' already has a Calls app with ID '${existingCallsApp.id}'.`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = this.getAppService(app);
|
const service = this.getAppService(app);
|
||||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||||
throw new AppDisableError(app.app);
|
throw new AppDisableError(app.app);
|
||||||
}
|
}
|
||||||
service.validate(app);
|
service?.validate(app);
|
||||||
// Only run beforeCreated when app is enabled (default true if omitted)
|
// Only run beforeCreated when app is enabled (default true if omitted)
|
||||||
if (app.enabled !== false) {
|
if (app.enabled !== false) {
|
||||||
await service.beforeCreated(app);
|
await service.beforeCreated(app);
|
||||||
@@ -101,17 +99,22 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
|
|
||||||
const result = await repo.save(app);
|
const result = await repo.save(app);
|
||||||
delete result.pk;
|
delete result.pk;
|
||||||
|
if (app.enabled !== false) {
|
||||||
|
await service?.afterCreated(manager, result);
|
||||||
|
}
|
||||||
return 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 knex = manager.store.getWAHADatabase();
|
||||||
const repo = new AppRepository(knex);
|
const repo = new AppRepository(knex);
|
||||||
const app = await repo.getById(appId);
|
const app = await repo.getById(appId);
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new NotFoundException(`App '${appId}' not found`);
|
return null;
|
||||||
}
|
}
|
||||||
delete (app as any).pk;
|
delete (app as any).pk;
|
||||||
|
const service = this.getAppService(app);
|
||||||
|
await service?.enrich(manager, app);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,21 +150,21 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const service = this.getAppService(app);
|
const service = this.getAppService(app);
|
||||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||||
throw new AppDisableError(app.app);
|
throw new AppDisableError(app.app);
|
||||||
}
|
}
|
||||||
service.validate(app);
|
service?.validate(app);
|
||||||
|
|
||||||
const hasEnabledChange = savedApp.enabled !== app.enabled;
|
const hasEnabledChange = savedApp.enabled !== app.enabled;
|
||||||
|
|
||||||
if (hasEnabledChange) {
|
if (hasEnabledChange) {
|
||||||
if (app.enabled) {
|
if (app.enabled) {
|
||||||
await service.beforeEnabled(savedApp, app);
|
await service?.beforeEnabled(manager, savedApp, app);
|
||||||
} else {
|
} else {
|
||||||
await service.beforeDisabled(savedApp, app);
|
await service?.beforeDisabled(manager, savedApp, app);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await service.beforeUpdated(savedApp, app);
|
await service?.beforeUpdated(manager, savedApp, app);
|
||||||
}
|
}
|
||||||
await repo.update(app.id, app);
|
await repo.update(app.id, app);
|
||||||
const updated = await repo.getById(app.id);
|
const updated = await repo.getById(app.id);
|
||||||
@@ -177,15 +180,49 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
throw new NotFoundException(`App '${appId}' not found`);
|
throw new NotFoundException(`App '${appId}' not found`);
|
||||||
}
|
}
|
||||||
const service = this.getAppService(app);
|
const service = this.getAppService(app);
|
||||||
await service?.beforeDeleted(app);
|
await service?.beforeDeleted(manager, app);
|
||||||
await repo.delete(app.id);
|
await repo.delete(app.id);
|
||||||
delete app.pk;
|
delete app.pk;
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purge(manager: SessionManager, appId: string): Promise<App> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
const service = this.getAppService(app);
|
||||||
|
if (!service) {
|
||||||
|
throw new AppDisableError(app.app);
|
||||||
|
}
|
||||||
|
await service.purge(manager, app);
|
||||||
|
delete app.pk;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeBySession(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);
|
||||||
|
if (!service) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await service.purge(manager, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async removeBySession(manager: SessionManager, session: string) {
|
async removeBySession(manager: SessionManager, session: string) {
|
||||||
const knex = manager.store.getWAHADatabase();
|
const knex = manager.store.getWAHADatabase();
|
||||||
const repo = new AppRepository(knex);
|
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);
|
await repo.deleteBySession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +235,10 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||||
throw new AppDisableError(app.app);
|
throw new AppDisableError(app.app);
|
||||||
}
|
}
|
||||||
|
const plugins = service.plugins(app, session, store);
|
||||||
|
for (const options of plugins) {
|
||||||
|
session.plugins.add(options, app.id);
|
||||||
|
}
|
||||||
service.beforeSessionStart(app, session);
|
service.beforeSessionStart(app, session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +261,15 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
session: string,
|
session: string,
|
||||||
apps: App[],
|
apps: App[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Reject duplicate unique apps in the payload before any writes,
|
||||||
|
// otherwise the by-type matching below binds them to the same app
|
||||||
|
const duplicateUniqueApp = findDuplicateUniqueApp(apps);
|
||||||
|
if (duplicateUniqueApp !== null) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`Only one '${duplicateUniqueApp}' app is allowed per session - remove duplicate entries from 'apps'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.list(manager, session);
|
const existing = await this.list(manager, session);
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
@@ -252,13 +302,15 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getAppService(app: App): IAppService | null {
|
private getAppService(app: App): IAppService | null {
|
||||||
switch (app.app) {
|
const appModule = GetApp(app.app);
|
||||||
case AppName.chatwoot:
|
if (!appModule) {
|
||||||
return this.chatwootService;
|
throw new Error(`App '${app.app}' not supported`);
|
||||||
case AppName.calls:
|
}
|
||||||
return this.callsAppService;
|
try {
|
||||||
default:
|
return this.moduleRef.get(appModule.Service, { strict: false });
|
||||||
throw new Error(`App '${app.app}' not supported`);
|
} catch {
|
||||||
|
// Provider is not registered - app is disabled via WAHA_APPS_ON / WAHA_APPS_OFF
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
|
import { DataStore } from '@waha/core/abc/DataStore';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
|
import { PluginOptions } from '@waha/core/abc/session.plugin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exact App service
|
* Exact App service
|
||||||
@@ -9,19 +12,66 @@ export interface IAppService {
|
|||||||
|
|
||||||
beforeCreated(app: App): Promise<void>;
|
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.
|
* 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.
|
* 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>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the app's stored data (database rows, caches) while keeping the app configured.
|
||||||
|
* Apps without storage implement it as a no-op.
|
||||||
|
*/
|
||||||
|
purge(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>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session plugins contributed by this app. AppsEnabledService registers them with the app id,
|
||||||
|
* and the session manager attaches their hooks and events before session.start().
|
||||||
|
* store - the server data store, for apps whose plugins persist data.
|
||||||
|
*/
|
||||||
|
plugins(
|
||||||
|
app: App,
|
||||||
|
session: WhatsappSession,
|
||||||
|
store?: DataStore,
|
||||||
|
): PluginOptions[];
|
||||||
|
|
||||||
beforeSessionStart(app: App, session: WhatsappSession): void;
|
beforeSessionStart(app: App, session: WhatsappSession): void;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
|
|||||||
export interface IAppsService {
|
export interface IAppsService {
|
||||||
list(manager: SessionManager, session: string): Promise<App[]>;
|
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>;
|
create(manager: SessionManager, app: App): Promise<App>;
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ export interface IAppsService {
|
|||||||
|
|
||||||
delete(manager: SessionManager, appId: string): Promise<App>;
|
delete(manager: SessionManager, appId: string): Promise<App>;
|
||||||
|
|
||||||
|
purge(manager: SessionManager, appId: string): Promise<App>;
|
||||||
|
|
||||||
|
purgeBySession(manager: SessionManager, session: string): Promise<void>;
|
||||||
|
|
||||||
removeBySession(manager: SessionManager, session: string): Promise<void>;
|
removeBySession(manager: SessionManager, session: string): Promise<void>;
|
||||||
|
|
||||||
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
||||||
|
import { AppDB } from '@waha/apps/app_sdk/storage/types';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
|
import { SessionPlugin } from '@waha/core/abc/session.plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the single instance of a unique-per-session app ('definition.unique') by session name,
|
||||||
|
* so app controllers can expose session-keyed routes ('api/apps/{app}/{session}/...') instead of app-id ones.
|
||||||
|
* Uniqueness is guaranteed at write time by AppsEnabledService, so the first enabled row is the instance.
|
||||||
|
* Must not import 'apps/registry.ts' or 'apps/definition.ts' - controllers import this resolver,
|
||||||
|
* and the registry imports app modules (with their controllers), which would create a require cycle.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UniqueAppResolver {
|
||||||
|
constructor(private readonly manager: SessionManager) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled app row for the session; 404 if the app is missing or disabled.
|
||||||
|
* Does not require the session to be running - persistent app data stays accessible for stopped sessions.
|
||||||
|
*/
|
||||||
|
async getEnabledApp(sessionName: string, appName: string): Promise<AppDB> {
|
||||||
|
const knex = this.manager.store.getWAHADatabase();
|
||||||
|
const repository = new AppRepository(knex);
|
||||||
|
const app = await repository.getEnabledBySessionAndApp(
|
||||||
|
sessionName,
|
||||||
|
appName,
|
||||||
|
);
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`App '${appName}' is not enabled for session '${sessionName}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live plugin instance for the app, or null when the session is not running (best-effort access).
|
||||||
|
*/
|
||||||
|
getPlugin<Plugin extends SessionPlugin<any, any>>(
|
||||||
|
app: AppDB,
|
||||||
|
PluginClass: abstract new (...args: any[]) => Plugin,
|
||||||
|
): Plugin | null {
|
||||||
|
if (!this.manager.isRunning(app.session)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = this.manager.getSession(app.session);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.plugins.get(PluginClass, app.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,25 @@ export class AppRepository {
|
|||||||
return this.deserialize(app);
|
return this.deserialize(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the enabled app of the given type for a session.
|
||||||
|
* Intended for unique-per-session apps - returns the first match.
|
||||||
|
*/
|
||||||
|
async getEnabledBySessionAndApp(
|
||||||
|
session: string,
|
||||||
|
appName: string,
|
||||||
|
): Promise<AppDB | null> {
|
||||||
|
const app = await this.knex(this.tableName)
|
||||||
|
.where('session', session)
|
||||||
|
.andWhere('app', appName)
|
||||||
|
.andWhere('enabled', true)
|
||||||
|
.first();
|
||||||
|
if (!app) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.deserialize(app);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all apps
|
* Gets all apps
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
WANumberExistResult,
|
WANumberExistResult,
|
||||||
} from '@waha/structures/chatting.dto';
|
} from '@waha/structures/chatting.dto';
|
||||||
import { SessionInfo } from '@waha/structures/sessions.dto';
|
import { SessionInfo } from '@waha/structures/sessions.dto';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import { Auth } from '@waha/core/auth/config';
|
|
||||||
import { ContactSortField } from '@waha/structures/contacts.dto';
|
import { ContactSortField } from '@waha/structures/contacts.dto';
|
||||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||||
|
|
||||||
@@ -25,9 +24,7 @@ export interface RequestOptions {
|
|||||||
export class WAHASelf {
|
export class WAHASelf {
|
||||||
public client: AxiosInstance;
|
public client: AxiosInstance;
|
||||||
|
|
||||||
constructor() {
|
constructor(public key: string) {
|
||||||
// Set 'X-Api-Key'
|
|
||||||
const key = Auth.keyplain.value;
|
|
||||||
const port =
|
const port =
|
||||||
parseInt(process.env.PORT) ||
|
parseInt(process.env.PORT) ||
|
||||||
parseInt(process.env.WHATSAPP_API_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> {
|
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
|
||||||
const response = await this.client.get(url, {
|
const response = await this.client.get(url, {
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
|
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
||||||
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
|
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const AppsDisabled = {
|
|||||||
provide: AppsService,
|
provide: AppsService,
|
||||||
useClass: AppsDisabledService,
|
useClass: AppsDisabledService,
|
||||||
},
|
},
|
||||||
|
UniqueAppResolver,
|
||||||
],
|
],
|
||||||
imports: [],
|
imports: [],
|
||||||
controllers: [AppsController, ChatwootLocalesController],
|
controllers: [AppsController, ChatwootLocalesController],
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import { RMutexModule } from '@waha/modules/rmutex';
|
|||||||
import { BullBoardModule } from '@bull-board/nestjs';
|
import { BullBoardModule } from '@bull-board/nestjs';
|
||||||
import { ExpressAdapter } from '@bull-board/express';
|
import { ExpressAdapter } from '@bull-board/express';
|
||||||
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
||||||
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
|
|
||||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
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';
|
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
|
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import { Auth } from '@waha/core/auth/config';
|
import { Auth } from '@waha/core/auth/config';
|
||||||
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
|
||||||
|
|
||||||
const QUEUES_IMPORTS_REQUIRED = [
|
const QUEUES_IMPORTS_REQUIRED = [
|
||||||
|
HttpPathsRegistration(
|
||||||
|
{ prefix: '/jobs', include: { authBasic: false } },
|
||||||
|
{ prefix: '/jobs/', include: { authBasic: false, accessLog: false } },
|
||||||
|
),
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
connection: {
|
connection: {
|
||||||
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
||||||
@@ -70,42 +74,27 @@ const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
|
|||||||
? QUEUES_IMPORTS_REQUIRED
|
? QUEUES_IMPORTS_REQUIRED
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
function getAppModule(name: AppName) {
|
// Apps enabled in the runtime configuration (WAHA_APPS_ON / WAHA_APPS_OFF)
|
||||||
if (!AppRuntimeConfig.HasApp(name)) {
|
const ENABLED_APPS = GetApps().filter((app) =>
|
||||||
return {
|
AppRuntimeConfig.HasApp(app.name),
|
||||||
imports: [],
|
);
|
||||||
controllers: [],
|
|
||||||
providers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
switch (name) {
|
|
||||||
case AppName.calls:
|
|
||||||
return CallsAppExports;
|
|
||||||
case AppName.chatwoot:
|
|
||||||
return ChatWootExports;
|
|
||||||
default:
|
|
||||||
throw Error(`App module not found for ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppsEnabled = {
|
export const AppsEnabled = {
|
||||||
imports: [
|
imports: [
|
||||||
...QUEUES_IMPORTS,
|
...QUEUES_IMPORTS,
|
||||||
...getAppModule(AppName.chatwoot).imports,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.imports),
|
||||||
...getAppModule(AppName.calls).imports,
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AppsController,
|
AppsController,
|
||||||
...getAppModule(AppName.chatwoot).controllers,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.controllers),
|
||||||
...getAppModule(AppName.calls).controllers,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: AppsService,
|
provide: AppsService,
|
||||||
useClass: AppsEnabledService,
|
useClass: AppsEnabledService,
|
||||||
},
|
},
|
||||||
...getAppModule(AppName.calls).providers,
|
UniqueAppResolver,
|
||||||
...getAppModule(AppName.chatwoot).providers,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.providers),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { ArgentinePhoneNumbersController } from '@waha/apps/argentine-phone-numbers/controller';
|
||||||
|
import { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService';
|
||||||
|
|
||||||
|
const ArgentinePhoneNumbersAppModule: AppModule = {
|
||||||
|
name: AppName.argentinePhoneNumbers,
|
||||||
|
openapi: {
|
||||||
|
title: 'Phone Numbers: Argentina',
|
||||||
|
description:
|
||||||
|
'Resolve Argentine phone numbers (with and without the mobile 9)',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: false,
|
||||||
|
queue: false,
|
||||||
|
migrations: true,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: [],
|
||||||
|
controllers: [ArgentinePhoneNumbersController],
|
||||||
|
providers: [ArgentinePhoneNumbersAppService],
|
||||||
|
},
|
||||||
|
Service: ArgentinePhoneNumbersAppService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArgentinePhoneNumbersAppModule;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiSecurity } from '@nestjs/swagger';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
|
import { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService';
|
||||||
|
import { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
|
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||||
|
|
||||||
|
@ApiSecurity('api_key')
|
||||||
|
@Controller('api/apps/argentine-phone-numbers/:session')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
export class ArgentinePhoneNumbersController extends PhoneNumbersCacheController {
|
||||||
|
constructor(
|
||||||
|
manager: SessionManager,
|
||||||
|
resolver: UniqueAppResolver,
|
||||||
|
appService: ArgentinePhoneNumbersAppService,
|
||||||
|
) {
|
||||||
|
super(manager, resolver, appService, AppName.argentinePhoneNumbers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
|
||||||
|
|
||||||
|
export class ArgentinePhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache';
|
||||||
|
|
||||||
|
const migration = migrations_001_init_cache({
|
||||||
|
table: 'app_argentine_phone_numbers_cache',
|
||||||
|
index: 'arphone',
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.up = migration.up;
|
||||||
|
exports.down = migration.down;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
|
||||||
|
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
|
||||||
|
import {
|
||||||
|
buildPlugin as buildPhoneNumbersPlugin,
|
||||||
|
resolveChat,
|
||||||
|
stubLookup,
|
||||||
|
} from '@waha/apps/phone-numbers/plugins/testing';
|
||||||
|
|
||||||
|
function buildPlugin(config: Partial<PhoneNumbersBaseConfig> = {}) {
|
||||||
|
return buildPhoneNumbersPlugin({
|
||||||
|
config: config,
|
||||||
|
rules: ArgentinePhoneNumberRules(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArgentinePhoneNumbers', () => {
|
||||||
|
it('uses the supplied form when it exists, without checking the other one', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: true, chatId: '541112345678@c.us' });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '+541112345678');
|
||||||
|
|
||||||
|
expect(resolved).toBe('541112345678@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledWith({
|
||||||
|
phone: '541112345678',
|
||||||
|
session: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries the form with 9 after the supplied one is absent, keeps a LID answer', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
session.checkNumberStatus = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ numberExists: false })
|
||||||
|
.mockResolvedValueOnce({ numberExists: true, chatId: '123456@lid' });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '541112345678@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('123456@lid');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenLastCalledWith({
|
||||||
|
phone: '5491112345678',
|
||||||
|
session: 'test',
|
||||||
|
});
|
||||||
|
// Both forms hit the cache now
|
||||||
|
expect(await resolveChat(session, '5491112345678@c.us')).toBe('123456@lid');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries the form without 9 as the fallback', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
session.checkNumberStatus = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ numberExists: false })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '542920123456@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5492920123456@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('542920123456@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the pn when the engine answers with both pn and a LID', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '123@lid',
|
||||||
|
pn: '5491112345678@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await resolveChat(session, '541112345678@c.us')).toBe(
|
||||||
|
'5491112345678@c.us',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft mode keeps the supplied form when both are absent, strict rejects', async () => {
|
||||||
|
const soft = buildPlugin();
|
||||||
|
stubLookup(soft.session, { numberExists: false });
|
||||||
|
expect(await resolveChat(soft.session, '541112345678@c.us')).toBe(
|
||||||
|
'541112345678@c.us',
|
||||||
|
);
|
||||||
|
expect(soft.session.checkNumberStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const strict = buildPlugin({ strict: true });
|
||||||
|
stubLookup(strict.session, { numberExists: false });
|
||||||
|
await expect(
|
||||||
|
resolveChat(strict.session, '541112345678@c.us'),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves local forms and other countries untouched', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
expect(await resolveChat(session, '1112345678@c.us')).toBe(
|
||||||
|
'1112345678@c.us',
|
||||||
|
);
|
||||||
|
expect(await resolveChat(session, '5511912345678@c.us')).toBe(
|
||||||
|
'5511912345678@c.us',
|
||||||
|
);
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ArgentinePhoneNumberRules } from './ArgentinePhoneNumberRules';
|
||||||
|
|
||||||
|
describe('ArgentinePhoneNumberRules', () => {
|
||||||
|
const rules = ArgentinePhoneNumberRules();
|
||||||
|
|
||||||
|
function resolve(digits: string) {
|
||||||
|
const rule = rules.find((r) => r.matches(digits));
|
||||||
|
return rule?.resolve(digits) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['541112345678', ['541112345678', '5491112345678']],
|
||||||
|
['5491112345678', ['5491112345678', '541112345678']],
|
||||||
|
['543511234567', ['543511234567', '5493511234567']],
|
||||||
|
['5492920123456', ['5492920123456', '542920123456']],
|
||||||
|
])('keeps the supplied form first for %s', (digits, expected) => {
|
||||||
|
expect(resolve(digits)).toEqual({ candidates: expected, fallback: digits });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'0111512345678',
|
||||||
|
'1112345678',
|
||||||
|
'91112345678',
|
||||||
|
'54111512345678',
|
||||||
|
'548001234567',
|
||||||
|
'54911123',
|
||||||
|
'54911123456789',
|
||||||
|
'5511912345678',
|
||||||
|
'',
|
||||||
|
])('does not match %s', (digits) => {
|
||||||
|
expect(resolve(digits)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
|
||||||
|
import { RegexpPhoneNumberRule } from '@waha/apps/phone-numbers/rules/RegexpPhoneNumberRule';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Argentina: mobiles have a 9 after the country code, callers send either form - check the supplied one first.
|
||||||
|
* 54 + area code (1-3...) + number, 10 digits, international form only
|
||||||
|
*/
|
||||||
|
export function ArgentinePhoneNumberRules(): PhoneNumberRule[] {
|
||||||
|
return [
|
||||||
|
new RegexpPhoneNumberRule('^54([1-3]\\d{9})$', '549$1'),
|
||||||
|
new RegexpPhoneNumberRule('^549([1-3]\\d{9})$', '54$1'),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
|
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
|
||||||
|
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
|
||||||
|
import { ArgentinePhoneNumbersCacheRepository } from '@waha/apps/argentine-phone-numbers/storage/ArgentinePhoneNumbersCacheRepository';
|
||||||
|
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
|
||||||
|
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArgentinePhoneNumbersAppService extends PhoneNumbersAppServiceBase<ArgentinePhoneNumbersAppConfig> {
|
||||||
|
protected readonly Repository = ArgentinePhoneNumbersCacheRepository;
|
||||||
|
|
||||||
|
constructor(resolver: UniqueAppResolver) {
|
||||||
|
super(resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected rules(config: ArgentinePhoneNumbersAppConfig): PhoneNumberRule[] {
|
||||||
|
void config;
|
||||||
|
return ArgentinePhoneNumberRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
|
||||||
|
|
||||||
|
export class ArgentinePhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
|
||||||
|
static tableName = 'app_argentine_phone_numbers_cache';
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/controller';
|
||||||
|
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
|
||||||
|
|
||||||
|
const BrazilianPhoneNumbersAppModule: AppModule = {
|
||||||
|
name: AppName.brazilianPhoneNumbers,
|
||||||
|
openapi: {
|
||||||
|
title: 'Phone Numbers: Brazil',
|
||||||
|
description: 'Resolve Brazilian phone numbers (with and without 9 digit)',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: false,
|
||||||
|
queue: false,
|
||||||
|
migrations: true,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: [],
|
||||||
|
controllers: [BrazilianPhoneNumbersController],
|
||||||
|
providers: [BrazilianPhoneNumbersAppService],
|
||||||
|
},
|
||||||
|
Service: BrazilianPhoneNumbersAppService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrazilianPhoneNumbersAppModule;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiSecurity } from '@nestjs/swagger';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
|
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
|
||||||
|
import { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
|
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||||
|
|
||||||
|
@ApiSecurity('api_key')
|
||||||
|
@Controller('api/apps/brazilian-phone-numbers/:session')
|
||||||
|
@UseGuards(PoliciesGuard)
|
||||||
|
export class BrazilianPhoneNumbersController extends PhoneNumbersCacheController {
|
||||||
|
constructor(
|
||||||
|
manager: SessionManager,
|
||||||
|
resolver: UniqueAppResolver,
|
||||||
|
appService: BrazilianPhoneNumbersAppService,
|
||||||
|
) {
|
||||||
|
super(manager, resolver, appService, AppName.brazilianPhoneNumbers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
|
||||||
|
|
||||||
|
export class BrazilianPhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache';
|
||||||
|
|
||||||
|
const migration = migrations_001_init_cache({
|
||||||
|
table: 'app_brazilian_phone_numbers_cache',
|
||||||
|
index: 'brphone',
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.up = migration.up;
|
||||||
|
exports.down = migration.down;
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
|
||||||
|
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
|
||||||
|
import { PhoneNumbersGowsPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersGowsPlugin';
|
||||||
|
import { PhoneNumbersNowebPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersNowebPlugin';
|
||||||
|
import { PhoneNumbersCorePlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersCorePlugin';
|
||||||
|
import {
|
||||||
|
buildPlugin as buildPhoneNumbersPlugin,
|
||||||
|
resolveChat,
|
||||||
|
stubLookup,
|
||||||
|
} from '@waha/apps/phone-numbers/plugins/testing';
|
||||||
|
|
||||||
|
interface BuildOptions {
|
||||||
|
config?: Partial<PhoneNumbersBaseConfig>;
|
||||||
|
repository?: any;
|
||||||
|
pluginClass?: typeof PhoneNumbersCorePlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlugin(options: BuildOptions = {}) {
|
||||||
|
return buildPhoneNumbersPlugin({
|
||||||
|
...options,
|
||||||
|
rules: [new BrazilianPhoneNumberRule()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers - wid.chat', () => {
|
||||||
|
it('resolves and caches the canonical phone when the engine answers with a PN', async () => {
|
||||||
|
// '558591203123' is the real number: DDD 85 with an 8-digit local part.
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
// Second call is served from cache - no extra WhatsApp lookup.
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledWith({
|
||||||
|
phone: '558591203123',
|
||||||
|
session: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the pn form when the engine answers with both pn and a LID', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '77820596330581@lid',
|
||||||
|
pn: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches a LID answer as-is instead of stapling a phone suffix on it', async () => {
|
||||||
|
// Engines answer with a LID only when the account has no phone form. The
|
||||||
|
// LID is routable, but '77820596330581@c.us' - its digits with a phone
|
||||||
|
// suffix - addresses nobody.
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '77820596330581@lid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('77820596330581@lid');
|
||||||
|
expect(second).toBe('77820596330581@lid');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the cache across both forms of the same number', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrong form first, then the already-correct one: same target, one lookup.
|
||||||
|
const wrongForm = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const rightForm = await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
expect(wrongForm).toBe('558591203123@c.us');
|
||||||
|
expect(rightForm).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight lookup between concurrent sends (single-flight)', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
resolveChat(session, '5585991203123@c.us'),
|
||||||
|
resolveChat(session, '558591203123@c.us'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites a dialed toll-free (0800) to the stored form, no lookup', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
// Dialed forms with and without the country code, plus the already-stored
|
||||||
|
// form, all address the same chat id - and none hits the WhatsApp lookup.
|
||||||
|
expect(await resolveChat(session, '08000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(await resolveChat(session, '5508000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(await resolveChat(session, '558000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the static 9th-digit rule for DDD below the lookup range, no lookup', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '551198765432@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5511998765432@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves numbers of other countries untouched', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '12132132130@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('12132132130@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes recursion-sensitive methods through untouched', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const viaCheck = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'checkNumberStatus',
|
||||||
|
);
|
||||||
|
const viaLidMap = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'findLIDByPhoneNumber',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(viaCheck).toBe('5585991203123@c.us');
|
||||||
|
expect(viaLidMap).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves non-send methods locally only - no lookup, cache still honored', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache miss on a local-only method: input goes through unresolved.
|
||||||
|
const cold = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'startTyping',
|
||||||
|
);
|
||||||
|
expect(cold).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A send resolves and caches; the local-only method now sees the cache.
|
||||||
|
await resolveChat(session, '5585991203123@c.us', 'sendText');
|
||||||
|
const warm = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'startTyping',
|
||||||
|
);
|
||||||
|
expect(warm).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 422 for a malformed number on send, passes it through otherwise', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
await expect(resolveChat(session, '55859912@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
const readOp = await resolveChat(session, '55859912@c.us', 'getPresence');
|
||||||
|
expect(readOp).toBe('55859912@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft mode: sends the best-guess when the number is confirmed not to exist', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
// DDD 85 keeps the 8-digit heuristic as the best-guess.
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
// Both candidates were tried before giving up.
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strict mode: rejects a confirmed-nonexistent number and caches the negative', async () => {
|
||||||
|
const { session } = buildPlugin({ config: { strict: true } });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
const lookups = session.checkNumberStatus.mock.calls.length;
|
||||||
|
|
||||||
|
// The negative is cached - the retry rejects without a new lookup...
|
||||||
|
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(lookups);
|
||||||
|
// ...and local-only methods still pass the number through.
|
||||||
|
const readOp = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'getPresence',
|
||||||
|
);
|
||||||
|
expect(readOp).toBe('5585991203123@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends as-is without caching when the lookup fails', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
session.checkNumberStatus = jest.fn(async () => {
|
||||||
|
throw new Error('engine down');
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
const lookups = session.checkNumberStatus.mock.calls.length;
|
||||||
|
// Not cached: the next send retries the lookup.
|
||||||
|
await resolveChat(session, '5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus.mock.calls.length).toBeGreaterThan(
|
||||||
|
lookups,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookup: false disables the WhatsApp tier', async () => {
|
||||||
|
const { session } = buildPlugin({ config: { lookup: false } });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers - wid.mention', () => {
|
||||||
|
it('resolves mentions best-effort and never breaks the send', async () => {
|
||||||
|
const { session } = buildPlugin({ config: { strict: true } });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
// Strict mode rejects the number on the chat hook, but a mention falls
|
||||||
|
// back to the input instead of failing the whole message.
|
||||||
|
const mention = await session.hooks.wid.mention.promise(
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'sendText',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mention).toBe('5585991203123@c.us');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers - persistent cache tier', () => {
|
||||||
|
function buildRepository() {
|
||||||
|
return {
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
setMany: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('serves a persisted resolution without any lookup and warms the memory tier', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
repository.get.mockResolvedValue({
|
||||||
|
key: '5585991203123',
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
verified: true,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
});
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
// Second call hits the memory tier - the database is read once.
|
||||||
|
expect(repository.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists verified resolutions under both forms of the number', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(repository.setMany).toHaveBeenCalledTimes(1);
|
||||||
|
const [keys, chatId, verified] = repository.setMany.mock.calls[0];
|
||||||
|
expect([...keys].sort()).toEqual(['558591203123', '5585991203123']);
|
||||||
|
expect(chatId).toBe('558591203123@c.us');
|
||||||
|
expect(verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist best-guesses or static-rule rewrites', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
// Static rule (DDD 11) and a confirmed-nonexistent best-guess (DDD 85).
|
||||||
|
await resolveChat(session, '551198765432@c.us');
|
||||||
|
await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
expect(repository.setMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves normally when the persistent tier errors out', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
repository.get.mockRejectedValue(new Error('db down'));
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers GOWS - local LID map tier', () => {
|
||||||
|
it('resolves via the LID map with no WhatsApp lookup', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
session.findLIDByPhoneNumber = jest.fn(async (phone: string) => {
|
||||||
|
if (phone === '558591203123') {
|
||||||
|
return { lid: '77820596330581@lid', pn: '558591203123@c.us' };
|
||||||
|
}
|
||||||
|
return { lid: null, pn: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the WhatsApp lookup for unknown numbers', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '5585991203123@c.us',
|
||||||
|
});
|
||||||
|
session.findLIDByPhoneNumber = jest.fn(async () => {
|
||||||
|
return { lid: null, pn: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers NOWEB - local contact store tier', () => {
|
||||||
|
it('resolves via the contact store with no WhatsApp lookup', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
session.store = {
|
||||||
|
getContactById: jest.fn(async (jid: string) => {
|
||||||
|
if (jid === '558591203123@s.whatsapp.net') {
|
||||||
|
return { id: '558591203123@s.whatsapp.net' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the WhatsApp lookup when the store has no match', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
session.store = {
|
||||||
|
getContactById: jest.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
extractPhoneDigits,
|
||||||
|
shouldSkipPhoneNormalization,
|
||||||
|
} from '@waha/apps/phone-numbers/utils/phone';
|
||||||
|
import {
|
||||||
|
generateBrazilMobileLookupCandidates,
|
||||||
|
isBrazilCountryCode,
|
||||||
|
isBrazilLandline,
|
||||||
|
isBrazilMobile,
|
||||||
|
isMalformedBrazilPhone,
|
||||||
|
needsBrazilWhatsAppLookup,
|
||||||
|
normalizeBrazilMobileForSendDigits,
|
||||||
|
normalizeBrazilTollFreeDigits,
|
||||||
|
} from './BrazilianPhoneNumberRule';
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumberRule', () => {
|
||||||
|
it('skips groups and lids', () => {
|
||||||
|
expect(shouldSkipPhoneNormalization('123@g.us')).toBe(true);
|
||||||
|
expect(shouldSkipPhoneNormalization('123@lid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR country code', () => {
|
||||||
|
expect(isBrazilCountryCode('558591203123')).toBe(true);
|
||||||
|
expect(isBrazilCountryCode('5491123456789')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags malformed BR numbers and accepts valid lengths', () => {
|
||||||
|
expect(isMalformedBrazilPhone('55859912')).toBe(true);
|
||||||
|
expect(isMalformedBrazilPhone('558591203123')).toBe(false);
|
||||||
|
expect(isMalformedBrazilPhone('5585991203123')).toBe(false);
|
||||||
|
// not a BR number, not our concern
|
||||||
|
expect(isMalformedBrazilPhone('123')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR landline numbers', () => {
|
||||||
|
expect(isBrazilLandline('558540423147')).toBe(true);
|
||||||
|
expect(isBrazilMobile('558540423147')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR mobile numbers', () => {
|
||||||
|
expect(isBrazilMobile('558591203123')).toBe(true);
|
||||||
|
expect(isBrazilMobile('5585991203123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any digit after the leading 9 on a 9-digit local', () => {
|
||||||
|
// Brazil has no 9-digit landline, so '9' + 8 digits is always mobile.
|
||||||
|
// The old rule required 6-9 right after the 9 and rejected real SP lines.
|
||||||
|
expect(isBrazilMobile('5511953523741')).toBe(true);
|
||||||
|
expect(isBrazilLandline('5511953523741')).toBe(false);
|
||||||
|
expect(isBrazilMobile('5511912345678')).toBe(true);
|
||||||
|
expect(isBrazilMobile('5511902345678')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps 9-digit mobiles out of the lookup range untouched', () => {
|
||||||
|
// DDD 11 is below the lookup range: no candidates, no WhatsApp lookup.
|
||||||
|
expect(needsBrazilWhatsAppLookup('5511953523741')).toBe(false);
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('5511953523741')).toBe(
|
||||||
|
'5511953523741',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects landlines in both DDD ranges', () => {
|
||||||
|
expect(isBrazilLandline('551151923057')).toBe(true);
|
||||||
|
expect(isBrazilLandline('558540428310')).toBe(true);
|
||||||
|
expect(isBrazilMobile('551151923057')).toBe(false);
|
||||||
|
expect(isBrazilMobile('558540428310')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites toll-free (0800) numbers to the stored form', () => {
|
||||||
|
// Dialed '0800' + 7 digits -> stored '55800' + 7 digits (leading 0 dropped,
|
||||||
|
// country code added). Both the bare and the 55-prefixed dialed forms map
|
||||||
|
// to the same stored number the server resolves them to.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('08000464636')).toBe('558000464636');
|
||||||
|
expect(normalizeBrazilTollFreeDigits('5508000464636')).toBe('558000464636');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-toll-free and already-stored numbers untouched', () => {
|
||||||
|
// Already-stored form routes through normally, so no rewrite here.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('558000464636')).toBeNull();
|
||||||
|
expect(normalizeBrazilTollFreeDigits('5585991203123')).toBeNull();
|
||||||
|
expect(normalizeBrazilTollFreeDigits('551151923057')).toBeNull();
|
||||||
|
// 0300/0500/0900 share the shape but are not handled here.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('03001234567')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires lookup only for DDD 31-99 mobile numbers', () => {
|
||||||
|
expect(needsBrazilWhatsAppLookup('5511987654321')).toBe(false);
|
||||||
|
expect(needsBrazilWhatsAppLookup('558591203123')).toBe(true);
|
||||||
|
expect(needsBrazilWhatsAppLookup('558540423147')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps send heuristic without 9 for DDD 31-99 until lookup resolves', () => {
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('558591203123')).toBe(
|
||||||
|
'558591203123',
|
||||||
|
);
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('555399034520')).toBe(
|
||||||
|
'555399034520',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes low DDD mobile numbers for send with 9 digits', () => {
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('551198765432')).toBe(
|
||||||
|
'5511998765432',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates with and without 9 candidates', () => {
|
||||||
|
expect(generateBrazilMobileLookupCandidates('558591203123')).toEqual([
|
||||||
|
'558591203123',
|
||||||
|
'5585991203123',
|
||||||
|
]);
|
||||||
|
expect(generateBrazilMobileLookupCandidates('5585991203123')).toEqual([
|
||||||
|
'558591203123',
|
||||||
|
'5585991203123',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts digits from chat ids', () => {
|
||||||
|
expect(extractPhoneDigits('558591203123@c.us')).toBe('558591203123');
|
||||||
|
expect(extractPhoneDigits('+558591203123')).toBe('558591203123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
PhoneNumberResolution,
|
||||||
|
PhoneNumberRule,
|
||||||
|
} from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
|
||||||
|
|
||||||
|
const COUNTRY_CODE = '55';
|
||||||
|
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
|
||||||
|
const MOBILE_FIRST_DIGIT = /^[6-9]/;
|
||||||
|
|
||||||
|
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
|
||||||
|
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
|
||||||
|
|
||||||
|
// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits.
|
||||||
|
const BR_PHONE_MIN_LENGTH = 12;
|
||||||
|
const BR_PHONE_MAX_LENGTH = 13;
|
||||||
|
|
||||||
|
export function isBrazilCountryCode(digits: string): boolean {
|
||||||
|
return digits.startsWith(COUNTRY_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMalformedBrazilPhone(digits: string): boolean {
|
||||||
|
if (!isBrazilCountryCode(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilPhone(digits: string): boolean {
|
||||||
|
return digits.startsWith(COUNTRY_CODE) && digits.length >= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrazilDdd(digits: string): number {
|
||||||
|
return parseInt(digits.substring(2, 4), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrazilLocalPart(digits: string): string {
|
||||||
|
return digits.substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilLandline(digits: string): boolean {
|
||||||
|
if (!isBrazilPhone(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
if (local.length !== 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return LANDLINE_FIRST_DIGIT.test(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilMobile(digits: string): boolean {
|
||||||
|
if (!isBrazilPhone(digits) || isBrazilLandline(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
// 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart
|
||||||
|
// from a landline - 2-5 is genuinely ambiguous and treated as a landline.
|
||||||
|
if (local.length === 8) {
|
||||||
|
return MOBILE_FIRST_DIGIT.test(local);
|
||||||
|
}
|
||||||
|
// 9-digit local starting with 9: unambiguously mobile. Brazil has no
|
||||||
|
// 9-digit landline, and the digit after the leading 9 is unrestricted -
|
||||||
|
// e.g. SP 11 9535-23741. Do not test it.
|
||||||
|
if (local.length === 9 && local[0] === '9') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus
|
||||||
|
// 7 subscriber digits. WhatsApp stores them under country code 55 with the
|
||||||
|
// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed
|
||||||
|
// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp
|
||||||
|
// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free
|
||||||
|
// number in a dialed form (the stored form '55800...' already routes untouched).
|
||||||
|
const BR_TOLLFREE_DIALED = /^0800\d{7}$/;
|
||||||
|
const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/;
|
||||||
|
|
||||||
|
export function normalizeBrazilTollFreeDigits(digits: string): string | null {
|
||||||
|
if (BR_TOLLFREE_DIALED.test(digits)) {
|
||||||
|
return `${COUNTRY_CODE}${digits.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (BR_TOLLFREE_DIALED_CC.test(digits)) {
|
||||||
|
return `${COUNTRY_CODE}${digits.slice(3)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsBrazilWhatsAppLookup(digits: string): boolean {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ddd = getBrazilDdd(digits);
|
||||||
|
return ddd >= BR_PHONE_DDD_LOOKUP_MIN && ddd <= BR_PHONE_DDD_LOOKUP_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrazilMobileForSendDigits(digits: string): string {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
const ddd = getBrazilDdd(digits);
|
||||||
|
if (ddd >= BR_PHONE_DDD_LOOKUP_MIN) {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
if (local.length === 8 && MOBILE_FIRST_DIGIT.test(local)) {
|
||||||
|
return `${COUNTRY_CODE}${ddd}9${local}`;
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBrazilMobileLookupCandidates(digits: string): string[] {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return [digits];
|
||||||
|
}
|
||||||
|
const ddd = digits.substring(2, 4);
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
let without9 = digits;
|
||||||
|
let with9 = digits;
|
||||||
|
|
||||||
|
if (local.length === 9 && local[0] === '9') {
|
||||||
|
without9 = `${COUNTRY_CODE}${ddd}${local.substring(1)}`;
|
||||||
|
with9 = `${COUNTRY_CODE}${ddd}${local}`;
|
||||||
|
} else if (local.length === 8) {
|
||||||
|
without9 = `${COUNTRY_CODE}${ddd}${local}`;
|
||||||
|
with9 = `${COUNTRY_CODE}${ddd}9${local}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [without9, with9];
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brazil: mobiles got a 9th digit, callers send either form - static rule for DDD < 31, lookup for the rest
|
||||||
|
*/
|
||||||
|
export class BrazilianPhoneNumberRule implements PhoneNumberRule {
|
||||||
|
matches(digits: string): boolean {
|
||||||
|
// Dialed toll-free has no country code
|
||||||
|
return isBrazilCountryCode(digits) || BR_TOLLFREE_DIALED.test(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(digits: string): PhoneNumberResolution | null {
|
||||||
|
// Before the length check - dialed '0800...' is shorter than a full number
|
||||||
|
const tollFree = normalizeBrazilTollFreeDigits(digits);
|
||||||
|
if (tollFree) {
|
||||||
|
return { candidates: [], fallback: tollFree };
|
||||||
|
}
|
||||||
|
if (isMalformedBrazilPhone(digits)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Landlines are sent as is
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return { candidates: [], fallback: digits };
|
||||||
|
}
|
||||||
|
const normalized = normalizeBrazilMobileForSendDigits(digits);
|
||||||
|
if (!needsBrazilWhatsAppLookup(digits)) {
|
||||||
|
return { candidates: [], fallback: normalized };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
candidates: generateBrazilMobileLookupCandidates(digits),
|
||||||
|
fallback: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
|
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
|
||||||
|
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
|
||||||
|
import { BrazilianPhoneNumbersCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneNumbersCacheRepository';
|
||||||
|
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
|
||||||
|
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrazilianPhoneNumbersAppService extends PhoneNumbersAppServiceBase<BrazilianPhoneNumbersAppConfig> {
|
||||||
|
protected readonly Repository = BrazilianPhoneNumbersCacheRepository;
|
||||||
|
|
||||||
|
constructor(resolver: UniqueAppResolver) {
|
||||||
|
super(resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected rules(config: BrazilianPhoneNumbersAppConfig): PhoneNumberRule[] {
|
||||||
|
void config;
|
||||||
|
return [new BrazilianPhoneNumberRule()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
|
||||||
|
|
||||||
|
export class BrazilianPhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
|
||||||
|
static tableName = 'app_brazilian_phone_numbers_cache';
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
||||||
|
|
||||||
|
const CallsAppModule: AppModule = {
|
||||||
|
name: AppName.calls,
|
||||||
|
openapi: {
|
||||||
|
title: 'Calls',
|
||||||
|
description: 'Handle incoming calls - reject, message back',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: false,
|
||||||
|
queue: false,
|
||||||
|
migrations: false,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: [],
|
||||||
|
controllers: [],
|
||||||
|
providers: [CallsAppService],
|
||||||
|
},
|
||||||
|
Service: CallsAppService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CallsAppModule;
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
|
||||||
|
|
||||||
export const CallsAppExports = {
|
|
||||||
providers: [CallsAppService],
|
|
||||||
imports: [],
|
|
||||||
controllers: [],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
|
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CallsAppDto extends App<CallsAppConfig> {
|
||||||
|
@Type(() => CallsAppConfig)
|
||||||
|
config: CallsAppConfig;
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
@@ -23,6 +25,26 @@ export class CallsAppChannelConfig {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
message?: string;
|
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 {
|
export class CallsAppConfig {
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
|
import { PluginOptions } from '@waha/core/abc/session.plugin';
|
||||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||||
|
import { CallsPlugin } from '@waha/apps/calls/services/CallsPlugin';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
|
||||||
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallsAppService implements IAppService {
|
export class CallsAppService implements IAppService {
|
||||||
constructor(
|
|
||||||
@InjectPinoLogger('CallsAppService')
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
validate(app: App<CallsAppConfig>): void {
|
validate(app: App<CallsAppConfig>): void {
|
||||||
// The DTO validation covers structure; no extra validation rules.
|
// The DTO validation covers structure; no extra validation rules.
|
||||||
void app;
|
void app;
|
||||||
@@ -25,38 +21,84 @@ export class CallsAppService implements IAppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async beforeEnabled(
|
async beforeEnabled(
|
||||||
|
manager: SessionManager,
|
||||||
savedApp: App<CallsAppConfig>,
|
savedApp: App<CallsAppConfig>,
|
||||||
newApp: App<CallsAppConfig>,
|
newApp: App<CallsAppConfig>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Enabling behaves the same as creating for this lightweight app.
|
void manager;
|
||||||
void savedApp;
|
void savedApp;
|
||||||
void newApp;
|
void newApp;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async beforeDisabled(
|
async beforeDisabled(
|
||||||
|
manager: SessionManager,
|
||||||
savedApp: App<CallsAppConfig>,
|
savedApp: App<CallsAppConfig>,
|
||||||
newApp: App<CallsAppConfig>,
|
newApp: App<CallsAppConfig>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
void newApp;
|
void manager;
|
||||||
void savedApp;
|
void savedApp;
|
||||||
|
void newApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
async beforeUpdated(
|
async beforeUpdated(
|
||||||
|
manager: SessionManager,
|
||||||
savedApp: App<CallsAppConfig>,
|
savedApp: App<CallsAppConfig>,
|
||||||
newApp: App<CallsAppConfig>,
|
newApp: App<CallsAppConfig>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
void manager;
|
||||||
void savedApp;
|
void savedApp;
|
||||||
void newApp;
|
void newApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
|
async beforeDeleted(
|
||||||
|
manager: SessionManager,
|
||||||
|
app: App<CallsAppConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
void manager;
|
||||||
void app;
|
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 purge(
|
||||||
|
manager: SessionManager,
|
||||||
|
app: App<CallsAppConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
void manager;
|
||||||
|
void app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrich(
|
||||||
|
manager: SessionManager,
|
||||||
|
app: App<CallsAppConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
void manager;
|
||||||
|
void app;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins(app: App<CallsAppConfig>, session: WhatsappSession): PluginOptions[] {
|
||||||
|
void session;
|
||||||
|
return [CallsPlugin.with(app.config, null)];
|
||||||
|
}
|
||||||
|
|
||||||
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
||||||
const listener = new CallsListener(app, session, this.logger);
|
void app;
|
||||||
listener.attach();
|
void session;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
||||||
|
|||||||
@@ -1,62 +1,29 @@
|
|||||||
import { Subscription } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
CallsAppChannelConfig,
|
CallsAppChannelConfig,
|
||||||
CallsAppConfig,
|
CallsAppConfig,
|
||||||
} from '@waha/apps/calls/dto/config.dto';
|
} from '@waha/apps/calls/dto/config.dto';
|
||||||
import { Logger } from 'pino';
|
import { SessionPlugin } from '@waha/core/abc/session.plugin';
|
||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { PluginEvent } from '@waha/core/abc/session.plugin.events';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
|
||||||
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
|
|
||||||
import { CallData } from '@waha/structures/calls.dto';
|
import { CallData } from '@waha/structures/calls.dto';
|
||||||
import { MessageTextRequest } from '@waha/structures/chatting.dto';
|
import { MessageTextRequest } from '@waha/structures/chatting.dto';
|
||||||
|
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
|
||||||
import { sleep } from '@waha/utils/promiseTimeout';
|
import { sleep } from '@waha/utils/promiseTimeout';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
export class CallsListener {
|
/**
|
||||||
private subscription?: Subscription;
|
* Rejects incoming calls and optionally sends an auto-reply message.
|
||||||
private config: CallsAppConfig;
|
*/
|
||||||
private readonly log: Logger;
|
export class CallsPlugin extends SessionPlugin<CallsAppConfig> {
|
||||||
private readonly session: WhatsappSession;
|
@PluginEvent(WAHAEvents.CALL_RECEIVED)
|
||||||
|
onCallReceived(calls$: Observable<CallData>) {
|
||||||
constructor(
|
calls$.subscribe((call: CallData) => {
|
||||||
app: App<CallsAppConfig>,
|
this.handleCall(call).catch((error) => {
|
||||||
session: WhatsappSession,
|
this.logger.error(
|
||||||
logger: PinoLogger,
|
{ err: error, callId: call?.id },
|
||||||
) {
|
|
||||||
this.session = session;
|
|
||||||
this.config = app.config;
|
|
||||||
this.log = logger.logger.child({
|
|
||||||
app: 'calls',
|
|
||||||
session: app.session,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(): void {
|
|
||||||
this.detach();
|
|
||||||
|
|
||||||
const observable = this.session.getEventObservable(
|
|
||||||
WAHAEvents.CALL_RECEIVED,
|
|
||||||
);
|
|
||||||
if (!observable) {
|
|
||||||
this.log.warn('CALL_RECEIVED event stream is not available, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscription = observable.subscribe((payload) => {
|
|
||||||
this.handleCall(payload as CallData).catch((error) => {
|
|
||||||
this.log.error(
|
|
||||||
{ err: error, callId: (payload as any)?.id },
|
|
||||||
'Failed to handle incoming call',
|
'Failed to handle incoming call',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.log.info('Calls app listener is attached');
|
|
||||||
}
|
|
||||||
|
|
||||||
detach(): void {
|
|
||||||
this.subscription?.unsubscribe();
|
|
||||||
this.subscription = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private configFor(call: CallData): CallsAppChannelConfig {
|
private configFor(call: CallData): CallsAppChannelConfig {
|
||||||
@@ -65,17 +32,17 @@ export class CallsListener {
|
|||||||
|
|
||||||
private async handleCall(call: CallData): Promise<void> {
|
private async handleCall(call: CallData): Promise<void> {
|
||||||
if (!call.from) {
|
if (!call.from) {
|
||||||
this.log.warn({ call: call?.id }, 'Incoming call has no chat id');
|
this.logger.warn({ call: call?.id }, 'Incoming call has no chat id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!call.id) {
|
if (!call.id) {
|
||||||
this.log.warn({ from: call.from }, 'Incoming call has no from');
|
this.logger.warn({ from: call.from }, 'Incoming call has no from');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.configFor(call);
|
const config = this.configFor(call);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
|
this.logger.warn({ callId: call.id }, 'No calls config found, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +51,7 @@ export class CallsListener {
|
|||||||
const shouldMessage = message.length > 0;
|
const shouldMessage = message.length > 0;
|
||||||
|
|
||||||
if (!shouldReject && !shouldMessage) {
|
if (!shouldReject && !shouldMessage) {
|
||||||
this.log.debug(
|
this.logger.debug(
|
||||||
{ callId: call.id, chatId: call.from },
|
{ callId: call.id, chatId: call.from },
|
||||||
'No actions configured for this call',
|
'No actions configured for this call',
|
||||||
);
|
);
|
||||||
@@ -92,25 +59,32 @@ export class CallsListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldReject) {
|
if (shouldReject) {
|
||||||
|
const waitBeforeDeclineMs = (config.waitBeforeDecline ?? 0) * 1000;
|
||||||
|
await sleep(waitBeforeDeclineMs);
|
||||||
await this.rejectCall(call);
|
await this.rejectCall(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldMessage) {
|
if (shouldMessage) {
|
||||||
|
const waitBeforeResponseMs = (config.waitBeforeResponse ?? 0) * 1000;
|
||||||
|
await sleep(waitBeforeResponseMs);
|
||||||
await this.replyWithTyping(call.from, message);
|
await this.replyWithTyping(call.from, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rejectCall(call: CallData): Promise<void> {
|
private async rejectCall(call: CallData): Promise<void> {
|
||||||
this.log.debug({ from: call.from, id: call.id }, 'Rejecting incoming call');
|
this.logger.debug(
|
||||||
|
{ from: call.from, id: call.id },
|
||||||
|
'Rejecting incoming call',
|
||||||
|
);
|
||||||
await this.session.rejectCall(call.from, call.id);
|
await this.session.rejectCall(call.from, call.id);
|
||||||
this.log.info({ from: call.from, id: call.id }, 'Call rejected');
|
this.logger.info({ from: call.from, id: call.id }, 'Call rejected');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replyWithTyping(
|
private async replyWithTyping(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.log.info(
|
this.logger.info(
|
||||||
{ chatId: chatId },
|
{ chatId: chatId },
|
||||||
'Sending auto-response for rejected call',
|
'Sending auto-response for rejected call',
|
||||||
);
|
);
|
||||||
@@ -128,7 +102,7 @@ export class CallsListener {
|
|||||||
try {
|
try {
|
||||||
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
|
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.warn(
|
this.logger.warn(
|
||||||
{ err: error, chatId: chatId },
|
{ err: error, chatId: chatId },
|
||||||
'Failed to set typing presence before reply',
|
'Failed to set typing presence before reply',
|
||||||
);
|
);
|
||||||
@@ -142,7 +116,7 @@ export class CallsListener {
|
|||||||
try {
|
try {
|
||||||
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
|
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.warn(
|
this.logger.warn(
|
||||||
{ err: error, chatId: chatId },
|
{ err: error, chatId: chatId },
|
||||||
'Failed to clear typing presence after reply',
|
'Failed to clear typing presence after reply',
|
||||||
);
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
|
||||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||||
|
|
||||||
interface LanguageResponse {
|
interface LanguageResponse {
|
||||||
@@ -10,7 +10,6 @@ interface LanguageResponse {
|
|||||||
|
|
||||||
@Controller('api/apps/chatwoot')
|
@Controller('api/apps/chatwoot')
|
||||||
@ApiSecurity('api_key')
|
@ApiSecurity('api_key')
|
||||||
@ApiTags('🧩 Apps')
|
|
||||||
export class ChatwootLocalesController {
|
export class ChatwootLocalesController {
|
||||||
@Get('locales')
|
@Get('locales')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation } from '@nestjs/swagger';
|
||||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||||
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
|
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
|
||||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||||
@@ -13,9 +13,9 @@ import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueue
|
|||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
||||||
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
||||||
|
import { IsExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
|
||||||
|
|
||||||
@Controller('webhooks/chatwoot/')
|
@Controller('webhooks/chatwoot/')
|
||||||
@ApiTags('🧩 Apps')
|
|
||||||
export class ChatwootWebhookController {
|
export class ChatwootWebhookController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly chatWootQueueService: ChatWootQueueService,
|
private readonly chatWootQueueService: ChatWootQueueService,
|
||||||
@@ -42,8 +42,13 @@ export class ChatwootWebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCommandsChat = IsCommandsChat(body);
|
const isCommandsChat = IsCommandsChat(body);
|
||||||
// Ignore private notes (most of them)
|
|
||||||
const deleted = body?.content_attributes?.deleted;
|
const deleted = body?.content_attributes?.deleted;
|
||||||
|
// Ignore messages that came from WhatsApp, do not send them back
|
||||||
|
if (IsExternalEcho(body) && !deleted) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore private notes (most of them)
|
||||||
if (body.private) {
|
if (body.private) {
|
||||||
// Ignore any private note in commands chats
|
// Ignore any private note in commands chats
|
||||||
if (isCommandsChat) {
|
if (isCommandsChat) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { conversation_message_create } from '@figuro/chatwoot-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a message WAHA creates from a WhatsApp message, so the webhook does not send it back
|
||||||
|
*/
|
||||||
|
export function SetExternalEcho(body: conversation_message_create) {
|
||||||
|
body.content_attributes = { ...body.content_attributes, external_echo: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message was created by WAHA from a WhatsApp message, not by an agent
|
||||||
|
*/
|
||||||
|
export function IsExternalEcho(body: any): boolean {
|
||||||
|
return Boolean(body?.content_attributes?.external_echo);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
||||||
import {
|
import {
|
||||||
ExponentialRetriesJobOptions,
|
ExponentialRetriesJobOptions,
|
||||||
@@ -6,6 +8,7 @@ import {
|
|||||||
NoRetriesJobOptions,
|
NoRetriesJobOptions,
|
||||||
} from '@waha/apps/app_sdk/constants';
|
} from '@waha/apps/app_sdk/constants';
|
||||||
import { MessageCleanupConsumer } from '@waha/apps/chatwoot/consumers/scheduled/message.cleanup';
|
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 { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
||||||
import * as lodash from 'lodash';
|
import * as lodash from 'lodash';
|
||||||
|
|
||||||
@@ -36,10 +39,15 @@ import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/con
|
|||||||
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
||||||
|
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
|
||||||
|
|
||||||
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
||||||
|
|
||||||
const IMPORTS = lodash.flatten([
|
const IMPORTS = lodash.flatten([
|
||||||
|
HttpPathsRegistration({
|
||||||
|
prefix: '/webhooks/',
|
||||||
|
include: { authBasic: false },
|
||||||
|
}),
|
||||||
BullModule.registerFlowProducer({
|
BullModule.registerFlowProducer({
|
||||||
name: FlowProducerName.MESSAGES_PULL_FLOW,
|
name: FlowProducerName.MESSAGES_PULL_FLOW,
|
||||||
}),
|
}),
|
||||||
@@ -51,6 +59,10 @@ const IMPORTS = lodash.flatten([
|
|||||||
name: QueueName.SCHEDULED_CHECK_VERSION,
|
name: QueueName.SCHEDULED_CHECK_VERSION,
|
||||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||||
}),
|
}),
|
||||||
|
RegisterAppQueue({
|
||||||
|
name: QueueName.SCHEDULED_CHECK_TIER,
|
||||||
|
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||||
|
}),
|
||||||
RegisterAppQueue({
|
RegisterAppQueue({
|
||||||
name: QueueName.TASK_CONTACTS_PULL,
|
name: QueueName.TASK_CONTACTS_PULL,
|
||||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||||
@@ -145,6 +157,7 @@ const PROVIDERS = [
|
|||||||
// Scheduled
|
// Scheduled
|
||||||
MessageCleanupConsumer,
|
MessageCleanupConsumer,
|
||||||
CheckVersionConsumer,
|
CheckVersionConsumer,
|
||||||
|
CheckTierConsumer,
|
||||||
// Services
|
// Services
|
||||||
ChatWootWAHAQueueService,
|
ChatWootWAHAQueueService,
|
||||||
ChatWootQueueService,
|
ChatWootQueueService,
|
||||||
@@ -154,8 +167,25 @@ const PROVIDERS = [
|
|||||||
QueueManager,
|
QueueManager,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ChatWootExports = {
|
const ChatWootAppModule: AppModule = {
|
||||||
providers: PROVIDERS,
|
name: AppName.chatwoot,
|
||||||
imports: IMPORTS,
|
openapi: {
|
||||||
controllers: CONTROLLERS,
|
title: 'Chatwoot',
|
||||||
|
description: 'Chatwoot integration',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: true,
|
||||||
|
queue: true,
|
||||||
|
migrations: true,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: IMPORTS,
|
||||||
|
controllers: CONTROLLERS,
|
||||||
|
providers: PROVIDERS,
|
||||||
|
},
|
||||||
|
Service: ChatWootAppService,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ChatWootAppModule;
|
||||||
@@ -20,8 +20,18 @@ export async function QueueStatus(ctx: QueueCommandContext, name: string) {
|
|||||||
for (const status of result) {
|
for (const status of result) {
|
||||||
status.name = QueueNameRepr(status.name);
|
status.name = QueueNameRepr(status.name);
|
||||||
}
|
}
|
||||||
// locked: true - last
|
const categoryOrder = ['inbox', 'whatsapp', 'task', 'scheduled'];
|
||||||
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
|
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', {
|
const msg = ctx.l.r('cli.cmd.queue.status.result', {
|
||||||
queues: result,
|
queues: result,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function runText(
|
|||||||
.map((line) => line.trimStart()) // remove indentation
|
.map((line) => line.trimStart()) // remove indentation
|
||||||
.join(' ') // join with space
|
.join(' ') // join with space
|
||||||
.trim(); // final cleanup
|
.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 output = new BufferedOutput();
|
||||||
const program = BuildProgram(commands, ctx, output);
|
const program = BuildProgram(commands, ctx, output);
|
||||||
const argv = parse(text);
|
const argv = parse(text);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
||||||
import { JobsOptions } from 'bullmq';
|
import { JobsOptions } from 'bullmq';
|
||||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||||
|
import { NameUpdateMode } from '@waha/apps/chatwoot/client/ContactService';
|
||||||
|
|
||||||
export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
||||||
const l = ctx.l;
|
const l = ctx.l;
|
||||||
@@ -39,6 +40,14 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
|||||||
.choices(['if-missing', 'update'])
|
.choices(['if-missing', 'update'])
|
||||||
.preset('if-missing'),
|
.preset('if-missing'),
|
||||||
)
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
'-n, --update-names <mode>',
|
||||||
|
l.r('cli.cmd.contacts.pull.option.update-names'),
|
||||||
|
)
|
||||||
|
.choices(Object.values(NameUpdateMode))
|
||||||
|
.default(NameUpdateMode.NO),
|
||||||
|
)
|
||||||
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
|
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
|
||||||
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
|
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
|
||||||
.option(
|
.option(
|
||||||
@@ -92,6 +101,7 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
|||||||
batch: opts.batch,
|
batch: opts.batch,
|
||||||
progress: opts.progress,
|
progress: opts.progress,
|
||||||
avatar: opts.avatar,
|
avatar: opts.avatar,
|
||||||
|
updateNames: opts.updateNames,
|
||||||
attributes: opts.attributes,
|
attributes: opts.attributes,
|
||||||
contacts: {
|
contacts: {
|
||||||
lids: opts.lids,
|
lids: opts.lids,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
|||||||
* Parse human string into milliseconds
|
* Parse human string into milliseconds
|
||||||
*/
|
*/
|
||||||
export function ParseMS(value: string) {
|
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 == null) throw new Error(`Invalid duration: "${value}"`);
|
||||||
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
|
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
|
||||||
return duration;
|
return duration;
|
||||||
|
|||||||
@@ -19,10 +19,41 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
|||||||
|
|
||||||
import { CacheForConfig } from '../cache/ConversationCache';
|
import { CacheForConfig } from '../cache/ConversationCache';
|
||||||
import { IConversationCache } from '../cache/IConversationCache';
|
import { IConversationCache } from '../cache/IConversationCache';
|
||||||
|
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||||
|
|
||||||
export interface ContactInfo {
|
export interface ContactInfo {
|
||||||
ChatId(): string;
|
ChatId(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuse an already fetched WhatsApp contact instead of requesting it again
|
||||||
|
*/
|
||||||
|
SetFetchedContact(contact: any): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked id - @lid, null when unknown
|
||||||
|
*/
|
||||||
|
LidId(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone number jid - @c.us, null when unknown
|
||||||
|
*/
|
||||||
|
JidId(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved E.164 phone number, null when not applicable
|
||||||
|
*/
|
||||||
|
PhoneNumberE164(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name saved in the phone book, null when not saved
|
||||||
|
*/
|
||||||
|
SavedName(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name the contact set for themselves, null when unknown
|
||||||
|
*/
|
||||||
|
PushName(): Promise<string | null>;
|
||||||
|
|
||||||
AvatarUrl(): Promise<string | null>;
|
AvatarUrl(): Promise<string | null>;
|
||||||
|
|
||||||
Attributes(): Promise<any>;
|
Attributes(): Promise<any>;
|
||||||
@@ -63,6 +94,8 @@ export class ContactConversationService {
|
|||||||
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
|
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
|
||||||
);
|
);
|
||||||
const attributes = await contactInfo.Attributes();
|
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(
|
await this.contactService.upsertCustomAttributes(
|
||||||
cwContact.data,
|
cwContact.data,
|
||||||
attributes,
|
attributes,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { sanitizeName } from '@waha/apps/chatwoot/client/ContactService';
|
import {
|
||||||
|
IsRawName,
|
||||||
|
sanitizeName,
|
||||||
|
} from '@waha/apps/chatwoot/client/ContactService';
|
||||||
|
|
||||||
describe('sanitizeName', () => {
|
describe('sanitizeName', () => {
|
||||||
it('should return the same name', () => {
|
it('should return the same name', () => {
|
||||||
@@ -21,3 +24,36 @@ describe('sanitizeName', () => {
|
|||||||
expect(sanitizeName(name)).toBe(name);
|
expect(sanitizeName(name)).toBe(name);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IsRawName', () => {
|
||||||
|
const placeholders = [
|
||||||
|
'11111111111@c.us',
|
||||||
|
'11111111111@c.us',
|
||||||
|
'011111111111111@lid',
|
||||||
|
'+11111111111',
|
||||||
|
'+11111111111',
|
||||||
|
'Johnny',
|
||||||
|
];
|
||||||
|
const cases: Array<{ name: string; expected: boolean }> = [
|
||||||
|
{ name: '', expected: true },
|
||||||
|
{ name: ' ', expected: true },
|
||||||
|
{ name: '11111111111@c.us', expected: true },
|
||||||
|
{ name: '11111111111', expected: true },
|
||||||
|
{ name: '+11111111111', expected: true },
|
||||||
|
{ name: '011111111111111@lid', expected: true },
|
||||||
|
{ name: '011111111111111', expected: true },
|
||||||
|
{ name: 'Johnny', expected: true },
|
||||||
|
{ name: ' johnny ', expected: true },
|
||||||
|
{ name: 'John Doe', expected: false },
|
||||||
|
{ name: 'John (Sales)', expected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)('$name', ({ name, expected }) => {
|
||||||
|
expect(IsRawName(name, placeholders)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a nameless contact with no ids as placeholder', () => {
|
||||||
|
expect(IsRawName('', [null, undefined])).toBe(true);
|
||||||
|
expect(IsRawName('John', [null, undefined])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,43 @@ export enum AvatarUpdateMode {
|
|||||||
ALWAYS,
|
ALWAYS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum NameUpdateMode {
|
||||||
|
// skip name updates, only new contacts get a name (phone book, push name or phone number)
|
||||||
|
NO = 'no',
|
||||||
|
// keep names set by hand, replace raw ones (phone number, push name) from the phone book
|
||||||
|
IF_RAW = 'if-raw',
|
||||||
|
// the phone book is the source of truth, overwrite any name
|
||||||
|
ALWAYS = 'always',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw name - empty or one of the ids the contact was created with, nobody typed it by hand
|
||||||
|
*/
|
||||||
|
export function IsRawName(
|
||||||
|
name: string,
|
||||||
|
placeholders: Array<string | null | undefined>,
|
||||||
|
): boolean {
|
||||||
|
const current = normalizeName(name);
|
||||||
|
if (!current) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
if (!placeholder) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = normalizeName(placeholder);
|
||||||
|
const bare = value.replace(/@.*$/, '').replace(/^\+/, '');
|
||||||
|
if (current === value || current === bare || current === `+${bare}`) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(name: string): string {
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeName(name: string) {
|
export function sanitizeName(name: string) {
|
||||||
// 255 chars max
|
// 255 chars max
|
||||||
const limit = 255;
|
const limit = 255;
|
||||||
@@ -40,6 +77,18 @@ export function sanitizeName(name: string) {
|
|||||||
return clean.slice(0, 255).trim();
|
return clean.slice(0, 255).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchClauseEqualTo(key: string, values: string[]) {
|
||||||
|
// equal_to with multiple values acts as IN
|
||||||
|
return {
|
||||||
|
attribute_key: key,
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: values,
|
||||||
|
attribute_model: 'standard',
|
||||||
|
custom_attribute_type: '',
|
||||||
|
query_operator: 'OR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class ContactService {
|
export class ContactService {
|
||||||
constructor(
|
constructor(
|
||||||
private config: ChatWootAPIConfig,
|
private config: ChatWootAPIConfig,
|
||||||
@@ -52,8 +101,11 @@ export class ContactService {
|
|||||||
contactInfo: ContactInfo,
|
contactInfo: ContactInfo,
|
||||||
): Promise<[ContactResponse, boolean]> {
|
): Promise<[ContactResponse, boolean]> {
|
||||||
const chatId = contactInfo.ChatId();
|
const chatId = contactInfo.ChatId();
|
||||||
let contact = await this.searchByAnyID(chatId);
|
const lid = await contactInfo.LidId();
|
||||||
|
const jid = await contactInfo.JidId();
|
||||||
|
let contact = await this.search(chatId, lid, jid);
|
||||||
if (contact) {
|
if (contact) {
|
||||||
|
await this.upsertPhoneNumber(contact, contactInfo);
|
||||||
return [contact, false];
|
return [contact, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,73 +114,94 @@ export class ContactService {
|
|||||||
return [contact, true];
|
return [contact, true];
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
|
async search(
|
||||||
const payload: any[] = [
|
chatId: string,
|
||||||
{
|
lid: string | null,
|
||||||
attribute_key: AttributeKey.WA_CHAT_ID,
|
jid: string | null,
|
||||||
filter_operator: 'equal_to',
|
): Promise<ContactResponse | null> {
|
||||||
values: [chatId],
|
// The chat id attribute holds the latest used address, so match any known form there
|
||||||
attribute_model: 'standard',
|
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
|
||||||
custom_attribute_type: '',
|
if (chatIds.length == 0) {
|
||||||
query_operator: 'OR',
|
return null;
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: AttributeKey.WA_JID,
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
query_operator: 'OR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: AttributeKey.WA_LID,
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
query_operator: 'OR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: 'identifier',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isJidCusFormat(chatId)) {
|
|
||||||
// Search by phone
|
|
||||||
const phoneNumberE164 = E164Parser.fromJid(chatId);
|
|
||||||
const phone_number = phoneNumberE164.replace('+', '');
|
|
||||||
payload[payload.length - 1].query_operator = 'OR';
|
|
||||||
payload.push({
|
|
||||||
attribute_key: 'phone_number',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [phone_number],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload: any[] = [
|
||||||
|
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
|
||||||
|
SearchClauseEqualTo('identifier', chatIds),
|
||||||
|
];
|
||||||
|
if (jid) {
|
||||||
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
|
||||||
|
}
|
||||||
|
if (lid) {
|
||||||
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
|
||||||
|
}
|
||||||
|
if (jid && isJidCusFormat(jid)) {
|
||||||
|
// Search by phone - both with and without the leading '+'
|
||||||
|
const phoneNumberE164 = E164Parser.fromJid(jid);
|
||||||
|
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
|
||||||
|
payload.push(SearchClauseEqualTo('phone_number', phones));
|
||||||
|
}
|
||||||
|
// The terminal clause must have no query_operator
|
||||||
|
delete payload[payload.length - 1].query_operator;
|
||||||
|
|
||||||
const response: any = await this.accountAPI.contacts.filter({
|
const response: any = await this.accountAPI.contacts.filter({
|
||||||
accountId: this.config.accountId,
|
accountId: this.config.accountId,
|
||||||
payload: payload as any,
|
payload: payload as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contacts = response.payload;
|
const contacts = response.payload;
|
||||||
if (contacts.length == 0) {
|
const candidates: ContactResponse[] = [];
|
||||||
|
for (const contact of contacts) {
|
||||||
|
const inboxes = lodash.filter(contact.contact_inboxes, {
|
||||||
|
inbox: { id: this.config.inboxId },
|
||||||
|
});
|
||||||
|
if (inboxes.length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
data: contact,
|
||||||
|
sourceId: inboxes[0].source_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (candidates.length == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const contact = contacts[0];
|
// Prefer a contact with a phone number over a phone-less duplicate
|
||||||
const inboxes = lodash.filter(contact.contact_inboxes, {
|
const withPhone = candidates.find((candidate) =>
|
||||||
inbox: { id: this.config.inboxId },
|
Boolean(candidate.data.phone_number),
|
||||||
});
|
);
|
||||||
if (inboxes.length == 0) {
|
return withPhone ?? candidates[0];
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
private async upsertPhoneNumber(
|
||||||
|
contact: ContactResponse,
|
||||||
|
contactInfo: ContactInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
if (contact.data.phone_number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
|
||||||
|
if (!phoneNumberE164) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.accountAPI.contacts.update({
|
||||||
|
id: contact.data.id,
|
||||||
|
accountId: this.config.accountId,
|
||||||
|
data: { phone_number: phoneNumberE164 },
|
||||||
|
});
|
||||||
|
contact.data.phone_number = phoneNumberE164;
|
||||||
|
this.logger.info(
|
||||||
|
`Set phone_number for contact.id: ${
|
||||||
|
contact.data.id
|
||||||
|
}, chat.id: ${contactInfo.ChatId()}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Chatwoot returns 422 when another contact already owns the phone number
|
||||||
|
this.logger.warn(
|
||||||
|
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
data: contact,
|
|
||||||
sourceId: inboxes[0].source_id,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async upsertCustomAttributes(
|
public async upsertCustomAttributes(
|
||||||
@@ -149,6 +222,37 @@ export class ContactService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the contact name, returns true when it changed
|
||||||
|
*/
|
||||||
|
public async updateName(
|
||||||
|
contact: ContactResponse,
|
||||||
|
name: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
name = sanitizeName(name);
|
||||||
|
const current = contact.data.name ?? '';
|
||||||
|
if (!name || current.trim() === name.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.accountAPI.contacts.update({
|
||||||
|
id: contact.data.id,
|
||||||
|
accountId: this.config.accountId,
|
||||||
|
data: { name: name },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error updating name for contact.id: ${contact.data.id} - ${err}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.logger.info(
|
||||||
|
`Renamed contact.id: ${contact.data.id}: '${current}' => '${name}'`,
|
||||||
|
);
|
||||||
|
contact.data.name = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async create(
|
public async create(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
payload: public_contact_create_update_payload,
|
payload: public_contact_create_update_payload,
|
||||||
|
|||||||
Loaded 100 of 526 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user