Compare commits

...
141 Commits
Author SHA1 Message Date
devlikepro 03e9588581 [core] 2026.3.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-03-05 17:12:33 +07:00
devlikepro 393f5ef7da [core] Fix "user_agents_1.default is not a constructor" when sending media 2026-03-05 17:12:33 +07:00
devlikepro f778678514 [core] do not fail fast 2026-03-05 17:12:32 +07:00
devlikepro e4f88771a8 [core] WPP - fix session folder
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-03-05 13:14:30 +07:00
devlikepro e885ea8e95 [core] 2026.3.1 2026-03-05 12:41:24 +07:00
devlikepro f8a9ed7d06 [core] Up dashboard 2026-03-05 12:41:23 +07:00
devlikepro d89e9105d0 [core] WAHA_NAMESPACE=all in example 2026-03-05 12:41:23 +07:00
devlikepro 3ba92dec04 [core] WAHA_NAMESPACE and WAHA_SESSION_NAMESPACE 2026-03-05 12:41:22 +07:00
devlikepro 441ebbd525 [core] WPP - forwardMessagesV2 2026-03-05 12:41:22 +07:00
devlikepro 690c43c245 [core] Apps - enable by default, allow in memory apps (no queue required) 2026-03-05 12:41:20 +07:00
devlikepro 26d8591a6c [core] media - fetch user-agent 2026-03-05 12:41:18 +07:00
devlikepro ac63c097ec [core] Add WPP engine - fix #101 2026-03-05 12:41:17 +07:00
devlikepro d29e4f8351 [core] Add vcard waid and tests 2026-03-05 12:41:17 +07:00
devlikepro 615ccda4c3 [core] ChatWoot - ParseMS typing 2026-03-05 12:41:16 +07:00
devlikepro d52eb71c76 [core] channels - try to resolve code or use id provided 2026-03-05 12:41:16 +07:00
devlikepro 8edd81a765 [core] ChatWoot - add participant if it's group in message mapping 2026-03-05 12:41:16 +07:00
devlikepro 5830cee384 [core] AGENTS.md 2026-03-05 12:41:15 +07:00
devlikepro 20f15b4370 [core] WEBJS - kill processes and remove Singleton 2026-03-05 12:41:15 +07:00
devlikepro 8432cd4f8d [core] GET /api/{session}/contacts/{id} 2026-03-05 12:41:13 +07:00
devlikepro 55870b88bf [core] @Activity 2026-03-05 12:41:12 +07:00
devlikepro 6acf4373f4 [core] CLAUDE.md 2026-03-05 12:41:12 +07:00
devlikepro 2381f0843f [core] Up NOWEB 2026-03-05 12:41:10 +07:00
devlikepro b4e6f5e722 [core] Fix no auth case - fix #1939 2026-03-05 12:41:10 +07:00
devlikepro 415bee3e8e [core] NOWEB - fix psql request 2026-03-05 12:41:09 +07:00
devlikepro aad924be79 [core] Up Dashboard 2026-03-05 12:41:09 +07:00
devlikepro 81083e7f82 [core] GOWS - add "merge" flag 2026-03-05 12:41:08 +07:00
devlikepro f8e52959a4 [core] NOWEB - add "merge" flag to turn off merging @lid and @c.us messages and chats 2026-03-05 12:41:08 +07:00
devlikepro 8eb542f49e [core] AGENTS.md ternaries 2026-03-05 12:41:07 +07:00
devlikepro 45ae263ab9 [core] NOWEB - fix ordering - timestamp first 2026-03-05 12:41:06 +07:00
devlikepro f066f982bf [core] Use npm for @devlikeapro/whatsapp-rust-bridge 2026-03-05 12:41:06 +07:00
devlikepro 0888640741 [core] @devlikeapro/whatsapp-rust-bridge 2026-03-05 12:41:05 +07:00
devlikepro bde3722f5c [core] Dashboard - Chat UI improvements 2026-03-05 12:41:05 +07:00
devlikepro 0cd3df117a [core] ChatWoot - sync only 1 chat (@c.us) if both @lid and @c.us provided in messages sync 2026-03-05 12:41:04 +07:00
devlikepro 4c9525cc9f [core] NOWEB - populate lid-c.us mapping with alt properly - fix #1712 2026-03-05 12:41:04 +07:00
devlikepro c14fe1e67d [core] GOWS - up engine
Fix "Link preview fails with 406 due to generic User-Agent(go-http-client/2.0)"" - fix #1914

Merge @lid and @c.us in /messages, /overview, /chats - fix #1684, fix #1817, fix #1910
2026-03-05 12:41:03 +07:00
devlikepro d1e3b209e7 [core] NOWEB - merge @lid and @c.us messages in /overview and /messages
fix #1444, fix #1419, fix #1683, fix #1432
2026-03-05 12:41:03 +07:00
devlikepro f37bf62a30 [core] NOWEB - fix ack status from history 2026-03-05 12:41:02 +07:00
devlikepro 538bfe0e24 [core] NOWEB - try finding message by id in update method fallback 2026-03-05 12:41:02 +07:00
devlikepro 93cd5307d9 [core] NOWEB - up engine 2026-03-05 12:41:02 +07:00
devlikepro b697f37b1e [core] NOWEB - up rust bridge
fix #1919
2026-03-05 12:41:01 +07:00
devlikepro 96a9cd8cbb [core] WEBJS - ignore "Protocol error (Network.getResponseBody): No data found for resource with given identifier"
fix #1918
2026-03-05 12:41:01 +07:00
devlikepro a21b9a11a9 [core] Up WEBJS
Can not get QR Code - fix #1923 fix #1918
2026-03-05 12:41:01 +07:00
Berg PinheiroandCursor 213b51e598 [core] Fix apps GET/PUT/DELETE 403 by adding CheckPolicies guard (#1927)
Add @CheckPolicies(CanServer(Action.Read)) to GET, PUT, and DELETE /:id endpoints so the PoliciesGuard has an explicit policy instead of throwing when no handler is present. Session name is not available at guard time for these routes, so the guard only enforces server-level read; handlers continue to enforce Action.Use on the app's session via req.ability?.can().

Fix #1926

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:06:06 +07:00
devlikepro faf3c77508 [core] CI - no unit tests (rust bridge is too big)
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-02-18 17:11:30 +07:00
devlikepro e6fdfb88ca [core] 2026.2.2 2026-02-18 17:11:20 +07:00
devlikepro 99746df626 [core] Dashboard
fix for crypto.randomUUID in apps
2026-02-18 17:11:16 +07:00
devlikepro 43aeeb55e8 [core] GOWS - Update engine
Fix "Edit message with image not working" - fix #1852
2026-02-18 17:11:16 +07:00
devlikepro 4f89f49d44 [core] README.md - build rust whatsapp bridge 2026-02-18 17:11:15 +07:00
devlikepro be583e7e40 [core] Dockerfile - build rust whatsapp bridge 2026-02-18 17:11:14 +07:00
devlikepro e8c8f89733 [core] AGENTS.md 2026-02-18 17:11:13 +07:00
devlikepro 13b6d3ddf7 [core] NOWEB - edit text messages in channels (doesn't work for image/video yet)
fix #1352
2026-02-18 17:11:13 +07:00
devlikepro 5e97fb74b1 [core] NOWEB - edit caption for media message
fix #1352
2026-02-18 17:11:12 +07:00
devlikepro cc47321cad [core] GOWS - add seconds to video messages
fix #1893
2026-02-18 17:11:11 +07:00
devlikepro 607dff8f7a [core] NOWEB - add seconds to video messages
fix #1893
2026-02-18 17:11:11 +07:00
devlikepro 0a74f8dbcd [core] picsum photos 2026-02-18 17:11:10 +07:00
devlikepro 796befcf87 [core] NOWEB - use rust-bridge fork 2026-02-18 17:11:10 +07:00
devlikepro 2c5610dd01 [core] NOWEB - update engine 2026-02-18 17:11:10 +07:00
devlikepro 43d110fa31 [core] WEBJS - update engine
fix "Lid is missing in chat table" - fix #1824

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

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

Closes #1833
Mentions chatwoot/chatwoot#13348

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

Co-authored-by: Dani Lipari <dani.lipari@dontouch.ch>
2026-02-16 14:34:50 +07:00
devlikepro 52222a6b98 [core] 2026.2.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-02-02 14:10:58 +07:00
devlikepro 99a0a19d8c [core] NOWEB - fix send image using proxy
fix #1859
2026-02-02 14:10:58 +07:00
devlikepro 48ed33bc94 [core] Up WEBJS
Fix GET groups internal error
 - fix #1860

Fix Send message to channel - fix #1863

Fix contact update - fix #1839

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

fix #1828
2026-01-26 10:08:45 +07:00
devlikepro 3b28826679 [core] dev 2026-01-26 10:08:45 +07:00
devlikepro 81646bbaa2 [core] Up Dashboard 2026-01-26 10:08:44 +07:00
devlikepro d8d84107fb [core] yarn:test in ci 2026-01-26 10:08:43 +07:00
devlikepro 58d917d374 [core] Api Keys - admin, per sessions
fix #387
fix #937
fix #1642
fix #1772
2026-01-26 10:08:41 +07:00
devlikepro f8f0063975 [core] Openapi - use pairing tag for qr/code/screenshot, move apps below 2026-01-26 10:08:40 +07:00
devlikepro 73faf330e3 [core] AGENTS.md 2026-01-26 10:08:40 +07:00
devlikepro 5a1ebb186d [core] import crypto 2026-01-26 10:08:40 +07:00
devlikepro 54d6d715b0 [core] Add "chrome" to dev build 2026-01-26 10:08:40 +07:00
devlikepro 5d9d6e29e2 [core] Up WEBJS - fix SendSeen not working
fix #1818
2026-01-26 10:08:40 +07:00
devlikepro cf3385f624 Merge remote-tracking branch 'core/core' into core
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-01-15 12:19:57 +07:00
devlikepro 0346c5e7b9 [core] 2026.1.3 2026-01-15 12:15:15 +07:00
devlikepro 443652b0a7 [core] Apps - ignore no type in headers 2026-01-15 12:15:14 +07:00
devlikepro 1e73c3dc6a [core] WEBJS - disable-metrics 2026-01-15 12:15:13 +07:00
devlikepro afa9d4208f [core] ChatWoot - fix delete message on WEBJS
fix #1769
2026-01-15 12:15:13 +07:00
devlikepro 64eda25b5d [core] WEBJS - get chats use AUTHENTICATED
fix #1782
2026-01-15 12:15:12 +07:00
devlikepro cfc6c931a3 [core] Up WEBJS
fix #1810
2026-01-15 12:15:11 +07:00
devlikepro 35bc56c948 [core] Up GOWS - 1.0.30 2026-01-15 12:15:11 +07:00
devlikepro 371bde4c3e [core] Up NOWEB - proto 1031716404 2026-01-15 12:15:09 +07:00
devlikepro 9497e2900e [core] .env 2026-01-15 12:15:08 +07:00
devlikepro 51bbc9998a [core] GitHub Actions - retry 2026-01-15 12:15:07 +07:00
devlikepro 057f7bce56 [core] ChatWoot - fix sending attachments with no caption in a group chat
fix #1808
2026-01-15 12:15:06 +07:00
devlikepro 52412fb4cc [core] ChatWoot - remove @lid when mention it
fix #1803
2026-01-15 12:15:06 +07:00
devlikepro a57f3443de [core] Resolve media absolute path 2026-01-15 12:15:06 +07:00
devlikepro 9b2537e7c7 [core] Add retries to docker build 2026-01-15 12:15:05 +07:00
Cameron Jackson a378ad4c3d [core] Sidestep existing .env from being overwritten (#1770)
Following docker instructions blindly currently wipes .env if it's already configure
2026-01-12 16:54:56 +07:00
devlikepro 8f867e0b5d [core] 2026.1.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-01-12 16:52:10 +07:00
devlikepro d4f6ec18fd [core] GOWS - receive and send video note
fix #1798
2026-01-12 16:52:10 +07:00
devlikepro ac05dd83e9 [core] NOWEB - handle video note media 2026-01-12 16:50:35 +07:00
devlikepro 17c22f0cd4 [core] Up WEBSJ - fix send media to channels
fix #1775
2026-01-12 16:50:34 +07:00
devlikepro 971979c13f [core] Do not send double session.status
fix #1784
2026-01-12 16:50:34 +07:00
devlikepro 845e203637 [core] GOWS - stop sessions first, then grpc
fix #1784
2026-01-12 16:50:34 +07:00
devlikepro 7a2e1cd731 [core] GOWS - start session in the background 2026-01-12 16:50:34 +07:00
devlikepro 84ba011a4c [core] Up GOWS - add profiling 2026-01-12 16:50:33 +07:00
devlikepro 50399f1192 [core] Add associatedChildMessage to edited message
fix #1773
2026-01-12 16:50:33 +07:00
devlikepro a2517b248d [core] ChatWoot - handle not found app
fix #1290
fix #1790
2026-01-12 16:50:33 +07:00
devlikepro f02d95331a [core] ChatWoot - support @lid mention directly
fix #1767
closes devlikeapro/waha-plus#36
2026-01-12 16:50:32 +07:00
Berg Pinheiro cb1faff88c [core] ChatWoot - support @mention and @all in group messages
fix #1767
closes devlikeapro/waha-plus#36
2026-01-12 16:50:32 +07:00
devlikepro 3ff1c06eea [core] ChatWoot - add session info api in client 2026-01-12 16:50:32 +07:00
devlikepro c91d2c59eb [core] ChatWoot - add group participants api in client 2026-01-12 16:50:32 +07:00
devlikepro 4cf5a74d23 [core] Groups - allow @all be with other mentions - we resolve group participants anyway 2026-01-12 16:50:32 +07:00
devlikepro 13de42dbe0 [core] Up GOWS
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
fix #1749
2026-01-02 19:47:30 +07:00
devlikepro f99b86ed18 [core] WEBJS - can not read properties of null - client
fix #1763
closes #1764
2026-01-02 19:27:50 +07:00
devlikepro 07f6c78372 [core] 2026.1.1 2026-01-02 19:27:50 +07:00
149 changed files with 9288 additions and 471 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)"
]
}
}
+3
View File
@@ -21,7 +21,10 @@ WHATSAPP_SWAGGER_ENABLED=True
# ==================
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=WEBJS
# So you don't have to create sessions/apps when switching between engines
WAHA_NAMESPACE=all
# "Firefox (YourApp)" in Linked Devices
# WAHA_CLIENT_DEVICE_NAME=YourApp
# Base URL for the API (used for webhooks, file URLs, etc.)
+54 -9
View File
@@ -12,10 +12,11 @@ jobs:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
${{ matrix.tag }}
strategy:
fail-fast: false
matrix:
include:
# Chromium - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'latest'
platform: 'amd64'
browser: 'chromium'
@@ -23,7 +24,7 @@ jobs:
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'arm'
platform: 'linux/arm64'
browser: 'chromium'
@@ -31,7 +32,7 @@ jobs:
goss: 'goss-linux-arm'
# Chrome - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'chrome'
platform: 'amd64'
browser: 'chrome'
@@ -39,14 +40,14 @@ jobs:
goss: 'goss-linux-amd64'
# Chrome - ARM (Chrome is not available for ARM)
#- runner: "buildjet-4vcpu-ubuntu-2204-arm"
#- runner: "ubuntu-24.04-arm"
# tag: "chrome-arm"
# platform: "linux/arm64"
# browser: "chrome"
# goss: 'goss-linux-arm'
# NOWEB - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'noweb'
platform: 'amd64'
browser: 'none'
@@ -54,7 +55,7 @@ jobs:
goss: 'goss-linux-amd64'
# NOWEB - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'noweb-arm'
platform: 'linux/arm64'
browser: 'none'
@@ -62,7 +63,7 @@ jobs:
goss: 'goss-linux-arm'
# GOWS - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'gows'
platform: 'amd64'
browser: 'none'
@@ -70,7 +71,7 @@ jobs:
goss: 'goss-linux-amd64'
# No browser - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'gows-arm'
platform: 'linux/arm64'
browser: 'none'
@@ -100,7 +101,51 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
- name: Build (attempt 1)
id: build1
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if: steps.build1.outcome == 'failure'
run: |
sleep 10
- name: Build (attempt 2)
id: build2
if: steps.build1.outcome == 'failure'
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if: steps.build2.outcome == 'failure'
run: |
sleep 10
- name: Build (attempt 3)
if: steps.build2.outcome == 'failure'
uses: docker/build-push-action@v4
with:
context: .
+95 -3
View File
@@ -6,8 +6,37 @@ on:
workflow_dispatch:
jobs:
# unit-tests:
# if: github.repository == 'devlikeapro/waha-plus'
# runs-on: ubuntu-22.04
# name: Unit tests
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
#
# - name: Set up Node
# uses: actions/setup-node@v4
# with:
# node-version: 22
#
# - name: Enable Corepack
# run: |
# corepack enable
# corepack prepare yarn@4.9.2 --activate
#
# - name: Install dependencies
# run: |
# unset GIT_CONFIG_PARAMETERS
# yarn install --immutable
#
# - name: Run unit tests
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
@@ -15,13 +44,24 @@ jobs:
strategy:
matrix:
include:
- runner: 'buildjet-4vcpu-ubuntu-2204'
# Chromium - x86
- runner: 'ubuntu-latest'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
# Chrome - x86
- runner: 'ubuntu-latest'
tag: 'dev-chrome'
platform: 'amd64'
browser: 'chrome'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'ubuntu-24.04-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
browser: 'chromium'
@@ -71,8 +111,60 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
- name: Build (attempt 1)
id: build1
if: ${{ steps.recent.outputs.has_recent == 'true' }}
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
== 'failure' }}
run: |
sleep 10
- name: Build (attempt 2)
id: build2
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
== 'failure' }}
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
== 'failure' }}
run: |
sleep 10
- name: Build (attempt 3)
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
== 'failure' }}
uses: docker/build-push-action@v4
with:
context: .
+82 -5
View File
@@ -3,6 +3,11 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
@@ -70,8 +75,14 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Prefer named function declarations over `const` arrow functions when possible.
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- Do not write verbose ternaries like
`condition !== undefined ? condition : default`; use idiomatic helpers such as
`??` (nullish coalescing) or existing boolean parsers so flags stay readable.
- Do not place `await` or other async calls inside ternary expressions (`?:`);
use explicit `if/else` blocks instead.
- For configs, prefer runtime configurability over constants. Environment keys
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
`session.config.*` for per-session overrides. If both env and config are
@@ -100,10 +111,10 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
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`
- Do **not** run `pre-commit run --all-files` unless the user explicitly
asks.
- `yarn build`
- `yarn test` Use Node 22. Address lint or formatting issues before
proceeding.
- `yarn test --watchman=false`
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
@@ -120,14 +131,80 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## Yes No
Always define key for objects (and in return too)
```js
// NO
const variable = 123;
const b = { variable };
// YES
const variable = 123;
const b = { variable: variable };
```
Use the three-line comment style for section headers inside files:
```ts
// NO
// ─── Section name ─────────────────────────────────────────────────────────────
// YES
//
// Section name
//
```
## Related Sources Code
You can find related source code in the following paths:
You can find and read related source code in the following paths:
- WEBJS: `../whatsapp-web.js`
- NOWEB: `../WhiskeySockets-Baileys`
- GOWS: `../gows` + `../whatsmeow`
- whatsapp-rust-bridge - `../whatsapp-rust-bridge`
- GOWS: `../gows`
- whatsmeow - `../whatsmeow`
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
- ChatWoot: `../chatwoot`
Following this playbook keeps contributions aligned with WAHA’s structure,
automation hooks, and release process.
## How to run API
You can run the project outside of sandbox using the below command, then run
queries against `default` session (if not asked to do something different) using
`curl` and `X-Api-Key: 666` header.
```bash
export DEBUG=1
export WAHA_API_KEY=666
export WAHA_DASHBOARD_PASSWORD=666
export WAHA_DASHBOARD_USERNAME=admin
export WWHATSAPP_SWAGGER_USERNAME=admin
export WHATSAPP_SWAGGER_PASSWORD=666
export WHATSAPP_DEFAULT_ENGINE={WEBJS|WPP|NOWEB|GOWS}
export WAHA_DEBUG_MODE=True
export WAHA_HTTP_STRICT_MODE=1
export WAHA_MEDIA_STORAGE=LOCAL
export WHATSAPP_FILES_FOLDER=./.media
npm run start
```
- Ask user before running the server.
- Before executing some queries make sure the session is in `WORKING` status.
- If it's `FAILED` or `SCAN_QR_CODE` ask user to scan QR code or fix failed
session.
## Code
### @Activity Decorator and Presence Tracking
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
View File
@@ -0,0 +1 @@
- Read `AGENTS.md` for more instructions
+3 -1
View File
@@ -8,7 +8,9 @@ FROM node:${NODE_IMAGE_TAG} AS build
ENV PUPPETEER_SKIP_DOWNLOAD=True
# git + build toolchain for git deps
RUN apt-get update && apt-get install -y git python3 build-essential && rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y --no-install-recommends git python3 build-essential curl ca-certificates unzip && \
rm -rf /var/lib/apt/lists/*
# npm packages
WORKDIR /git
+9 -4
View File
@@ -32,16 +32,22 @@ push:
docker push devlikeapro/waha
for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
export WHATSAPP_SWAGGER_CONFIG_ADVANCED=true && export WHATSAPP_SWAGGER_PASSWORD=666 && yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-02-11
up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18
up-wpp:
yarn up @wppconnect-team/wppconnect
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
@@ -57,4 +63,3 @@ gows:
copy-dashboard:
cd ../waha-hub/ui && \
make copy-waha
+12 -1
View File
@@ -147,7 +147,18 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
## Start the project
1. Clone the repository
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
3. Run the following commands:
3. Install the **whatsapp-rust-bridge prerequisites**:
```bash
# Bun runtime used by whatsapp-rust-bridge prepare scripts
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.9
# Rust nightly toolchain used for whatsapp-rust-bridge
curl -fsSL https://sh.rustup.rs | bash -s -- -y --default-toolchain nightly-2026-01-30
rustup target add wasm32-unknown-unknown
cargo install wasm-pack --vers 0.14.0 --locked
```
4. Run the following commands:
```bash
# Install dependencies
yarn install
@@ -75,7 +75,6 @@ services:
- POSTGRES_USER=postgres
# Please provide your own password.
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
redis:
image: redis:alpine
+57 -23
View File
@@ -16,23 +16,27 @@
"start:prod-exit": "node dist/main",
"lint": "oxlint --deny-warnings",
"lint-fix": "oxlint --fix --deny-warnings",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test": "yarn test:unit",
"test:watch": "jest --selectProjects unit --watch",
"test:cov": "jest --selectProjects unit --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
"test:unit": "jest --selectProjects unit",
"test:e2e": "jest --selectProjects e2e",
"test:e8e": "yarn test:e2e",
"test:all": "yarn test:unit && yarn test:e8e",
"gows:proto:fetch": "node scripts/gows-proto.js fetch",
"gows:proto:build": "node scripts/gows-proto.js build",
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-02-11",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
"@bull-board/api": "^6.9.1",
"@bull-board/express": "^6.9.1",
"@bull-board/nestjs": "^6.9.1",
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@nestjs/axios": "^3.0.2",
@@ -53,6 +57,7 @@
"@types/passport": "^1.0.17",
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^1.40.1",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
@@ -107,7 +112,8 @@
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17",
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
},
@@ -120,7 +126,8 @@
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"whatwg-url": "13.0.0",
"axios": "^1.9.0"
"axios": "^1.9.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
},
"devDependencies": {
"@grpc/grpc-js": "^1.14.1",
@@ -134,6 +141,7 @@
"@types/node-fetch": "^2.6.13",
"@types/semver": "^7.7.0",
"@types/supertest": "^2.0.8",
"@types/user-agents": "^1",
"dotenv": "^17.2.3",
"grpc-tools": "^1.13.0",
"jest": "^29.7.0",
@@ -148,21 +156,47 @@
"typescript": "5.9.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"projects": [
{
"displayName": "unit",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"testPathIgnorePatterns": [
"/__tests__/e2e/"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
{
"displayName": "e2e",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": "__tests__/e2e/.*\\.test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
]
},
"packageManager": "yarn@4.9.2"
}
+17 -3
View File
@@ -76,17 +76,31 @@ const updatedContent = exampleContent
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
const generatedEnvValues = [
`WAHA_API_KEY=${apiKey}`,
`WAHA_API_KEY_PLAIN=${apiKey}`,
'WAHA_DASHBOARD_USERNAME=admin',
`WAHA_DASHBOARD_PASSWORD=${adminPassword}`,
'WHATSAPP_SWAGGER_USERNAME=admin',
`WHATSAPP_SWAGGER_PASSWORD=${adminPassword}`,
];
const lines = [
'Credentials generated.',
'',
'Dashboard and Swagger:',
'Generated env values:',
...generatedEnvValues.map((line) => ` - ${line}`),
'',
'Use these credentials to login in Dashboard or Swagger:',
' - Username: admin',
` - Password: ${adminPassword}`,
'',
'API key: ',
'Use this API key in the x-api-key header:',
` - ${apiKey}`,
'',
'Use it in the Dashboard connection flow: https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
'Read more:',
' - https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
' - https://waha.devlike.pro/docs/how-to/security/',
];
console.log(lines.join('\n'));
+391
View File
@@ -0,0 +1,391 @@
import axios from 'axios';
import WebSocket = require('ws');
const VALID_API_KEY = '666';
const VALID_USERNAME = 'admin';
const VALID_PASSWORD = '666';
const VALID_SESSION_API_KEY = 'key_0fQfI3n3rFVsczBbBfknLXKUreY2my6J';
const BASE_URL = (process.env.WAHA_BASE_URL ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
const WS_BASE_URL = BASE_URL.replace(/^http/, 'ws');
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_UNPROCESSABLE = 422;
const WS_POLICY_VIOLATION = 1008;
const WS_OK_CODES = [1000, 1005];
describe('admin - GET /api/sessions/{name}', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('admin - POST /api/sessions', () => {
test('admin key can create a session without name and remove it', async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{},
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(createResponse.status).toBe(HTTP_CREATED);
expect(createResponse.data?.name).toBeTruthy();
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/${createResponse.data.name}`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(deleteResponse.status).toBe(HTTP_OK);
});
});
describe('GET /api/sessions?all=true', () => {
beforeAll(async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'another' },
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_CREATED, HTTP_UNPROCESSABLE].includes(createResponse.status)) {
throw new Error(
`Unexpected status for creating "another" session: ${createResponse.status}`,
);
}
});
afterAll(async () => {
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/another`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_OK, HTTP_NOT_FOUND].includes(deleteResponse.status)) {
throw new Error(
`Unexpected status for deleting "another" session: ${deleteResponse.status}`,
);
}
});
test('admin key returns two sessions', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(2);
expect(response.data.map((session) => session.name).sort()).toEqual([
'another',
'default',
]);
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('session key returns one session', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(1);
expect(response.data[0]?.name).toBe('default');
});
});
describe('GET /api/server/version', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
});
describe('GET /api/server/environment', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
describe('admin - GET /health', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('/ws', () => {
const buildWsUrl = (apiKey?: string) => {
const wsUrl = new URL('/ws', WS_BASE_URL);
wsUrl.searchParams.set('session', '*');
wsUrl.searchParams.set('events', '*');
if (apiKey) {
wsUrl.searchParams.set('x-api-key', apiKey);
}
return wsUrl.toString();
};
const waitForOpen = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket open timeout'));
}, timeoutMs);
socket.once('open', () => {
clearTimeout(timeout);
resolve();
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
const waitForClose = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<{ code: number; reason: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket close timeout'));
}, timeoutMs);
socket.once('close', (code, reason) => {
clearTimeout(timeout);
resolve({ code, reason: reason.toString() });
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
test('no api key is unauthorized', async () => {
const socket = new WebSocket(buildWsUrl());
const { code } = await waitForClose(socket);
expect(code).toBe(WS_POLICY_VIOLATION);
});
test('admin api key is ok', async () => {
const socket = new WebSocket(buildWsUrl(VALID_API_KEY));
await waitForOpen(socket);
socket.close();
const { code } = await waitForClose(socket);
expect(WS_OK_CODES).toContain(code);
});
});
describe('GET /api/files/test.txt', () => {
// create "test.txt" in current dir/.media/test.txt
let file = null;
beforeAll(() => {
const fs = require('fs');
const path = require('path');
const mediaDir = path.resolve('./.media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir);
}
const filePath = path.join(mediaDir, 'test.txt');
fs.writeFileSync(filePath, 'This is a test file.');
file = filePath;
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /dashboard', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('session key - GET /api/sessions/{name}', () => {
test('default key - default session - is authorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('default key - create a new sessions - forbidden', async () => {
const response = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'newone' },
{
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
},
);
expect(response.status).toBe(HTTP_FORBIDDEN);
});
test('another key - default session - unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': 'key_another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('default key - another session - forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/another`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
@ApiSecurity('api_key')
@Controller('api/keys')
@ApiTags('🔑 Api Keys')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ApiKeysController {
constructor(private manager: SessionManager) {}
@Post('/')
@ApiOperation({ summary: 'Create a new API key' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${body.session}" does not exist`,
);
}
}
let apikey: ApiKey | null = null;
// Try 5 times to check there's no conflict on id and key
for (let i = 0; i < 5; i++) {
apikey = {
id: generatePrefixedId('key_id'),
key: `key_${generateSecret(32)}`,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
rules: null,
};
const idExists = await this.manager.apiKeyRepository.getById(apikey.id);
if (idExists) {
continue;
}
const keyExists = await this.manager.apiKeyRepository.getByKey(
apikey.key,
);
if (keyExists) {
continue;
}
break;
}
if (!apikey) {
throw new UnprocessableEntityException(
`Failed to generate API key, try again`,
);
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Get('/')
@ApiOperation({ summary: 'Get all API keys' })
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.manager.apiKeyRepository.list();
return keys.map((key) => ApiKeyToDTO(key));
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@Body() body: ApiKeyRequest,
): Promise<ApiKeyDTO> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
};
CheckInvariant(apikey);
if (apikey.session) {
const exists = await this.manager.exists(apikey.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${apikey.session}" does not exist`,
);
}
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an API key' })
async delete(@Param('id') id: string): Promise<{ result: true }> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
await this.manager.apiKeyRepository.deleteById(id);
return { result: true };
}
}
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
key: apikey.key,
isActive: apikey.isActive,
isAdmin: apikey.isAdmin,
session: apikey.session,
};
}
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Post,
Query,
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
@@ -24,10 +25,17 @@ import {
RequestCodeRequest,
} from '../structures/auth.dto';
import { Base64File } from '../structures/files.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/auth')
@ApiTags('🔑 Auth')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class AuthController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -14,10 +15,17 @@ import {
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class CallsController {
constructor(private manager: SessionManager) {}
+13 -4
View File
@@ -8,6 +8,7 @@ import {
Param,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -39,10 +40,17 @@ import {
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ChannelsController {
constructor(
private manager: SessionManager,
@@ -123,14 +131,15 @@ export class ChannelsController {
})
async previewChannelMessages(
@WorkingSessionParam session: WhatsappSession,
@Param('id') code: string,
@Param('id') codeOrId: string,
@Query() query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
if (isJidNewsletter(code)) {
const channel = await session.channelsGetChannel(code);
let code = null;
if (isJidNewsletter(codeOrId)) {
const channel = await session.channelsGetChannel(codeOrId);
code = parseChannelInviteLink(channel.invite);
}
const inviteCode = parseChannelInviteLink(code);
const inviteCode = parseChannelInviteLink(code || codeOrId);
return session.previewChannelMessages(inviteCode, query);
}
+12 -4
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -24,15 +25,15 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ChatPictureQuery,
ChatPictureResponse,
ChatsPaginationParams,
ChatSummary,
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
MessageSortField,
OverviewBodyRequest,
OverviewFilter,
OverviewPaginationParams,
PinMessageRequest,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
@@ -40,11 +41,18 @@ import {
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/chats')
@ApiTags('💬 Chats')
@UsePipes(new ValidationPipe({ transform: true }))
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class ChatsController {
constructor(private manager: SessionManager) {}
@@ -53,7 +61,7 @@ class ChatsController {
@ApiOperation({ summary: 'Get chats' })
getChats(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: ChatsPaginationParams,
@Query() pagination: GetChatsParams,
) {
return session.getChats(pagination);
}
@@ -67,7 +75,7 @@ class ChatsController {
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
getChatsOverview(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: OverviewPaginationParams,
@Query() pagination: GetChatsOverviewParams,
@Query() filter: OverviewFilter,
): Promise<ChatSummary[]> {
return session.getChatsOverview(pagination, filter);
+31
View File
@@ -5,6 +5,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -48,15 +49,22 @@ import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('📤 Chatting')
@UseGuards(PoliciesGuard)
export class ChattingController {
constructor(private manager: SessionManager) {}
@Post('/sendText')
@ApiOperation({ summary: 'Send a text message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -72,6 +80,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -87,6 +96,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -102,6 +112,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVoice(@Body() request: MessageVoiceRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendVoice(request);
@@ -113,6 +124,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -128,6 +140,7 @@ export class ChattingController {
description:
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendLinkCustomPreview(
@Body() request: MessageLinkCustomPreviewRequest,
@@ -147,6 +160,7 @@ export class ChattingController {
description: 'Send Buttons',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendButtons(@Body() request: SendButtonsRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -158,6 +172,7 @@ export class ChattingController {
summary: 'Send a list message (interactive)',
description: 'Send a List message with sections and rows',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendList(@Body() request: SendListRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -165,6 +180,7 @@ export class ChattingController {
}
@Post('/forwardMessage')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async forwardMessage(
@Body() request: MessageForwardRequest,
): Promise<WAMessage> {
@@ -173,6 +189,7 @@ export class ChattingController {
}
@Post('/sendSeen')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
@@ -188,6 +205,7 @@ export class ChattingController {
}
@Post('/startTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async startTyping(@Body() chat: ChatRequest) {
// It's infinitive action
const whatsapp = await this.manager.getWorkingSession(chat.session);
@@ -196,6 +214,7 @@ export class ChattingController {
}
@Post('/stopTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async stopTyping(@Body() chat: ChatRequest) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
await whatsapp.stopTyping(chat);
@@ -204,6 +223,7 @@ export class ChattingController {
@Put('/reaction')
@ApiOperation({ summary: 'React to a message with an emoji' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setReaction(@Body() request: MessageReactionRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.setReaction(request);
@@ -211,6 +231,7 @@ export class ChattingController {
@Put('/star')
@ApiOperation({ summary: 'Star or unstar a message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setStar(@Body() request: MessageStarRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
await whatsapp.setStar(request);
@@ -222,6 +243,7 @@ export class ChattingController {
summary: 'Send a poll with options',
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
@@ -232,6 +254,7 @@ export class ChattingController {
summary: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPollVote(@Body() request: MessagePollVoteRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -239,12 +262,14 @@ export class ChattingController {
}
@Post('/sendLocation')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLocation(@Body() request: MessageLocationRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLocation(request);
}
@Post('/sendContactVcard')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendContactVCard(request);
@@ -254,6 +279,7 @@ export class ChattingController {
@ApiOperation({
summary: 'Reply on a button message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendButtonsReply(@Body() request: MessageButtonReply) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -262,6 +288,7 @@ export class ChattingController {
@Get('/sendText')
@ApiOperation({ summary: 'Send a text message', deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async sendTextGet(@Query() query: MessageTextQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
const msg = new MessageTextRequest();
@@ -276,6 +303,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Query() query: GetMessageQuery,
@@ -292,6 +320,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async DEPRECATED_checkNumberStatus(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -305,6 +334,7 @@ export class ChattingController {
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async reply(@Body() request: MessageReplyRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.reply(request);
@@ -312,6 +342,7 @@ export class ChattingController {
@Post('/sendLinkPreview')
@ApiOperation({ deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLinkPreview(request);
+14
View File
@@ -4,6 +4,7 @@ import {
Get,
Post,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -21,15 +22,22 @@ import {
ContactRequest,
ContactsPaginationParams,
} from '../structures/contacts.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
export class ContactsController {
constructor(private manager: SessionManager) {}
@Get('/all')
@ApiOperation({ summary: 'Get all contacts' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@Query() query: SessionQuery,
@@ -45,6 +53,7 @@ export class ContactsController {
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async get(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContact(query);
@@ -52,6 +61,7 @@ export class ContactsController {
@Get('/check-exists')
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async checkExists(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -65,6 +75,7 @@ export class ContactsController {
description:
'Returns null if you do not have permission to read their status.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async getAbout(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContactAbout(query);
@@ -76,6 +87,7 @@ export class ContactsController {
description:
'If privacy settings do not allow to get the picture, the method will return null.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
@@ -88,6 +100,7 @@ export class ContactsController {
@Post('/block')
@ApiOperation({ summary: 'Block contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async block(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.blockContact(request);
@@ -95,6 +108,7 @@ export class ContactsController {
@Post('/unblock')
@ApiOperation({ summary: 'Unblock contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async unblock(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.unblockContact(request);
+73
View File
@@ -0,0 +1,73 @@
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)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
@Get('/:id')
@SessionApiParam
@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
@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 };
}
}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Param,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -19,10 +20,17 @@ import {
EventMessageRequest,
} from '../structures/events.dto';
import { WAMessage } from '../structures/responses.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/events')
@ApiTags('📅 Events')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class EventsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -9,6 +9,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,10 +41,17 @@ import {
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/groups')
@ApiTags('👥 Groups')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class GroupsController {
constructor(private manager: SessionManager) {}
+8 -1
View File
@@ -1,12 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HealthCheck } from '@nestjs/terminus';
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('health')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class HealthController {
constructor(private wahaHealth: WAHAHealthCheckService) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -26,10 +27,17 @@ import {
import * as lodash from 'lodash';
import { SessionManager } from '../core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/labels')
@ApiTags('🏷️ Labels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LabelsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -4,6 +4,7 @@ import {
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isLidUser } from '@waha/core/utils/jids';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LidsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Post,
UnprocessableEntityException,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -21,10 +22,17 @@ import {
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class MediaController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
@@ -20,10 +21,17 @@ import {
WAHAChatPresences,
WAHASessionPresence,
} from '../structures/presence.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/presence')
@ApiTags('✅ Presence')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class PresenceController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,17 @@ import {
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ProfileController {
constructor(private manager: SessionManager) {}
+14 -2
View File
@@ -4,23 +4,35 @@ import {
Query,
Res,
StreamableFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import { Response } from 'express';
import { SessionManager } from '../core/abc/manager.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { SessionQuery } from '../structures/base.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('🖼️ Screenshot')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
export class ScreenshotController {
constructor(private manager: SessionManager) {}
@Get('/screenshot')
@ApiOperation({
summary:
'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)',
})
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
async screenshot(
+11
View File
@@ -7,6 +7,7 @@ import {
Logger,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,16 @@ import {
import { sleep } from '@waha/utils/promiseTimeout';
import { VERSION } from '@waha/version';
import * as lodash from 'lodash';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
export class ServerController {
private logger: Logger;
@@ -35,12 +42,14 @@ export class ServerController {
@Get('version')
@ApiOperation({ summary: 'Get the version of the server' })
@CheckPolicies(CanServer(Action.Read))
get(): WAHAEnvironment {
return VERSION;
}
@Get('environment')
@ApiOperation({ summary: 'Get the server environment' })
@CheckPolicies(CanServer(Action.Manage))
environment(
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -67,6 +76,7 @@ export class ServerController {
@Get('status')
@ApiOperation({ summary: 'Get the server status' })
@CheckPolicies(CanServer(Action.Read))
async status(): Promise<ServerStatusResponse> {
const now = Date.now();
const uptime = Math.floor(process.uptime() * 1000);
@@ -87,6 +97,7 @@ export class ServerController {
"If you're using docker, after calling this endpoint Docker will start a new container, " +
'so you can use this endpoint to restart the server',
})
@CheckPolicies(CanServer(Action.Manage))
@UsePipes(new WAHAValidationPipe())
async stop(@Body() request: StopRequest): Promise<StopResponse> {
const timeout = 1_000;
+8
View File
@@ -9,6 +9,7 @@ import {
NotFoundException,
Query,
StreamableFile,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -25,10 +26,17 @@ import {
CpuProfileQuery,
} from '@waha/structures/server.debug.dto';
import { createReadStream } from 'fs';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server/debug')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ServerDebugController {
private logger: Logger;
private readonly enabled: boolean;
+39 -5
View File
@@ -9,10 +9,18 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOAuth2,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
@@ -34,18 +42,24 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
import { FilterSessions } from '../core/auth/casl.ability';
import { CheckPolicies } from '../core/auth/policies.decorator';
import { PoliciesGuard } from '../core/auth/policies.guard';
import { CanSession, FromBody, FromParam } from '../core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
@UseGuards(PoliciesGuard)
class SessionsController {
constructor(
private manager: SessionManager,
@@ -57,11 +71,19 @@ class SessionsController {
}
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
@ApiOperation({
summary: 'List all sessions',
})
@CheckPolicies(CanSession(Action.List))
@ApiOAuth2(['read:items'])
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
@Req() req,
): Promise<SessionInfo[]> {
const sessions = await this.manager.getSessions(query.all);
let sessions = await this.manager.getSessions(query.all);
if (!req.user.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
@@ -73,6 +95,7 @@ class SessionsController {
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async get(
@Param('session') name: string,
@@ -91,6 +114,7 @@ class SessionsController {
@Get(':session/me')
@SessionApiParam
@ApiOperation({ summary: 'Get information about the authenticated account' })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
return session.getSessionMeInfo();
}
@@ -102,6 +126,7 @@ class SessionsController {
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Create))
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -139,6 +164,7 @@ class SessionsController {
})
@SessionApiParam
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@@ -177,6 +203,7 @@ class SessionsController {
description:
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('session') name: string): Promise<void> {
await this.withLock(name, async () => {
@@ -195,6 +222,7 @@ class SessionsController {
description:
'Start the session with the given name. The session must exist. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async start(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -214,6 +242,7 @@ class SessionsController {
summary: 'Stop the session',
description: 'Stop the session with the given name. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stop(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -229,6 +258,7 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -253,6 +283,7 @@ class SessionsController {
summary: 'Restart the session',
description: 'Restart the session with the given name.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async restart(@Param('session') name: string): Promise<SessionDTO> {
await this.manager.restart(name);
@@ -266,6 +297,7 @@ class SessionsController {
'Create session (if not exists) or update a config (if exists) and start it.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRACATED_start(
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
@@ -292,6 +324,7 @@ class SessionsController {
description: 'Stop session and Logout by default.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
@@ -323,6 +356,7 @@ class SessionsController {
description: 'Stop, Logout and Delete session.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
+8 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
@@ -15,10 +15,17 @@ import {
VideoStatus,
VoiceStatus,
} from '../structures/status.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/status')
@ApiTags('🟢 Status')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class StatusController {
constructor(private manager: SessionManager) {}
+9 -7
View File
@@ -1,17 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import {
ApiExtraModels,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAEnvironment } from '../structures/environment.dto';
import { VERSION } from '../version';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/version')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Read))
export class VersionController {
@Get('')
@ApiOperation({
+42 -19
View File
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
import { EventWildUnmask } from '@waha/utils/events';
import { generatePrefixedId } from '@waha/utils/ids';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { URL } from 'url';
import { Server } from 'ws';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
export enum WebSocketCloseCode {
NORMAL = 1000,
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
constructor(
private manager: SessionManager,
private auth: WebSocketAuth,
private readonly casl: CaslAbilityFactory,
) {
this.logger = new Logger('WebsocketGateway');
this.heartbeat = new WebsocketHeartbeatJob(
@@ -62,11 +65,16 @@ export class WebsocketGatewayCore
);
}
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
async handleConnection(
socket: WebSocket,
request: IncomingMessage,
...args
): Promise<any> {
// wsc - websocket client
socket.id = generatePrefixedId('wsc');
if (!this.auth.validateRequest(request)) {
const user = await this.auth.validateRequest(request);
if (!user) {
// Not authorized - close connection
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
this.logger.debug(
@@ -75,9 +83,20 @@ export class WebsocketGatewayCore
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const params = this.getParams(request);
const session: string = params.session;
let session: string = params.session;
const ability = this.casl.createForUser(user);
if (session == '*' && !ability.can(Action.Use, 'all')) {
// Limit user to listen only the session events
session = user.session;
}
if (!ability.can(Action.Use, new SessionName(session))) {
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
@@ -86,12 +105,17 @@ export class WebsocketGatewayCore
const sub = this.manager
.getSessionEvents(session, events)
.subscribe((data) => {
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
setImmediate(() => {
this.logger.debug(
`Sending data to client, event.id: ${data.id}`,
data,
);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
});
});
});
socket.on('close', () => {
@@ -101,14 +125,13 @@ export class WebsocketGatewayCore
}
private getParams(request: IncomingMessage) {
const query = url.parse(request.url, true).query;
const session = (query.session as string) || '*';
let paramsEvents = (query.events as string[]) || '*';
// if params events string - split by ","
if (typeof paramsEvents === 'string') {
paramsEvents = paramsEvents.split(',');
}
const events = this.eventUnmask.unmask(paramsEvents);
// We need only search params, so localhost is fine here
const query = new URL(request.url, 'http://localhost').searchParams;
const session = query.get('session') || '*';
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
}
+1 -1
View File
@@ -40,7 +40,7 @@ export class AxiosLogging {
// Log debug for 2xx/3xx responses
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
if (type.startsWith('application/json')) {
if (type?.startsWith('application/json')) {
const data = JSON.stringify(response.data);
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
} else {
+51 -4
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Inject,
NotFoundException,
@@ -9,6 +10,8 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -17,6 +20,15 @@ import {
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import {
CanServer,
CanSession,
FromBody,
FromQuery,
} from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { App } from '../dto/app.dto';
@@ -25,6 +37,7 @@ import { ListAppsQuery } from '../dto/query.dto';
@ApiSecurity('api_key')
@Controller('api/apps')
@ApiTags('🧩 Apps')
@UseGuards(PoliciesGuard)
export class AppsController {
constructor(
@Inject(AppsService)
@@ -34,6 +47,7 @@ export class AppsController {
@Get('/')
@ApiOperation({ summary: 'List all apps for a session' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new WAHAValidationPipe())
async list(
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
@@ -43,6 +57,7 @@ export class AppsController {
@Post('/')
@ApiOperation({ summary: 'Create a new app' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
@@ -55,15 +70,39 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string): Promise<App> {
return await this.appsService.get(this.manager, id);
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
if (!app) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
return app;
}
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
async update(
@Param('id') id: string,
@Body() app: App,
@Req() req: any,
): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (existing) {
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
} else {
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
}
if (!app.id) {
app.id = id;
} else if (app.id !== id) {
@@ -82,8 +121,16 @@ export class AppsController {
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
+12 -7
View File
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
export const JobRemoveOptions = jobRemoveOptions();
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
};
function exponentialRetriesJobOptions(): DefaultJobOptions {
return {
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
backoff: {
type: process.env.WAHA_APPS_JOBS_BACKOFF_TYPE || 'exponential',
delay: parseInt(process.env.WAHA_APPS_JOBS_BACKOFF_DELAY) || 1000,
},
};
}
export const ExponentialRetriesJobOptions = exponentialRetriesJobOptions();
export const NoRetriesJobOptions: DefaultJobOptions = {
attempts: 1,
+33 -5
View File
@@ -1,4 +1,10 @@
import { parseBool } from '@waha/helpers';
import { APPS } from '@waha/apps/app_sdk/apps/definition';
// Apps that don't require queue (Redis) - derived from AppDefinition
const IN_MEMORY_APPS = Object.values(APPS)
.filter((app) => !app.queue)
.map((app) => app.name);
function parseCommaSeparatedList(value: string | undefined): string[] {
if (!value) {
@@ -7,8 +13,30 @@ function parseCommaSeparatedList(value: string | undefined): string[] {
return value.split(',').map((item) => item.trim());
}
export const AppEnv = {
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
};
function isMongoDB(): boolean {
return !!process.env.WHATSAPP_SESSIONS_MONGO_URL;
}
function buildAppEnv() {
const enabled = process.env.WAHA_APPS_ENABLED;
const on = process.env.WAHA_APPS_ON;
const off = process.env.WAHA_APPS_OFF;
// Default if not mongodb
const isUserConfigured = enabled !== undefined || on !== undefined;
if (!isUserConfigured && !isMongoDB()) {
return {
enabled: true,
on: IN_MEMORY_APPS,
off: parseCommaSeparatedList(off),
};
}
return {
enabled: parseBool(enabled),
on: parseCommaSeparatedList(on),
off: parseCommaSeparatedList(off),
};
}
export const AppEnv = buildAppEnv();
@@ -21,7 +21,7 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
throw new AppsIsDisabledError();
}
@@ -104,12 +104,12 @@ export class AppsEnabledService implements IAppsService {
return result;
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
if (!app) {
throw new NotFoundException(`App '${appId}' not found`);
return null;
}
delete (app as any).pk;
return app;
+1 -1
View File
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
export interface IAppsService {
list(manager: SessionManager, session: string): Promise<App[]>;
get(manager: SessionManager, appId: string): Promise<App>;
get(manager: SessionManager, appId: string): Promise<App | null>;
create(manager: SessionManager, app: App): Promise<App>;
+19
View File
@@ -146,6 +146,17 @@ export class WAHASelf {
.then((response) => response.data);
}
async getGroupParticipants(
session: string,
groupId: string,
opts?: RequestOptions,
) {
const url = `/api/${session}/groups/${groupId}/participants/v2`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
async getChannel(
session: string,
channelId: string,
@@ -371,6 +382,10 @@ export class WAHASessionAPI {
return this.api.getGroup(this.session, groupId, opts);
}
getGroupParticipants(groupId: string, opts?: RequestOptions): Promise<any> {
return this.api.getGroupParticipants(this.session, groupId, opts);
}
getChannel(channelId: string, opts?: RequestOptions): Promise<Channel> {
return this.api.getChannel(this.session, channelId, opts);
}
@@ -459,4 +474,8 @@ export class WAHASessionAPI {
findLIDByPN(pn: string, opts?: RequestOptions) {
return this.api.findLIDByPN(this.session, pn, opts);
}
getSessionInfo(opts?: RequestOptions) {
return this.api.get(this.session, opts);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
* Parse human string into milliseconds
*/
export function ParseMS(value: string) {
const duration = ms(value);
const duration = ms(value as ms.StringValue);
if (duration == null) throw new Error(`Invalid duration: "${value}"`);
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
return duration;
@@ -19,6 +19,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { CacheForConfig } from '../cache/ConversationCache';
import { IConversationCache } from '../cache/IConversationCache';
import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
@@ -63,6 +64,8 @@ export class ContactConversationService {
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
);
const attributes = await contactInfo.Attributes();
// Keep the current chat id in sync to reply using the latest address.
attributes[AttributeKey.WA_CHAT_ID] = chatId;
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
+3 -3
View File
@@ -27,15 +27,15 @@ export function GetAllChatIDs(contact: any): Array<string> {
}
export function FindChatID(contact: any): string | null {
if (GetChatID(contact)) {
return GetChatID(contact);
}
if (GetJID(contact)) {
return GetJID(contact);
}
if (GetLID(contact)) {
return GetLID(contact);
}
if (GetChatID(contact)) {
return GetChatID(contact);
}
return null;
}
+10 -1
View File
@@ -8,6 +8,7 @@ import { InboxData } from '@waha/apps/chatwoot/consumers/types';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import {
ChatIDNotFoundForContactError,
ChatWootAppNotFoundError,
PhoneNumberNotFoundInWhatsAppError,
} from '@waha/apps/chatwoot/errors';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
@@ -43,7 +44,11 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -86,6 +91,10 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
await this.ReportErrorRecovered(job, body);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForMessage(job, err, body);
throw err;
}
@@ -16,6 +16,7 @@ import {
MessageMappingService,
} from '@waha/apps/chatwoot/storage';
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/messages/to/whatsapp/markdown';
import { parseMentionsFromText } from '@waha/apps/chatwoot/messages/to/whatsapp/mentions';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import {
@@ -36,6 +37,7 @@ import {
LinkPreview,
} from '@waha/apps/chatwoot/dto/config.dto';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { isJidGroup } from '@waha/core/utils/jids';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
@@ -90,7 +92,16 @@ export class MessageHandler {
return;
}
const content = MarkdownToWhatsApp(message.content);
let content: string = message.content;
let mentions: null | string[] = null;
if (isJidGroup(chatId)) {
// Parse any mention in the text and get a new clean text if required
const parsed = parseMentionsFromText(message.content);
content = parsed.text;
mentions = parsed.mentions;
}
content = MarkdownToWhatsApp(content);
const results = [];
let part = 0; // Start from 0 and increment before each send/check
const replyTo = await this.getReplyTo(message).catch((err) => {
@@ -116,7 +127,7 @@ export class MessageHandler {
content: content,
chatwoot: body,
});
const msg = await this.sendTextMessage(chatId, text, replyTo);
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
results.push(msg);
await this.saveMapping(message, msg, part);
this.logger.info(`Text message sent: ${msg.id}`);
@@ -139,7 +150,7 @@ export class MessageHandler {
chatwoot: body,
singleAttachment: attachments.length == 1,
});
const msg = await this.sendFile(chatId, caption, file, replyTo);
const msg = await this.sendFile(chatId, caption, replyTo, mentions, file);
this.logger.info(
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
);
@@ -196,6 +207,7 @@ export class MessageHandler {
chatId: string,
content: string,
replyTo: string,
mentions: string[] | null,
) {
const request: MessageTextRequest = {
session: '',
@@ -206,6 +218,7 @@ export class MessageHandler {
this.config.linkPreview,
),
linkPreviewHighQuality: this.config.linkPreview == LinkPreview.HQ,
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
@@ -219,8 +232,9 @@ export class MessageHandler {
private async sendFile(
chatId: string,
content: string,
file: MessageAttachment,
replyTo: string,
mentions: string[] | null,
file: MessageAttachment,
) {
const url = file.data_url;
let filename = url.split('/').pop();
@@ -245,6 +259,7 @@ export class MessageHandler {
url: url,
mimetype: mimetype,
},
mentions: mentions,
};
return session.sendImage(imageRequest);
case 'video':
@@ -262,6 +277,7 @@ export class MessageHandler {
filename: filename,
},
convert: true,
mentions: mentions,
};
return session.sendVideo(videoRequest);
case 'audio':
@@ -288,6 +304,7 @@ export class MessageHandler {
url: url,
mimetype: mimetype,
},
mentions: mentions,
};
return session.sendFile(fileRequest);
}
+10 -1
View File
@@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
/**
* Base class for ChatWoot scheduled consumers
@@ -35,7 +36,11 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -62,6 +67,10 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
await this.ReportErrorRecovered(job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForJob(job, err);
throw err;
}
+10 -1
View File
@@ -13,6 +13,7 @@ import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { SignalRace } from '@waha/utils/abortable';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
/**
* Base class for ChatWoot background task consumers
@@ -36,7 +37,11 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -68,6 +73,10 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
await this.ReportErrorRecovered(job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
this.ReportErrorForJob(job, err).catch((exc) =>
this.logger.error(`Failed to report error for job ${job.id}: ${exc}`),
);
+11 -2
View File
@@ -12,6 +12,7 @@ import { WhatsAppChatIdKey } from '@waha/apps/chatwoot/consumers/mutex';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
@@ -83,6 +84,10 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
await this.ReportErrorRecovered(errorReportInfo, job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForMessage(errorReportInfo, job, err);
throw err;
}
@@ -95,7 +100,11 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -338,7 +347,7 @@ export abstract class MessageBaseHandler<
chat_id: toCusFormat(key.remoteJid),
message_id: key.id,
from_me: key.fromMe,
participant: null,
participant: isJidGroup(key.remoteJid) ? key.participant : null,
};
await this.mappingService.map(chatwoot, whatsapp, 1);
return;
@@ -32,7 +32,10 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageReaction): string {
if (event.environment?.engine == WAHAEngine.WEBJS) {
if (
event.environment?.engine == WAHAEngine.WEBJS ||
event.environment?.engine == WAHAEngine.WPP
) {
// chat in "to" field for WEBJS for message.reaction
// probably we need to set it to "from"
// but for backward compatability we do this
@@ -45,6 +45,12 @@ export class ChatWootErrorReporter {
// There's retries more left - ignore it for now
return;
}
if (!conversation) {
this.logger.error(
'Chatwoot error report skipped: conversation not ready',
);
return;
}
const attempts = {
current: this.job.attemptsMade + 1,
max: this.job.opts?.attempts || 1,
@@ -87,6 +93,12 @@ export class ChatWootErrorReporter {
// https://github.com/devlikeapro/waha/issues/1395
return null;
if (!conversation) {
this.logger.warn(
'Chatwoot success report skipped: conversation not ready',
);
return;
}
const template = this.l.key(TKey.JOB_REPORT_SUCCEEDED);
const attempts = {
current: this.job.attemptsMade + 1,
+6
View File
@@ -15,3 +15,9 @@ export class UnknownJIDFormat extends Error {
super(`WhatsApp Chat ID is not recognized: ${jid}`);
}
}
export class ChatWootAppNotFoundError extends Error {
constructor(public appId: string) {
super(`Chatwoot app not found or disabled: ${appId}`);
}
}
@@ -15,6 +15,14 @@ describe('MarkdownToWhatsApp', () => {
expect(MarkdownToWhatsApp(input)).toBe(expected);
});
// ChatWoot v4.10.1+ escapes newlines with trailing backslash
it('strips trailing backslash before newlines', () => {
const input = 'Hi \\\ntext message \\\nwith line \\\nbreak';
expect(MarkdownToWhatsApp(input)).toBe(
'Hi \ntext message \nwith line \nbreak',
);
});
// Tests for URL protection
it('preserves URLs with multiple underscores', () => {
const input = 'https://example.com/page__name__test';
@@ -4,6 +4,8 @@ export function MarkdownToWhatsApp(text: string): string {
}
return (
text
// Remove trailing backslash before newlines (ChatWoot v4.10.1+ escaping)
.replace(/\\\n/g, '\n')
// Triple-backtick blocks → WhatsApp monospace blocks (same syntax)
.replace(/```([\s\S]*?)```/g, '```$1```')
// Italic **first**, but only single‐star or single‐underscore
@@ -0,0 +1,130 @@
import { parseMentionsFromText } from './mentions';
describe('parseMentionsFromText', () => {
it('returns null mentions when none exist', () => {
const input = 'Hello there';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello there',
mentions: null,
});
});
it('handles empty content safely', () => {
expect(parseMentionsFromText(null)).toEqual({
text: null,
mentions: null,
});
});
it('parses @all and removes it from the text', () => {
const input = 'Hello @all';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello',
mentions: ['all'],
});
});
it('parses @ALL case-insensitively', () => {
const input = 'Hello @ALL';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello',
mentions: ['all'],
});
});
it('uses a fallback text when only @all is present', () => {
const input = '@all';
expect(parseMentionsFromText(input)).toEqual({
text: ' ',
mentions: ['all'],
});
});
it('parses phone number mentions with a suffix', () => {
const input = 'Ping @558591203123';
expect(parseMentionsFromText(input)).toEqual({
text: 'Ping @558591203123',
mentions: ['558591203123@c.us'],
});
});
it('deduplicates phone number mentions', () => {
const input = 'Hi @1111111 and @1111111';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hi @1111111 and @1111111',
mentions: ['1111111@c.us'],
});
});
it('handles @all alongside phone mentions', () => {
const input = '@all Hi @558591203123';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hi @558591203123',
mentions: ['all', '558591203123@c.us'],
});
});
it('ignores quoted @all text', () => {
const input = "You can send '@all' to chat";
expect(parseMentionsFromText(input)).toEqual({
text: "You can send '@all' to chat",
mentions: null,
});
});
it('ignores email-like mentions', () => {
const input = 'Reach us at email@12341domain.com';
expect(parseMentionsFromText(input)).toEqual({
text: 'Reach us at email@12341domain.com',
mentions: null,
});
});
it('ignores @all when followed by letters', () => {
const input = 'Please ping @allx';
expect(parseMentionsFromText(input)).toEqual({
text: 'Please ping @allx',
mentions: null,
});
});
it('ignores phone mentions followed by letters', () => {
const input = 'Call @1234567abc';
expect(parseMentionsFromText(input)).toEqual({
text: 'Call @1234567abc',
mentions: null,
});
});
it('ignores @all inside email-like text', () => {
const input = 'Contact email@alldomain.com for details';
expect(parseMentionsFromText(input)).toEqual({
text: 'Contact email@alldomain.com for details',
mentions: null,
});
});
it('accepts phone mentions with trailing punctuation', () => {
const input = 'Ping @1231234, then @5555555.';
expect(parseMentionsFromText(input)).toEqual({
text: 'Ping @1231234, then @5555555.',
mentions: ['1231234@c.us', '5555555@c.us'],
});
});
it('parses @lid mentions without c.us suffix', () => {
const input = 'Notify @123123@lid now';
expect(parseMentionsFromText(input)).toEqual({
text: 'Notify @123123 now',
mentions: ['123123@lid'],
});
});
it('replaces @lid suffix with plain ids', () => {
const input = '@123456@lid please ping @654321@lid.';
expect(parseMentionsFromText(input)).toEqual({
text: '@123456 please ping @654321.',
mentions: ['123456@lid', '654321@lid'],
});
});
});
@@ -0,0 +1,91 @@
import { ensureSuffix } from '@waha/core/abc/session.abc';
import { isJidGroup } from '@waha/core/utils/jids';
export interface ParsedMentions {
text: string;
mentions?: string[];
}
/**
* Parse mentions from text message
* Supports:
* - @all - mentions all group participants
* - @558591203123 - mentions specific phone number
*
* @param text - The message text
* @returns Parsed mentions with cleaned text
*/
export function parseMentionsFromText(
text: string | null | undefined,
): ParsedMentions {
if (!text) {
return {
text: text,
mentions: null,
};
}
const mentions: string[] = [];
let content = text;
// Regex to match @all (case-insensitive, word boundary)
// Use separate regex instances to avoid lastIndex issues with global flag
const allRegexTest = /(^|\s)@all\b/gi;
if (allRegexTest.test(text)) {
mentions.push('all');
// Remove @all from text - WAHA endpoint doesn't display it, only mentions all participants
// Create a new regex instance for replace to avoid lastIndex issues
const allRegexReplace = /(^|\s)@all\b/gi;
content = content.replace(allRegexReplace, '$1').trim();
}
const lidRegex = /(^|\s)@(\d{6,15})@lid\b/g;
const lidMatches = text.matchAll(lidRegex);
for (const match of lidMatches) {
const lidNumber = match[2];
const formattedLid = `${lidNumber}@lid`;
if (!mentions.includes(formattedLid)) {
mentions.push(formattedLid);
}
}
if (mentions.length > 0) {
const lidRegexReplace = /(^|\s)@(\d{6,15})@lid\b/g;
content = content.replace(lidRegexReplace, '$1@$2').trim();
}
// Regex to match @ followed by phone number
// Matches: @558591203123, @5585991203123, etc.
// Phone numbers: 7-15 digits (international format)
const phoneRegex = /(^|\s)@(\d{7,15})(?!@lid)\b/g;
const phoneMatches = text.matchAll(phoneRegex);
for (const match of phoneMatches) {
const phoneNumber = match[2];
// Format phone number to @c.us format
const formattedJid = ensureSuffix(phoneNumber);
if (!mentions.includes(formattedJid)) {
mentions.push(formattedJid);
}
}
if (mentions.length == 0) {
// Nothing found
return {
text: text,
mentions: null,
};
}
// Ensure text is not empty when there are mentions
// If text becomes empty after removing mentions, use a default message
content = content.trim();
if (content === '') {
// When only mentions are present, use a minimal text to satisfy DTO requirements
// The mentions array will handle the actual mentions
content = ' ';
}
return {
text: content,
mentions: mentions,
};
}
+74 -13
View File
@@ -4,6 +4,7 @@ import { WAHAEngine } from '@waha/structures/enums.dto';
import { getEngineName } from '@waha/version';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { WAMessage } from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
@@ -52,7 +53,7 @@ class NOWEBHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
return chats;
return preferPnChats(chats);
}
ContactIsMy(contact) {
@@ -96,7 +97,7 @@ class GOWSHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
return chats;
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
@@ -124,7 +125,7 @@ class WEBJSHelper implements IEngineHelper {
return {
timestamp: new Date(message.timestamp * 1000),
from_me: message.fromMe,
chat_id: message.from,
chat_id: message.id.remote,
message_id: message.id.id,
participant: message.author || null,
};
@@ -150,17 +151,63 @@ class WEBJSHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
if (chats.length == 2) {
const lidChat = chats.find(isLidUser);
const cusChat = chats.find(isPnUser);
if (lidChat && cusChat) {
return [lidChat];
}
// WEBJS engine merges messages for @lid and @c.us
// into single chat, so it's fine to pull only from one
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
return false;
}
ContactIsMy(contact) {
return contact.isMyContact;
}
}
class WPPHelper implements IEngineHelper {
ChatID(message: WAMessage): string {
return toCusFormat(parseMessageIdSerialized(message.id as any).remoteJid);
}
CallChatID(call: CallData): string {
return call.from;
}
/**
* Parse API response and get the data for WPP engine.
* WPP returns message.id as a composite string "fromMe_chatId_msgId".
*/
WhatsAppMessageKeys(message: any): WhatsAppMessage {
const parsed = parseMessageIdSerialized(message.id);
return {
timestamp: new Date(message.timestamp * 1000),
from_me: parsed.fromMe,
chat_id: toCusFormat(parsed.remoteJid),
message_id: parsed.id,
participant: message.author || null,
};
}
/**
* WPP API lacks server-side sorting hooks, so we buffer and sort by the unix timestamp in memory.
*/
async *IterateMessages<T extends { timestamp: number }>(
messages: AsyncGenerator<T>,
): AsyncGenerator<T> {
const buffer: T[] = [];
for await (const message of messages) {
buffer.push(message);
}
// Otherwise - return the original
return chats;
const sorted = lodash.sortBy(buffer, (item) => item.timestamp);
for (const message of sorted) {
yield message;
}
}
FilterChatIdsForMessages(chats: string[]): string[] {
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
@@ -185,8 +232,22 @@ switch (getEngineName()) {
case WAHAEngine.WEBJS:
engineHelper = new WEBJSHelper();
break;
case WAHAEngine.WPP:
engineHelper = new WPPHelper();
break;
default:
engineHelper = new WEBJSHelper(); // Default to WEBJS as it's the default engine
}
export const EngineHelper = engineHelper;
function preferPnChats(chats: string[]): string[] {
const unique = lodash.uniq(chats ?? []);
const hasPn = unique.some(isPnUser);
const hasLid = unique.some(isLidUser);
if (hasPn && hasLid) {
// Prefer @c.us / phone chats when both formats exist to avoid duplicate fetches.
return unique.filter(isPnUser);
}
return unique;
}
+12
View File
@@ -5,3 +5,15 @@ export function getEngineName(): string {
// If not set - use WEBJS
return process.env.WHATSAPP_DEFAULT_ENGINE || WAHAEngine.WEBJS;
}
export function getNamespace(): string {
// Controls the prefix used for the main database and folder (non-session data)
// If not set - falls back to engine name for backward compatibility
return (process.env.WAHA_NAMESPACE || getEngineName()).toLowerCase();
}
export function getSessionNamespace(): string {
// Controls the prefix used for session databases and folders
// If not set - falls back to engine name for backward compatibility
return (process.env.WAHA_SESSION_NAMESPACE || getEngineName()).toLowerCase();
}
-38
View File
@@ -1,38 +0,0 @@
import { Body, Controller, Delete, Param, Put, UsePipes } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
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';
@ApiSecurity('api_key')
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
@Put('/:chatId')
@SessionApiParam
@ChatIdApiParam
@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 };
}
}
+5 -5
View File
@@ -74,15 +74,14 @@ export class SwaggerConfiguratorCore {
.setExternalDoc(this.title, this.externalDocUrl)
.setVersion(VERSION.version)
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔑 Auth', 'Authentication')
.addTag('📱 Pairing', 'Pair a session with WhatsApp on your phone.')
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📞 Calls', 'Call handling methods')
.addTag('✅ Presence', `Presence information`)
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
.addTag('🔑 Api Keys', 'API Keys management')
.addTag(
'👤 Contacts',
`Contacts methods.<br>
@@ -90,13 +89,14 @@ export class SwaggerConfiguratorCore {
'E.g: \`12312312310\` OR \`12312312310@c.us\`<br>`,
)
.addTag('👥 Groups', `Groups methods.<br>`)
.addTag('✅ Presence', `Presence information`)
.addTag('📞 Calls', 'Call handling methods')
.addTag('📅 Events', `Event Message`)
.addTag(
'🏷️ Labels',
'Labels - available only for WhatsApp Business accounts',
)
.addTag('🖼️ Media', 'Media methods')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔍 Observability', 'Other methods')
.addTag('🗄️ Storage', 'Storage methods')
.addApiKey({
+2
View File
@@ -36,6 +36,7 @@ import {
import { ISessionAuthRepository } from '../storage/ISessionAuthRepository';
import { ISessionConfigRepository } from '../storage/ISessionConfigRepository';
import { WhatsappSession } from './session.abc';
import { IApiKeyRepository } from '@waha/core/storage/IApiKeyRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -48,6 +49,7 @@ export abstract class SessionManager
public sessionConfigRepository: ISessionConfigRepository;
protected sessionMeRepository: ISessionMeRepository;
protected sessionWorkerRepository: ISessionWorkerRepository;
public apiKeyRepository: IApiKeyRepository;
private lock: any;
WAIT_SESSION_RUNNING_INTERVAL = 500;
+3 -2
View File
@@ -50,7 +50,6 @@ import * as lodash from 'lodash';
import * as NodeCache from 'node-cache';
import { Logger } from 'pino';
import {
BehaviorSubject,
catchError,
delay,
filter,
@@ -228,7 +227,8 @@ export abstract class WhatsappSession {
engineConfig,
ignore,
}: SessionParams) {
this.status$ = new BehaviorSubject(WAHASessionStatus.STOPPED);
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<WAHASessionStatus>();
this.name = name;
this.proxyConfig = proxyConfig;
@@ -381,6 +381,7 @@ export abstract class WhatsappSession {
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-metrics',
// '--disable-features=site-per-process', // COMMENTED to test WEBJS stability
'--disable-gpu', // COMMENTED to test WEBJS stability
'--disable-offer-store-unmasked-wallet-cards',
+18 -4
View File
@@ -9,18 +9,20 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { LidsController } from '@waha/api/lids.controller';
import { ApiKeysController } from '@waha/api/apikeys.controller';
import { ProfileController } from '@waha/api/profile.controller';
import { ServerController } from '@waha/api/server.controller';
import { ServerDebugController } from '@waha/api/server.debug.controller';
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
import { AppsModuleExports } from '@waha/apps/apps.module';
import { ContactsSessionController } from '@waha/contacts.session.controller';
import { ContactsSessionController } from '@waha/api/contacts.session.controller';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
@@ -64,6 +66,9 @@ import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore';
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
@@ -140,6 +145,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
ApiKeysController,
SessionsController,
ProfileController,
ChattingController,
@@ -171,6 +177,7 @@ export const PROVIDERS_BASE: Provider[] = [
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
WhatsappConfigService,
EngineConfigService,
@@ -178,6 +185,9 @@ export const PROVIDERS_BASE: Provider[] = [
MediaLocalStorageConfig,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
CaslAbilityFactory,
PoliciesGuard,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -234,11 +244,15 @@ export class AppModuleCore {
}
configure(consumer: MiddlewareConsumer) {
// Because we use ServeStaticModule, we need to inject a middleware
// ServeStaticModule does not support @UseGuards
const exclude = this.config.getExcludedPaths();
consumer
.apply(AuthMiddleware)
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'ws');
.forRoutes('api', 'health');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { User } from '@waha/core/auth/apiKey.strategy';
import { SessionManager } from '@waha/core/abc/manager.abc';
@Injectable()
export class ApiKeyService {
constructor(private manager: SessionManager) {}
async get(apikey: string): Promise<User | null> {
if (!apikey) {
return null;
}
const key = await this.manager.apiKeyRepository.getActiveByKey(apikey);
if (!key) {
return null;
}
return {
isAdmin: key.isAdmin,
session: key.session,
};
}
}
+20 -22
View File
@@ -1,34 +1,32 @@
import { Injectable } from '@nestjs/common';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { IApiKeyAuth } from './auth';
import { URL } from 'url';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
@Injectable()
export class WebSocketAuth {
constructor(private auth: IApiKeyAuth) {}
constructor(private strategy: ApiKeyStrategy) {}
validateRequest(request: IncomingMessage) {
if (this.auth.skipAuth()) {
return true;
}
const provided = this.getKeyFromQueryParams(request);
return this.auth.isValid(provided);
async validateRequest(request: IncomingMessage) {
const apikey = this.getKeyFromQueryParams('x-api-key', request);
return await this.strategy.user(apikey);
}
private getKeyFromQueryParams(request: IncomingMessage) {
let query = url.parse(request.url, true).query;
// case-insensitive query params
query = Object.keys(query).reduce((acc, key) => {
acc[key.toLowerCase()] = query[key];
return acc;
}, {});
private getKeyFromQueryParams(name: string, request: IncomingMessage) {
// Case-insensitive
name = name.toLowerCase();
const query = new URL(request.url || '', 'http://localhost').searchParams;
const matches: string[] = [];
const provided = query['x-api-key'];
// Check if it's array - return first
if (Array.isArray(provided)) {
return provided[0];
for (const [key, value] of query.entries()) {
if (key.toLowerCase() === name) {
matches.push(value);
}
}
return provided;
if (matches.length === 0) {
return undefined;
}
return matches[0];
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard('headerapikey') {}
+37
View File
@@ -0,0 +1,37 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import { IApiKeyAuth, NoAuth } from '@waha/core/auth/auth';
import * as passport from 'passport';
@Injectable()
export class ApiKeyAuthMiddleware implements NestMiddleware {
constructor(private auth: IApiKeyAuth) {}
use(req: any, res: any, next: () => void) {
if (this.auth instanceof NoAuth) {
next();
return;
}
passport.authenticate('headerapikey', { session: false }, (err, user?) => {
if (err) {
const exception =
err instanceof UnauthorizedException
? err
: new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
if (!user) {
const exception = new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
req.user = user;
next();
})(req, res, next);
}
}
+29 -6
View File
@@ -2,19 +2,42 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export interface User {
isAdmin: boolean;
session?: string;
}
function AdminUser(): User {
return {
isAdmin: true,
session: null,
};
}
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
constructor(private auth: IApiKeyAuth) {
constructor(
private auth: IApiKeyAuth,
private apiKeyService: ApiKeyService,
) {
// @ts-ignore
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
const isValid = this.auth.isValid(apikey);
return done(isValid);
return this.validate(apikey, done);
});
}
validate(apikey: string, done: (result: boolean) => void): void {
const isValid = this.auth.isValid(apikey);
return done(isValid);
validate(apikey: string, done: (err?, user?: User) => void): void {
this.user(apikey)
.then((user) => done(null, user))
.catch((err) => done(err, null));
}
async user(apikey: string): Promise<User> {
if (this.auth.isValid(apikey)) {
return AdminUser();
}
return this.apiKeyService.get(apikey);
}
}
-28
View File
@@ -1,28 +0,0 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import * as passport from 'passport';
import { IApiKeyAuth } from './auth';
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(private auth: IApiKeyAuth) {}
use(req: any, res: any, next: () => void) {
// Skip authentication if auth says so
if (this.auth.skipAuth()) {
next();
return;
}
passport.authenticate('headerapikey', { session: false }, (value) => {
if (!value) {
throw new UnauthorizedException();
}
next();
})(req, res, next);
}
}
-14
View File
@@ -2,18 +2,12 @@ import * as crypto from 'crypto';
export abstract class IApiKeyAuth {
abstract isValid(plain: string): boolean;
abstract skipAuth(): boolean;
}
export class NoAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return true;
}
skipAuth(): boolean {
return true;
}
}
export class PlainApiKeyAuth implements IApiKeyAuth {
@@ -22,10 +16,6 @@ export class PlainApiKeyAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return compare(plain, this.key);
}
skipAuth(): boolean {
return false;
}
}
export class HashAuth implements IApiKeyAuth {
@@ -41,10 +31,6 @@ export class HashAuth implements IApiKeyAuth {
const hash = crypto.createHash(this.algorithm).update(plain).digest('hex');
return compare(hash, this.hash);
}
skipAuth(): boolean {
return false;
}
}
/**
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { User } from './apiKey.strategy';
import { createMongoAbility } from '@casl/ability';
import { AdminRules, SessionRules } from './casl.rules';
import { Action, AppAbility, session } from './casl.types';
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User): AppAbility {
// when we disable auth using WHATSAPP_API_KEY_EXCLUDE_PATH
// req.use will be null, so we allow any request
if (!user) {
return createMongoAbility(AdminRules());
}
// Admin user
if (user.isAdmin) {
return createMongoAbility(AdminRules());
}
if (user.session) {
return createMongoAbility(SessionRules(user.session));
}
return createMongoAbility([]);
}
}
export function FilterSessions<T extends { name: string }>(
ability: AppAbility,
action: Action,
sessions: T[],
) {
return sessions.filter((s) => ability.can(action, new session(s.name)));
}
+45
View File
@@ -0,0 +1,45 @@
import { RawRuleOf } from '@casl/ability';
import { Action, AppAbility } from './casl.types';
export function AdminRules(): RawRuleOf<AppAbility>[] {
return [
{
action: Action.Manage,
subject: 'all',
},
];
}
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
return [
//
// Server
//
{
action: 'read',
subject: 'server',
},
//
// Session
//
{
action: Action.List,
subject: 'session',
},
{
action: Action.Read,
subject: 'session',
conditions: { name: name },
},
// {
// action: Action.Delete,
// subject: 'session',
// conditions: { name: name },
// },
{
action: Action.Use,
subject: 'session',
conditions: { name: name },
},
];
}
+28
View File
@@ -0,0 +1,28 @@
import { InferSubjects, MongoAbility } from '@casl/ability';
export class session {
constructor(public name: string) {}
}
export class server {}
export enum Action {
//
// CASL
//
Manage = 'manage', // it's a special action in casl, meaning "any actions"
//
// Session
//
List = 'list', // list sessions
Read = 'read', // read session info (not messages)
Create = 'create', // create a new session
Delete = 'delete', // delete a session
Use = 'use', // all actions - send a message, retrieve, manage session status
}
type Subjects =
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
| 'all';
type Actions = keyof typeof Action | Action | `${Action}`;
export type AppAbility = MongoAbility<[Actions, Subjects]>;
+2 -1
View File
@@ -1,4 +1,5 @@
import { parseBool } from '@waha/helpers';
import * as crypto from 'crypto';
export interface SValue {
param: string;
@@ -142,7 +143,7 @@ export function ReportGeneratedValue() {
'Save these values to your environment (.env or WAHA_*) to reuse them; new keys are generated on every start otherwise.',
);
console.warn('');
console.warn("cat <<'EOF' > .env");
console.warn("cat <<'EOF' >> .env");
console.warn('');
for (const key of values) {
console.warn(`${key.param}=${key.value}`);
+15
View File
@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common';
import { ExecutionContext } from '@nestjs/common';
import { AppAbility } from './casl.types';
export const CHECK_POLICIES_KEY = 'check_policies';
export type PolicyHandlerCallback = (
ability: AppAbility,
context: ExecutionContext,
) => boolean;
export type PolicyHandler = PolicyHandlerCallback;
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
+43
View File
@@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import {
CHECK_POLICIES_KEY,
PolicyHandler,
} from '@waha/core/auth/policies.decorator';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handlers =
this.reflector.getAllAndOverride<PolicyHandler[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]) || [];
if (handlers.length === 0) {
throw new ForbiddenException();
}
const req = context.switchToHttp().getRequest();
const user = req.user;
const ability = this.caslAbilityFactory.createForUser(user);
req.ability = ability;
const ok = handlers.every((handler) => handler(ability, context));
if (!ok) {
throw new ForbiddenException();
}
return true;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { BadRequestException, ExecutionContext } from '@nestjs/common';
import {
Action,
AppAbility,
server,
session,
} from '@waha/core/auth/casl.types';
type GetSession = (req: any) => unknown;
function requireSessionName(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException('Session name is required');
}
return value;
}
export function CanSession(action: Action, name?: GetSession) {
return (ability: AppAbility, context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
if (name) {
const sessionName = requireSessionName(name(req));
return ability.can(action, new session(sessionName));
}
return ability.can(action, 'session');
};
}
export function FromParam(key = 'session'): GetSession {
return (req) => req.params?.[key];
}
export function FromBody(key = 'session'): GetSession {
return (req) => req.body?.[key];
}
export function FromQuery(key = 'session'): GetSession {
return (req) => req.query?.[key];
}
export function CanServer(action: Action) {
return (ability: AppAbility, context: ExecutionContext) => {
return ability.can(action, 'server');
};
}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WPPConfig } from '@waha/core/engines/wpp/WppConfig';
@Injectable()
export class WPPEngineConfigService {
constructor(protected configService: ConfigService) {}
getConfig(): WPPConfig {
let webVersion = this.configService.get<string>(
'WAHA_WPP_WEB_VERSION',
undefined,
);
if (!webVersion) {
webVersion = this.configService.get<string>(
'WAHA_WEBJS_WEB_VERSION',
undefined,
);
}
return {
webVersion: webVersion,
puppeteerArgs: this.getPuppeterArgs(),
};
}
getPuppeterArgs(): string[] {
let args = this.configService.get<string>('WAHA_WPP_PUPPETER_ARGS', '');
if (!args) {
args = this.configService.get<string>('WAHA_WEBJS_PUPPETER_ARGS', '');
}
return args
.split(' ')
.map((arg) => arg.trim())
.filter(Boolean);
}
}
@@ -52,9 +52,11 @@ export class GowsEventStreamObservable extends Observable<EnginePayload> {
};
stream.on('data', (raw) => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
setImmediate(() => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
});
});
stream.on('end', (...args) => {
+34 -25
View File
@@ -9,6 +9,7 @@ export class GowsSubprocess {
private child: any;
private ready: boolean = false;
private stdoutBuffer: string = '';
constructor(
private logger: Logger,
@@ -17,7 +18,7 @@ export class GowsSubprocess {
readonly pprof: boolean = false,
) {}
start(onExit: (code: number) => void) {
start(onExit: (code: number | null, signal: NodeJS.Signals | null) => void) {
this.logger.info('Starting GOWS subprocess...');
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
@@ -33,43 +34,51 @@ export class GowsSubprocess {
detached: true,
});
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
this.child.on('close', async (code, singal) => {
const msg = code
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${singal}`;
this.child.on('close', (code, signal) => {
const msg =
code !== null
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${signal}`;
this.logger.debug(msg);
onExit(code);
onExit(code, signal);
});
this.child.on('error', (err) => {
this.logger.error(`GOWS subprocess error: ${err}`);
});
this.child.stderr.setEncoding('utf8');
this.child.stderr.on('data', (data) => {
this.logger.error(data);
this.child.stderr?.setEncoding('utf8');
this.child.stderr?.on('data', (data) => {
this.logger.error(data.toString().trim());
});
this.child.stdout.setEncoding('utf8');
this.child.stdout.on('data', async (data) => {
// remove empty line at the end, split by \n
const lines = data.trim().split('\n');
lines.forEach((line) => this.log(line));
this.child.stdout?.setEncoding('utf8');
this.child.stdout?.on('data', (data) => {
this.handleStdout(data.toString());
});
this.listenReady();
}
listenReady() {
this.child.stdout.on('data', async (data) => {
if (this.ready) {
private handleStdout(chunk: string) {
this.stdoutBuffer += chunk;
const parts = this.stdoutBuffer.split('\n');
this.stdoutBuffer = parts.pop() ?? '';
parts.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
if (!data.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
this.log(trimmed);
void this.checkReady(trimmed);
});
void this.checkReady(this.stdoutBuffer);
}
private async checkReady(text: string) {
if (this.ready || !text.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
}
async waitWhenReady(timeout: number) {
@@ -79,7 +88,7 @@ export class GowsSubprocess {
timeout,
);
if (!started) {
const msg = 'GOWS did not start after 10 seconds';
const msg = `GOWS did not start after ${timeout} ms`;
this.logger.error(msg);
throw new Error(msg);
}
+6 -8
View File
@@ -1,24 +1,22 @@
import * as grpc from '@grpc/grpc-js';
import { messages } from '@waha/core/engines/gows/grpc/gows';
export const GetMessageServiceClient = (
session: string,
export function BuildMessageServiceClient(
address: string,
credentials: grpc.ChannelCredentials,
): messages.MessageServiceClient => {
): messages.MessageServiceClient {
return new messages.MessageServiceClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
});
};
}
export const GetEventStreamClient = (
session: string,
export function BuildEventStreamClient(
address: string,
credentials: grpc.ChannelCredentials,
): messages.EventStreamClient => {
): messages.EventStreamClient {
return new messages.EventStreamClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
});
};
}
+468 -6
View File
@@ -23,7 +23,8 @@ export namespace messages {
IMAGE = 0,
AUDIO = 1,
VIDEO = 2,
DOCUMENT = 3
DOCUMENT = 3,
PTV = 4
}
export enum Presence {
AVAILABLE = 0,
@@ -878,6 +879,99 @@ export namespace messages {
return EventJson.deserialize(bytes);
}
}
export class StreamEventsRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
exclude?: string[];
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [2], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("exclude" in data && data.exclude != undefined) {
this.exclude = data.exclude;
}
}
}
get session() {
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
}
set session(value: Session) {
pb_1.Message.setWrapperField(this, 1, value);
}
get has_session() {
return pb_1.Message.getField(this, 1) != null;
}
get exclude() {
return pb_1.Message.getFieldWithDefault(this, 2, []) as string[];
}
set exclude(value: string[]) {
pb_1.Message.setField(this, 2, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
exclude?: string[];
}): StreamEventsRequest {
const message = new StreamEventsRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.exclude != null) {
message.exclude = data.exclude;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
exclude?: string[];
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.exclude != null) {
data.exclude = this.exclude;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_session)
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
if (this.exclude.length)
writer.writeRepeatedString(2, this.exclude);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): StreamEventsRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new StreamEventsRequest();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
break;
case 2:
pb_1.Message.addToRepeatedField(message, 2, reader.readString());
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): StreamEventsRequest {
return StreamEventsRequest.deserialize(bytes);
}
}
export class PairCodeRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -1235,6 +1329,193 @@ export namespace messages {
return SessionStoreConfig.deserialize(bytes);
}
}
export class SessionStorageConfig extends pb_1.Message {
#one_of_decls: number[][] = [[1], [2], [3], [4]];
constructor(data?: any[] | ({} & (({
messages?: boolean;
}) | ({
groups?: boolean;
}) | ({
chats?: boolean;
}) | ({
labels?: boolean;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("messages" in data && data.messages != undefined) {
this.messages = data.messages;
}
if ("groups" in data && data.groups != undefined) {
this.groups = data.groups;
}
if ("chats" in data && data.chats != undefined) {
this.chats = data.chats;
}
if ("labels" in data && data.labels != undefined) {
this.labels = data.labels;
}
}
}
get messages() {
return pb_1.Message.getFieldWithDefault(this, 1, false) as boolean;
}
set messages(value: boolean) {
pb_1.Message.setOneofField(this, 1, this.#one_of_decls[0], value);
}
get has_messages() {
return pb_1.Message.getField(this, 1) != null;
}
get groups() {
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
}
set groups(value: boolean) {
pb_1.Message.setOneofField(this, 2, this.#one_of_decls[1], value);
}
get has_groups() {
return pb_1.Message.getField(this, 2) != null;
}
get chats() {
return pb_1.Message.getFieldWithDefault(this, 3, false) as boolean;
}
set chats(value: boolean) {
pb_1.Message.setOneofField(this, 3, this.#one_of_decls[2], value);
}
get has_chats() {
return pb_1.Message.getField(this, 3) != null;
}
get labels() {
return pb_1.Message.getFieldWithDefault(this, 4, false) as boolean;
}
set labels(value: boolean) {
pb_1.Message.setOneofField(this, 4, this.#one_of_decls[3], value);
}
get has_labels() {
return pb_1.Message.getField(this, 4) != null;
}
get _messages() {
const cases: {
[index: number]: "none" | "messages";
} = {
0: "none",
1: "messages"
};
return cases[pb_1.Message.computeOneofCase(this, [1])];
}
get _groups() {
const cases: {
[index: number]: "none" | "groups";
} = {
0: "none",
2: "groups"
};
return cases[pb_1.Message.computeOneofCase(this, [2])];
}
get _chats() {
const cases: {
[index: number]: "none" | "chats";
} = {
0: "none",
3: "chats"
};
return cases[pb_1.Message.computeOneofCase(this, [3])];
}
get _labels() {
const cases: {
[index: number]: "none" | "labels";
} = {
0: "none",
4: "labels"
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
static fromObject(data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
}): SessionStorageConfig {
const message = new SessionStorageConfig({});
if (data.messages != null) {
message.messages = data.messages;
}
if (data.groups != null) {
message.groups = data.groups;
}
if (data.chats != null) {
message.chats = data.chats;
}
if (data.labels != null) {
message.labels = data.labels;
}
return message;
}
toObject() {
const data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
} = {};
if (this.messages != null) {
data.messages = this.messages;
}
if (this.groups != null) {
data.groups = this.groups;
}
if (this.chats != null) {
data.chats = this.chats;
}
if (this.labels != null) {
data.labels = this.labels;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_messages)
writer.writeBool(1, this.messages);
if (this.has_groups)
writer.writeBool(2, this.groups);
if (this.has_chats)
writer.writeBool(3, this.chats);
if (this.has_labels)
writer.writeBool(4, this.labels);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): SessionStorageConfig {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new SessionStorageConfig();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.messages = reader.readBool();
break;
case 2:
message.groups = reader.readBool();
break;
case 3:
message.chats = reader.readBool();
break;
case 4:
message.labels = reader.readBool();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): SessionStorageConfig {
return SessionStorageConfig.deserialize(bytes);
}
}
export class SessionProxyConfig extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -1439,13 +1720,15 @@ export namespace messages {
}
}
export class SessionConfig extends pb_1.Message {
#one_of_decls: number[][] = [[4]];
#one_of_decls: number[][] = [[4], [5]];
constructor(data?: any[] | ({
store?: SessionStoreConfig;
log?: SessionLogConfig;
proxy?: SessionProxyConfig;
} & (({
ignore?: SessionIgnoreJidsConfig;
}) | ({
storage?: SessionStorageConfig;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -1462,6 +1745,9 @@ export namespace messages {
if ("ignore" in data && data.ignore != undefined) {
this.ignore = data.ignore;
}
if ("storage" in data && data.storage != undefined) {
this.storage = data.storage;
}
}
}
get store() {
@@ -1500,6 +1786,15 @@ export namespace messages {
get has_ignore() {
return pb_1.Message.getField(this, 4) != null;
}
get storage() {
return pb_1.Message.getWrapperField(this, SessionStorageConfig, 5) as SessionStorageConfig;
}
set storage(value: SessionStorageConfig) {
pb_1.Message.setOneofWrapperField(this, 5, this.#one_of_decls[1], value);
}
get has_storage() {
return pb_1.Message.getField(this, 5) != null;
}
get _ignore() {
const cases: {
[index: number]: "none" | "ignore";
@@ -1509,11 +1804,21 @@ export namespace messages {
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
get _storage() {
const cases: {
[index: number]: "none" | "storage";
} = {
0: "none",
5: "storage"
};
return cases[pb_1.Message.computeOneofCase(this, [5])];
}
static fromObject(data: {
store?: ReturnType<typeof SessionStoreConfig.prototype.toObject>;
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
}): SessionConfig {
const message = new SessionConfig({});
if (data.store != null) {
@@ -1528,6 +1833,9 @@ export namespace messages {
if (data.ignore != null) {
message.ignore = SessionIgnoreJidsConfig.fromObject(data.ignore);
}
if (data.storage != null) {
message.storage = SessionStorageConfig.fromObject(data.storage);
}
return message;
}
toObject() {
@@ -1536,6 +1844,7 @@ export namespace messages {
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
} = {};
if (this.store != null) {
data.store = this.store.toObject();
@@ -1549,6 +1858,9 @@ export namespace messages {
if (this.ignore != null) {
data.ignore = this.ignore.toObject();
}
if (this.storage != null) {
data.storage = this.storage.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -1563,6 +1875,8 @@ export namespace messages {
writer.writeMessage(3, this.proxy, () => this.proxy.serialize(writer));
if (this.has_ignore)
writer.writeMessage(4, this.ignore, () => this.ignore.serialize(writer));
if (this.has_storage)
writer.writeMessage(5, this.storage, () => this.storage.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -1584,6 +1898,9 @@ export namespace messages {
case 4:
reader.readMessage(message.ignore, () => message.ignore = SessionIgnoreJidsConfig.deserialize(reader));
break;
case 5:
reader.readMessage(message.storage, () => message.storage = SessionStorageConfig.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -2679,6 +2996,73 @@ export namespace messages {
return AudioInfo.deserialize(bytes);
}
}
export class VideoInfo extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
duration?: number;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("duration" in data && data.duration != undefined) {
this.duration = data.duration;
}
}
}
get duration() {
return pb_1.Message.getFieldWithDefault(this, 1, 0) as number;
}
set duration(value: number) {
pb_1.Message.setField(this, 1, value);
}
static fromObject(data: {
duration?: number;
}): VideoInfo {
const message = new VideoInfo({});
if (data.duration != null) {
message.duration = data.duration;
}
return message;
}
toObject() {
const data: {
duration?: number;
} = {};
if (this.duration != null) {
data.duration = this.duration;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.duration != 0)
writer.writeFloat(1, this.duration);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): VideoInfo {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new VideoInfo();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.duration = reader.readFloat();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): VideoInfo {
return VideoInfo.deserialize(bytes);
}
}
export class Media extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -2688,6 +3072,7 @@ export namespace messages {
audio?: AudioInfo;
filename?: string;
contentPath?: string;
video?: VideoInfo;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -2710,6 +3095,9 @@ export namespace messages {
if ("contentPath" in data && data.contentPath != undefined) {
this.contentPath = data.contentPath;
}
if ("video" in data && data.video != undefined) {
this.video = data.video;
}
}
}
get content() {
@@ -2751,6 +3139,15 @@ export namespace messages {
set contentPath(value: string) {
pb_1.Message.setField(this, 6, value);
}
get video() {
return pb_1.Message.getWrapperField(this, VideoInfo, 7) as VideoInfo;
}
set video(value: VideoInfo) {
pb_1.Message.setWrapperField(this, 7, value);
}
get has_video() {
return pb_1.Message.getField(this, 7) != null;
}
static fromObject(data: {
content?: Uint8Array;
type?: MediaType;
@@ -2758,6 +3155,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
}): Media {
const message = new Media({});
if (data.content != null) {
@@ -2778,6 +3176,9 @@ export namespace messages {
if (data.contentPath != null) {
message.contentPath = data.contentPath;
}
if (data.video != null) {
message.video = VideoInfo.fromObject(data.video);
}
return message;
}
toObject() {
@@ -2788,6 +3189,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
} = {};
if (this.content != null) {
data.content = this.content;
@@ -2807,6 +3209,9 @@ export namespace messages {
if (this.contentPath != null) {
data.contentPath = this.contentPath;
}
if (this.video != null) {
data.video = this.video.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -2825,6 +3230,8 @@ export namespace messages {
writer.writeString(5, this.filename);
if (this.contentPath.length)
writer.writeString(6, this.contentPath);
if (this.has_video)
writer.writeMessage(7, this.video, () => this.video.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -2852,6 +3259,9 @@ export namespace messages {
case 6:
message.contentPath = reader.readString();
break;
case 7:
reader.readMessage(message.video, () => message.video = VideoInfo.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -8811,6 +9221,7 @@ export namespace messages {
filters?: MessageFilters;
pagination?: Pagination;
sortBy?: SortBy;
merge?: OptionalBool;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -8827,6 +9238,9 @@ export namespace messages {
if ("sortBy" in data && data.sortBy != undefined) {
this.sortBy = data.sortBy;
}
if ("merge" in data && data.merge != undefined) {
this.merge = data.merge;
}
}
}
get session() {
@@ -8865,11 +9279,21 @@ export namespace messages {
get has_sortBy() {
return pb_1.Message.getField(this, 4) != null;
}
get merge() {
return pb_1.Message.getWrapperField(this, OptionalBool, 5) as OptionalBool;
}
set merge(value: OptionalBool) {
pb_1.Message.setWrapperField(this, 5, value);
}
get has_merge() {
return pb_1.Message.getField(this, 5) != null;
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
filters?: ReturnType<typeof MessageFilters.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
}): GetMessagesRequest {
const message = new GetMessagesRequest({});
if (data.session != null) {
@@ -8884,6 +9308,9 @@ export namespace messages {
if (data.sortBy != null) {
message.sortBy = SortBy.fromObject(data.sortBy);
}
if (data.merge != null) {
message.merge = OptionalBool.fromObject(data.merge);
}
return message;
}
toObject() {
@@ -8892,6 +9319,7 @@ export namespace messages {
filters?: ReturnType<typeof MessageFilters.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
@@ -8905,6 +9333,9 @@ export namespace messages {
if (this.sortBy != null) {
data.sortBy = this.sortBy.toObject();
}
if (this.merge != null) {
data.merge = this.merge.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -8919,6 +9350,8 @@ export namespace messages {
writer.writeMessage(3, this.pagination, () => this.pagination.serialize(writer));
if (this.has_sortBy)
writer.writeMessage(4, this.sortBy, () => this.sortBy.serialize(writer));
if (this.has_merge)
writer.writeMessage(5, this.merge, () => this.merge.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -8940,6 +9373,9 @@ export namespace messages {
case 4:
reader.readMessage(message.sortBy, () => message.sortBy = SortBy.deserialize(reader));
break;
case 5:
reader.readMessage(message.merge, () => message.merge = OptionalBool.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -9287,6 +9723,7 @@ export namespace messages {
sortBy?: SortBy;
pagination?: Pagination;
filter?: ChatFilter;
merge?: OptionalBool;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -9303,6 +9740,9 @@ export namespace messages {
if ("filter" in data && data.filter != undefined) {
this.filter = data.filter;
}
if ("merge" in data && data.merge != undefined) {
this.merge = data.merge;
}
}
}
get session() {
@@ -9341,11 +9781,21 @@ export namespace messages {
get has_filter() {
return pb_1.Message.getField(this, 4) != null;
}
get merge() {
return pb_1.Message.getWrapperField(this, OptionalBool, 5) as OptionalBool;
}
set merge(value: OptionalBool) {
pb_1.Message.setWrapperField(this, 5, value);
}
get has_merge() {
return pb_1.Message.getField(this, 5) != null;
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
filter?: ReturnType<typeof ChatFilter.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
}): GetChatsRequest {
const message = new GetChatsRequest({});
if (data.session != null) {
@@ -9360,6 +9810,9 @@ export namespace messages {
if (data.filter != null) {
message.filter = ChatFilter.fromObject(data.filter);
}
if (data.merge != null) {
message.merge = OptionalBool.fromObject(data.merge);
}
return message;
}
toObject() {
@@ -9368,6 +9821,7 @@ export namespace messages {
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
filter?: ReturnType<typeof ChatFilter.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
@@ -9381,6 +9835,9 @@ export namespace messages {
if (this.filter != null) {
data.filter = this.filter.toObject();
}
if (this.merge != null) {
data.merge = this.merge.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -9395,6 +9852,8 @@ export namespace messages {
writer.writeMessage(3, this.pagination, () => this.pagination.serialize(writer));
if (this.has_filter)
writer.writeMessage(4, this.filter, () => this.filter.serialize(writer));
if (this.has_merge)
writer.writeMessage(5, this.merge, () => this.merge.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -9416,6 +9875,9 @@ export namespace messages {
case 4:
reader.readMessage(message.filter, () => message.filter = ChatFilter.deserialize(reader));
break;
case 5:
reader.readMessage(message.merge, () => message.merge = OptionalBool.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -10490,20 +10952,20 @@ export namespace messages {
path: "/messages.EventStream/StreamEvents",
requestStream: false,
responseStream: true,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
requestSerialize: (message: StreamEventsRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => StreamEventsRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: EventJson) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => EventJson.deserialize(new Uint8Array(bytes))
}
};
[method: string]: grpc_1.UntypedHandleCall;
abstract StreamEvents(call: grpc_1.ServerWritableStream<Session, EventJson>): void;
abstract StreamEvents(call: grpc_1.ServerWritableStream<StreamEventsRequest, EventJson>): void;
}
export class EventStreamClient extends grpc_1.makeGenericClientConstructor(UnimplementedEventStreamService.definition, "EventStream", {}) {
constructor(address: string, credentials: grpc_1.ChannelCredentials, options?: Partial<grpc_1.ChannelOptions>) {
super(address, credentials, options);
}
StreamEvents: GrpcStreamServiceInterface<Session, EventJson> = (message: Session, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
StreamEvents: GrpcStreamServiceInterface<StreamEventsRequest, EventJson> = (message: StreamEventsRequest, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
return super.StreamEvents(message, metadata, options);
};
}
+14 -3
View File
@@ -642,6 +642,17 @@ function deserialize_messages_StartSessionRequest(buffer_arg) {
return gows_pb.StartSessionRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_StreamEventsRequest(arg) {
if (!(arg instanceof gows_pb.StreamEventsRequest)) {
throw new Error('Expected argument of type messages.StreamEventsRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_StreamEventsRequest(buffer_arg) {
return gows_pb.StreamEventsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_SubscribePresenceRequest(arg) {
if (!(arg instanceof gows_pb.SubscribePresenceRequest)) {
throw new Error('Expected argument of type messages.SubscribePresenceRequest');
@@ -695,10 +706,10 @@ var EventStreamService = exports.EventStreamService = {
path: '/messages.EventStream/StreamEvents',
requestStream: false,
responseStream: true,
requestType: gows_pb.Session,
requestType: gows_pb.StreamEventsRequest,
responseType: gows_pb.EventJson,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
requestSerialize: serialize_messages_StreamEventsRequest,
requestDeserialize: deserialize_messages_StreamEventsRequest,
responseSerialize: serialize_messages_EventJson,
responseDeserialize: deserialize_messages_EventJson,
},
File diff suppressed because it is too large. Load diff
+69 -24
View File
@@ -65,6 +65,8 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
MessageSortField,
OverviewFilter,
ReadChatMessagesQuery,
@@ -124,7 +126,11 @@ import {
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
import {
MeInfo,
ProxyConfig,
SessionConfig,
} from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
@@ -174,8 +180,8 @@ import {
isLabelUpsertEvent,
} from './labels.gows';
import {
GetEventStreamClient,
GetMessageServiceClient,
BuildEventStreamClient,
BuildMessageServiceClient,
} from '@waha/core/engines/gows/clients';
import esm from '@waha/vendor/esm';
import { IsEditedMessage } from '@waha/core/utils/pwa';
@@ -188,6 +194,19 @@ import * as path from 'path';
import MessageServiceClient = messages.MessageServiceClient;
import * as fsp from 'fs/promises';
function getGowsStorageConfig(
sessionConfig?: SessionConfig,
): messages.SessionStorageConfig {
const storeConfig = sessionConfig?.gows?.storage;
return new messages.SessionStorageConfig({
// Only explicit false disables; undefined/null defaults to enabled.
messages: storeConfig?.messages !== false,
groups: storeConfig?.groups !== false,
chats: storeConfig?.chats !== false,
labels: storeConfig?.labels !== false,
});
}
enum WhatsMeowEvent {
CONNECTED = 'gows.ConnectedEventData',
DISCONNECTED = 'events.Disconnected',
@@ -216,6 +235,12 @@ enum WhatsMeowEvent {
CALL_REJECT = 'events.CallReject',
CALL_TERMINATE = 'events.CallTerminate',
CALL_OFFER_NOTICE = 'events.CallOfferNotice',
// Other
AppState = 'events.AppState',
AppStateSyncComplete = 'AppStateSyncComplete',
AppStateSyncError = 'AppStateSyncError',
HistorySync = 'events.HistorySync',
Contact = 'events.Contact',
}
export interface GowsConfig {
@@ -267,6 +292,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
address: auth.address(),
dialect: auth.dialect(),
}),
storage: getGowsStorageConfig(this.sessionConfig),
log: new messages.SessionLogConfig({
level: level ?? messages.LogLevel.INFO,
}),
@@ -282,20 +308,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}),
});
this.client = GetMessageServiceClient(
this.name,
this.client = BuildMessageServiceClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
);
try {
await promisify(this.client.StartSession)(request);
} catch (err) {
promisify(this.client.StartSession)(request).catch((err) => {
this.logger.error('Failed to start the client');
this.logger.error(err, err.stack);
this.status = WAHASessionStatus.FAILED;
throw err;
}
});
}
protected getProxyUrl(config: ProxyConfig): string {
@@ -312,12 +335,25 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.stream$ = new GowsEventStreamObservable(
this.loggerBuilder.child({ grpc: 'stream' }),
() => {
const client = GetEventStreamClient(
this.name,
const client = BuildEventStreamClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
);
const stream = client.StreamEvents(this.session);
// Avoid having a lot of events after pairing the device
// https://github.com/devlikeapro/waha/issues/1826
// TODO: we need to make it more dynamic
const exclude = [
WhatsMeowEvent.AppState,
WhatsMeowEvent.AppStateSyncComplete,
WhatsMeowEvent.AppStateSyncError,
WhatsMeowEvent.HistorySync,
WhatsMeowEvent.Contact,
];
const request = new messages.StreamEventsRequest({
session: this.session,
exclude: exclude,
});
const stream = client.StreamEvents(request);
return { client, stream };
},
);
@@ -766,15 +802,14 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
async stop(): Promise<void> {
this.cleanupPresenceTimeout();
if (this.client) {
const response = await promisify(this.client.StopSession)(this.session);
response.toObject();
}
this.status = WAHASessionStatus.STOPPED;
this.events?.stop();
this.stopEvents();
this.client?.close();
this.mediaManager.close();
if (this.client) {
await promisify(this.client.StopSession)(this.session);
this.client?.close();
}
}
public async requestCode(phoneNumber: string, method: string, params?: any) {
@@ -881,6 +916,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return data.id;
}
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const request = new messages.RejectCallRequest({
session: this.session,
@@ -1044,6 +1080,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
@Activity()
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -1822,24 +1859,27 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
pagination.sortOrder = SortOrder.DESC;
}
const chats = await this.getChats(pagination, filter);
const merge = (pagination as GetChatsOverviewParams).merge ?? true;
const promises = [];
for (const chat of chats) {
promises.push(this.fetchChatSummary(chat));
promises.push(this.fetchChatSummary(chat, merge));
}
const result = await Promise.all(promises);
return result;
}
protected async fetchChatSummary(chat): Promise<ChatSummary> {
protected async fetchChatSummary(chat, merge: boolean): Promise<ChatSummary> {
const id = toCusFormat(chat.id);
const name = chat.name;
const picture = await this.getContactProfilePicture(chat.id, false);
const messages = await this.getChatMessages(
chat.id,
{ limit: 1, offset: 0, downloadMedia: false },
{},
);
const lastMessageQuery: GetChatMessagesQuery = {
limit: 1,
offset: 0,
downloadMedia: false,
merge,
};
const messages = await this.getChatMessages(chat.id, lastMessageQuery, {});
const message = messages.length > 0 ? messages[0] : null;
return {
id: id,
@@ -1864,6 +1904,8 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
pagination: PaginationParams,
filter: OverviewFilter | null = null,
) {
const merge =
(pagination as GetChatsParams | GetChatsOverviewParams).merge ?? true;
if (pagination.sortBy === ChatSortField.CONVERSATION_TIMESTAMP) {
pagination.sortBy = 'timestamp';
}
@@ -1887,6 +1929,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
filter: new messages.ChatFilter({
jids: jids,
}),
merge: optional(merge, messages.OptionalBool),
});
const response = await promisify(this.client.GetChats)(request);
const data = parseJsonList(response);
@@ -1899,6 +1942,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
filter: GetChatMessagesFilter,
) {
const downloadMedia = query.downloadMedia;
const merge = query.merge ?? true;
let jid: messages.OptionalString;
if (chatId === 'all') {
jid = null;
@@ -1936,6 +1980,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
limit: query.limit,
offset: query.offset,
}),
merge: optional(merge, messages.OptionalBool),
});
const response = await promisify(this.client.GetMessages)(request);
const msgs = parseJsonList(response);
+17 -3
View File
@@ -64,10 +64,19 @@ export function ToGroupParticipant(
interface GroupParticipantUpdate {
id: string;
author: string;
participants: string[];
participants: Array<string | NOWEBGroupParticipant>;
action: ParticipantAction;
}
function getParticipantId(
participant: string | NOWEBGroupParticipant,
): string | undefined {
if (typeof participant === 'string') {
return participant;
}
return participant?.id;
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
@@ -92,7 +101,8 @@ export function ToGroupV2Participants(
break;
}
const participants: GroupParticipant[] = update.participants.map((id) => {
const participants: GroupParticipant[] = update.participants.map((item) => {
const id = getParticipantId(item);
return {
id: toCusFormat(id),
role: role,
@@ -131,7 +141,11 @@ export function ToGroupV2LeaveEvent(
return null;
}
const meId = esm.b.jidNormalizedUser(me.id);
if (!update.participants.includes(meId)) {
const includesMe = update.participants.some((participant) => {
const id = getParticipantId(participant);
return id === meId;
});
if (!includesMe) {
return null;
}
+85 -19
View File
@@ -91,6 +91,8 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
OverviewFilter,
PinDuration,
ReadChatMessagesQuery,
@@ -321,9 +323,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
// Detect browser
let browser = ['Ubuntu', 'Chrome', '22.04.4'] as WABrowserDescription;
let deviceName =
this.sessionConfig.client?.deviceName ?? WAHA_CLIENT_DEVICE_NAME;
this.sessionConfig?.client?.deviceName ?? WAHA_CLIENT_DEVICE_NAME;
let browserName =
this.sessionConfig.client?.browserName ?? WAHA_CLIENT_BROWSER_NAME;
this.sessionConfig?.client?.browserName ?? WAHA_CLIENT_BROWSER_NAME;
if (browserName && !deviceName) {
browser = Browsers.appropriate(browserName);
} else if (!browserName && deviceName) {
@@ -355,10 +357,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
return {
agent: agents?.socket,
// Baileys types still expect a Node https.Agent here,
// but 'undici' fetch requires a Dispatcher.
// Cast keeps the compiler satisfied while we pass the ProxyAgent at runtime.
fetchAgent: agents?.fetch as unknown as Agent,
// Baileys media upload uses Node https.request in Node runtime.
fetchAgent: agents?.fetch as Agent,
auth: state,
printQRInTerminal: false,
browser: browser,
@@ -908,6 +908,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Other methods
*/
@Activity()
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -927,6 +928,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return this.generateMessageID();
}
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const jid = toJID(this.ensureSuffix(from));
await this.sock.rejectCall(id, jid);
@@ -956,24 +958,63 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
@Activity()
public editMessage(
public async editMessage(
chatId: string,
messageId: string,
request: EditMessageRequest,
) {
const jid = toJID(this.ensureSuffix(chatId));
const key = parseMessageIdSerialized(messageId);
const message = {
const stored = await this.store
?.loadMessage(key.remoteJid, key.id)
.catch(() => null);
const content = extractMessageContent(stored?.message);
let editedMessage = undefined;
if (content?.imageMessage) {
editedMessage = {
imageMessage: {
caption: request.text,
},
};
} else if (content?.videoMessage) {
editedMessage = {
videoMessage: {
caption: request.text,
},
};
} else if (content?.documentMessage) {
editedMessage = {
documentMessage: {
caption: request.text,
},
};
} else if (content?.documentWithCaptionMessage?.message?.documentMessage) {
editedMessage = {
documentWithCaptionMessage: {
message: {
documentMessage: {
caption: request.text,
},
},
},
};
}
let message: any = {
text: request.text,
mentions: request.mentions?.map(toJID),
edit: key,
editedMessage: editedMessage,
linkPreview: this.getLinkPreview(request),
linkPreviewHighQuality: request.linkPreviewHighQuality,
};
const options = {
messageId: this.generateMessageID(),
};
return this.sock.sendMessage(jid, message, options);
if (isJidNewsletter(jid)) {
// Newsletter edits reuse the original message ID
options.messageId = key.id;
}
return await this.sock.sendMessage(jid, message, options);
}
@Activity()
@@ -1138,10 +1179,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
) {
const downloadMedia = query.downloadMedia;
const pagination = query as PaginationParams;
const merge = query.merge ?? true;
const messages = await this.store.getMessagesByJid(
toJID(chatId),
filter,
pagination,
merge,
);
const promises = [];
@@ -1167,7 +1210,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
query: GetChatMessageQuery,
): Promise<null | WAMessage> {
const key = parseMessageIdSerialized(messageId, true);
const message = await this.store.getMessageById(toJID(chatId), key.id);
const merge = query.merge ?? true;
const message = await this.store.getMessageById(
toJID(chatId),
key.id,
merge,
);
if (!message) return null;
return await this.processIncomingMessage(message, query.downloadMedia);
}
@@ -1254,7 +1302,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
*/
async getChats(pagination: PaginationParams) {
const chats = await this.store.getChats(pagination, true);
const merge = (pagination as GetChatsParams).merge ?? true;
const chats = await this.store.getChats(pagination, true, undefined, merge);
// Remove unreadCount, it's not ready yet
chats.forEach((chat) => delete chat.unreadCount);
return chats;
@@ -1264,6 +1313,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
const merge = (pagination as GetChatsOverviewParams).merge ?? true;
// Convert customer format IDs to JID format if filter is provided
let jidFilter;
if (filter?.ids && filter.ids.length > 0) {
@@ -1272,19 +1322,27 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
};
}
const chats = await this.store.getChats(pagination, false, jidFilter);
const chats = await this.store.getChats(
pagination,
false,
jidFilter,
merge,
);
// Remove unreadCount, it's not ready yet
chats.forEach((chat) => delete chat.unreadCount);
const promises = [];
for (const chat of chats) {
promises.push(this.fetchChatSummary(chat));
promises.push(this.fetchChatSummary(chat, merge));
}
const result = await Promise.all(promises);
return result;
}
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
protected async fetchChatSummary(
chat: Chat,
merge: boolean,
): Promise<ChatSummary> {
const id = toCusFormat(chat.id);
let name = chat.name;
if (!name) {
@@ -1294,11 +1352,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
name = contact?.name || contact?.notify;
}
const picture = await this.getContactProfilePicture(chat.id, false);
const messages = await this.getChatMessages(
chat.id,
{ limit: 1, offset: 0, downloadMedia: false },
{},
);
const lastMessageQuery: GetChatMessagesQuery = {
limit: 1,
offset: 0,
downloadMedia: false,
merge: merge,
};
const messages = await this.getChatMessages(chat.id, lastMessageQuery, {});
const message = messages.length > 0 ? messages[0] : null;
return {
id: id,
@@ -2910,6 +2970,12 @@ export function extractBody(message): string | null {
// @ts-ignore - AudioMessage doesn't have caption field
body = mediaContent?.caption;
}
if (!body && content.protocolMessage?.editedMessage) {
body = extractBody(content.protocolMessage.editedMessage);
}
if (!body && content.associatedChildMessage?.message) {
body = extractBody(content.associatedChildMessage.message);
}
// Response for buttons
if (!body) {
body = content.templateButtonReplyMessage?.selectedDisplayText;
@@ -11,6 +11,7 @@ export interface IChatRepository {
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]>;
getById(id: string): Promise<Chat | null>;
@@ -14,9 +14,10 @@ export interface IMessagesRepository {
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]>;
getByJidById(jid: string, id: string): Promise<any | null>;
getByJidById(jid: string, id: string, merge?: boolean): Promise<any | null>;
updateByJidAndId(jid: string, id: string, update: any): Promise<boolean>;
+7 -1
View File
@@ -31,14 +31,20 @@ export interface INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any>;
getMessageById(chatId: string, messageId: string): Promise<any>;
getMessageById(
chatId: string,
messageId: string,
merge?: boolean,
): Promise<any>;
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]>;
getChat(jid: string): Promise<Chat | null>;
@@ -2,7 +2,10 @@ import type { Chat, Contact, GroupMetadata, proto } from '@adiwajshing/baileys';
import type makeWASocket from '@adiwajshing/baileys';
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { BadRequestException } from '@nestjs/common';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import {
GetChatMessagesFilter,
OverviewFilter,
} from '@waha/structures/chats.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import {
LimitOffsetParams,
@@ -57,15 +60,25 @@ export class NowebInMemoryStore implements INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any> {
throw new BadRequestException(this.errorMessage);
}
getMessageById(chatId: string, messageId: string): Promise<any> {
getMessageById(
chatId: string,
messageId: string,
merge?: boolean,
): Promise<any> {
throw new BadRequestException(this.errorMessage);
}
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]> {
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
throw new BadRequestException(this.errorMessage);
}
@@ -44,6 +44,7 @@ import { INowebStorage } from './INowebStorage';
import { INowebStore } from './INowebStore';
import { LabelAssociationType } from '../labels/LabelAssociationType';
import esm from '@waha/vendor/esm';
import { StatusStringToStatus } from '@waha/core/utils/acks';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -130,7 +131,7 @@ export class NowebPersistentStore implements INowebStore {
return {
id: message.key.remoteJid,
lid: lid,
jid: pn,
phoneNumber: pn,
};
})
.filter(Boolean);
@@ -249,6 +250,9 @@ export class NowebPersistentStore implements INowebStore {
private async syncMessagesHistory(messages) {
const realMessages = messages.filter(esm.b.isRealMessage);
messages = messages.filter((msg) => this.jids.include(msg.key.remoteJid));
for (const message of messages) {
message.status = StatusStringToStatus(message.status);
}
await this.messagesRepo.upsert(realMessages);
this.logger.info(
`history sync - '${messages.length}' got messages, '${realMessages.length}' real messages`,
@@ -288,7 +292,11 @@ export class NowebPersistentStore implements INowebStore {
);
continue;
}
const message = await this.messagesRepo.getByJidById(jid, update.key.id);
let message = await this.messagesRepo.getByJidById(jid, update.key.id);
if (!message) {
// Fallback to just id
message = await this.messagesRepo.getById(update.key.id);
}
if (!message) {
this.logger.warn(
`got update for non-existent message. update: '${JSON.stringify(
@@ -599,24 +607,35 @@ export class NowebPersistentStore implements INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge: boolean = true,
): Promise<any> {
pagination.sortBy = 'messageTimestamp';
pagination.sortOrder = pagination.sortOrder || SortOrder.DESC;
return this.messagesRepo.getAllByJid(chatId, filter, pagination);
return this.messagesRepo.getAllByJid(chatId, filter, pagination, merge);
}
getMessageById(chatId: string, messageId: string): Promise<any> {
return this.messagesRepo.getByJidById(chatId, messageId);
getMessageById(
chatId: string,
messageId: string,
merge: boolean = true,
): Promise<any> {
return this.messagesRepo.getByJidById(chatId, messageId, merge);
}
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge: boolean = true,
): Promise<Chat[]> {
pagination.sortBy ||= 'conversationTimestamp';
pagination.sortOrder ||= SortOrder.DESC;
return this.chatRepo.getAllWithMessages(pagination, broadcast, filter);
return this.chatRepo.getAllWithMessages(
pagination,
broadcast,
filter,
merge,
);
}
async getChat(jid: string): Promise<Chat | null> {
@@ -2,6 +2,7 @@ import type { Chat } from '@adiwajshing/baileys';
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { Knex } from 'knex';
export class SqlChatMethods {
constructor(private repository: SqlKVRepository<any>) {}
@@ -10,23 +11,112 @@ export class SqlChatMethods {
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge: boolean = true,
): Promise<Chat[]> {
// Get chats with conversationTimestamp is not Null
let query = this.repository.select().whereNotNull('conversationTimestamp');
const knex = this.repository.getKnex();
const tableName = this.repository.table;
let baseQuery = this.repository
.select()
.whereNotNull(`${tableName}.conversationTimestamp`);
if (!broadcast) {
// filter out chat by id if it ends at @newsletter or @broadcast
query = query
.andWhereNot('id', 'like', '%@broadcast')
.andWhereNot('id', 'like', '%@newsletter');
if (!merge) {
baseQuery = this.applyNonMergedFilters(baseQuery, tableName, {
broadcast,
filter,
});
const pagedQuery = this.repository.pagination(baseQuery, pagination);
const rows = await pagedQuery;
return rows.map((row) => this.repository.parse(row));
}
// Filter by IDs if provided
if (filter?.ids && filter.ids.length > 0) {
query = query.whereIn('id', filter.ids);
const annotatedQuery = this.annotateWithPnJid(knex, baseQuery, {
tableName,
broadcast,
filter,
}).as('annotated_chats');
const dedupedQuery = knex
.select('*')
.from(
knex
.select(
'annotated_chats.*',
knex.raw(
'ROW_NUMBER() OVER (PARTITION BY annotated_chats.primary_jid ORDER BY annotated_chats."conversationTimestamp" DESC, annotated_chats.primary_priority ASC) as __rownum',
),
)
.from(annotatedQuery)
.as('ranked_chats'),
)
.where('__rownum', 1);
const pagedQuery = this.repository.pagination(dedupedQuery, pagination);
const rows = await pagedQuery;
return rows.map((row) => {
const chat = this.repository.parse(row);
if (merge && row.primary_jid) {
chat.id = row.primary_jid;
}
return chat;
});
}
private annotateWithPnJid(
knex: Knex,
query: Knex.QueryBuilder,
opts: {
tableName: string;
broadcast: boolean;
filter?: OverviewFilter;
},
) {
const pnExpr = this.buildPnJidExpr(opts.tableName);
let annotated = query
.leftJoin('lid_map', 'lid_map.id', `${opts.tableName}.id`)
.select(
knex.raw(`${pnExpr} as primary_jid`),
knex.raw(
`CASE WHEN ${opts.tableName}.id LIKE '%@lid' THEN 1 ELSE 0 END as primary_priority`,
),
);
if (!opts.broadcast) {
annotated = annotated
.andWhereNot(`${opts.tableName}.id`, 'like', '%@broadcast')
.andWhereNot(`${opts.tableName}.id`, 'like', '%@newsletter');
}
query = this.repository.pagination(query, pagination);
return await this.repository.all(query);
if (opts.filter?.ids && opts.filter.ids.length > 0) {
annotated = annotated.andWhere((builder) => {
builder
.whereIn(`${opts.tableName}.id`, opts.filter.ids)
.orWhereIn('lid_map.pn', opts.filter.ids);
});
}
return annotated;
}
private buildPnJidExpr(tableName: string) {
const column = `"${tableName}"."id"`;
return `CASE WHEN ${column} LIKE '%@lid' THEN COALESCE(lid_map.pn, ${column}) ELSE ${column} END`;
}
private applyNonMergedFilters(
query: Knex.QueryBuilder,
tableName: string,
opts: { broadcast: boolean; filter?: OverviewFilter },
) {
let filtered = query;
if (!opts.broadcast) {
filtered = filtered
.andWhereNot(`${tableName}.id`, 'like', '%@broadcast')
.andWhereNot(`${tableName}.id`, 'like', '%@newsletter');
}
if (opts.filter?.ids && opts.filter.ids.length > 0) {
filtered = filtered.whereIn(`${tableName}.id`, opts.filter.ids);
}
return filtered;
}
}
@@ -1,11 +1,17 @@
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { AckToStatus } from '@waha/core/utils/acks';
import { isLidUser } from '@waha/core/utils/jids';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { Knex } from 'knex';
import { INowebLidPNRepository } from '../INowebLidPNRepository';
export class SqlMessagesMethods {
constructor(private repository: SqlKVRepository<any>) {}
constructor(
private repository: SqlKVRepository<any>,
private lidRepository?: INowebLidPNRepository,
) {}
upsert(messages: any[]): Promise<void> {
return this.repository.upsertMany(messages);
@@ -15,10 +21,17 @@ export class SqlMessagesMethods {
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge: boolean = true,
): Promise<any[]> {
let query = this.repository.select();
const tableName = this.repository.table;
if (jid !== ALL_JID) {
query = this.repository.select().where({ jid: jid });
if (merge) {
const pnJid = await this.resolvePnJid(jid);
query = this.applyPnJidFilter(query, pnJid);
} else {
query = query.where(`${tableName}.jid`, jid);
}
}
if (filter['filter.timestamp.lte'] != null) {
query = query.where(
@@ -34,10 +47,11 @@ export class SqlMessagesMethods {
filter['filter.timestamp.gte'],
);
}
const dataColumn = `${this.repository.table}.data`;
if (filter['filter.fromMe'] != null) {
// filter by data json inside
const [sql, value] = this.repository.filterJson(
'data',
dataColumn,
'key.fromMe',
filter['filter.fromMe'],
);
@@ -45,18 +59,39 @@ export class SqlMessagesMethods {
}
if (filter['filter.ack'] != null) {
const status = AckToStatus(filter['filter.ack']);
const [sql, value] = this.repository.filterJson('data', 'status', status);
const [sql, value] = this.repository.filterJson(
dataColumn,
'status',
status,
);
query = query.whereRaw(sql, [value]);
}
query = this.repository.pagination(query, pagination);
return this.repository.all(query);
}
async getByJidById(jid: string, id: string): Promise<any> {
async getByJidById(
jid: string,
id: string,
merge: boolean = true,
): Promise<any> {
if (jid === ALL_JID) {
return this.repository.getBy({ id: id });
}
return this.repository.getBy({ jid: jid, id: id });
const tableName = this.repository.table;
const baseQuery = this.repository.select().where(`${tableName}.id`, id);
let query;
if (merge) {
const pnJid = await this.resolvePnJid(jid);
query = this.applyPnJidFilter(baseQuery, pnJid);
} else {
query = baseQuery.andWhere(`${tableName}.jid`, jid);
}
const rows = await query.limit(1);
if (!rows.length) {
return null;
}
return this.repository.parse(rows[0]);
}
async updateByJidAndId(
@@ -83,4 +118,39 @@ export class SqlMessagesMethods {
deleteAllByJid(jid: string): Promise<void> {
return this.repository.deleteBy({ jid: jid });
}
private applyPnJidFilter(
query: Knex.QueryBuilder,
pnJid: string,
): Knex.QueryBuilder {
const tableName = this.repository.table;
const pnExpr = this.buildPnJidExpr(tableName, 'jid');
return query
.leftJoin('lid_map', 'lid_map.id', `${tableName}.jid`)
.whereRaw(`${pnExpr} = ?`, [pnJid]);
}
private async resolvePnJid(jid: string): Promise<string> {
if (!isLidUser(jid)) {
return jid;
}
if (this.lidRepository) {
const mapped = await this.lidRepository.findPNByLid(jid);
if (mapped) {
return mapped;
}
}
const row = await this.repository
.getKnex()
.select('pn')
.from('lid_map')
.where('id', jid)
.first();
return row?.pn || jid;
}
private buildPnJidExpr(tableName: string, column: string) {
const colRef = `"${tableName}"."${column}"`;
return `CASE WHEN ${colRef} LIKE '%@lid' THEN COALESCE(lid_map.pn, ${colRef}) ELSE ${colRef} END`;
}
}
@@ -16,7 +16,7 @@ export class NOWEBSqlite3KVRepository<
return JSON.stringify(data, esm.b.BufferJSON.replacer);
}
protected parse(row: any): any {
public parse(row: any): any {
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
}
@@ -30,7 +30,13 @@ export class Sqlite3ChatRepository
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
return this.methods.getAllWithMessages(pagination, broadcast, filter);
return this.methods.getAllWithMessages(
pagination,
broadcast,
filter,
merge,
);
}
}
@@ -6,17 +6,26 @@ import { PaginationParams } from '@waha/structures/pagination.dto';
import { IMessagesRepository } from '../IMessagesRepository';
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
import { INowebLidPNRepository } from '../INowebLidPNRepository';
import Knex from 'knex';
export class Sqlite3MessagesRepository
extends NOWEBSqlite3KVRepository<any>
implements IMessagesRepository
{
constructor(
knex: Knex.Knex,
private readonly lidRepository: INowebLidPNRepository,
) {
super(knex);
}
get schema() {
return NowebMessagesSchema;
}
get methods() {
return new SqlMessagesMethods(this);
return new SqlMessagesMethods(this, this.lidRepository);
}
get metadata() {
@@ -31,12 +40,13 @@ export class Sqlite3MessagesRepository
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]> {
return this.methods.getAllByJid(jid, filter, pagination);
return this.methods.getAllByJid(jid, filter, pagination, merge);
}
async getByJidById(jid: string, id: string): Promise<any> {
return this.methods.getByJidById(jid, id);
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
return this.methods.getByJidById(jid, id, merge);
}
async updateByJidAndId(
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more