Compare commits

...
157 Commits
Author SHA1 Message Date
devlikepro fbc58b0707 [core] 2025.6.6
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-06-27 11:09:33 +07:00
devlikepro d5a525789e [core] NOWEB - show poll on the device
fix #988
2025-06-27 11:09:32 +07:00
devlikepro 34066921e1 [core] run xvfb only for WEBJS engine 2025-06-27 11:09:32 +07:00
devlikepro 901c594311 [core] WEBJS - fix pin message
fix #1081
2025-06-27 11:09:32 +07:00
devlikepro 5f619e608b [core] WEBJS - return response for a message
fix #1083
2025-06-27 11:09:31 +07:00
devlikepro 49295090ad [core] Use 501 Not Implemented HTTP status for NotImplemented by engine error
fix #1047
2025-06-27 11:09:31 +07:00
devlikepro a6e43adb0a [core] use xvfb-run 2025-06-27 11:09:30 +07:00
devlikepro 1f165bb6cb [core] Puppeteer 24.10.0 2025-06-27 11:09:30 +07:00
devlikepro 8fc9d88d2e [core] No security reminder, it creates a zombie process
fix #1078
2025-06-27 11:09:29 +07:00
devlikepro d7e5a5ce05 [core] Add ping check 2025-06-27 11:09:29 +07:00
devlikepro 09d446fdeb [core] Install wget for chrome 2025-06-27 11:09:28 +07:00
devlikepro c0bbd60e15 [core] Up Dashboard 2025-06-27 11:09:28 +07:00
devlikepro af3fc137be [core] fix del usage 2025-06-27 11:09:27 +07:00
devlikepro 0ee5e1bd88 [core] Up del, rimraf 2025-06-27 11:09:27 +07:00
devlikepro f92ea18651 [core] Use Debian Bookworm 2025-06-27 11:09:27 +07:00
devlikepro 3cce2bb35f [core] 2025.6.5
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-06-25 13:38:56 +07:00
devlikepro e080ce42ee [core] no dashboard username by default 2025-06-25 13:38:56 +07:00
devlikepro 5dbe41da78 [core] postgresql 127.0.0.1 2025-06-25 13:38:55 +07:00
devlikepro eef3957d4f [core] Warning about no API key 2025-06-25 13:38:55 +07:00
devlikepro 680f4596b9 [core] Up Dashboard 2025-06-25 13:38:55 +07:00
devlikepro 8de344f220 [core] Add enabled vars for dashboard and swagger 2025-06-25 13:38:54 +07:00
devlikepro 8005846955 [core] admin/admin by default for dashboard 2025-06-25 13:38:54 +07:00
devlikepro 3edd60448b [core] format .env better 2025-06-25 13:38:54 +07:00
devlikepro 4a2fa5c076 [core] message about plain key 2025-06-25 13:38:54 +07:00
devlikepro 96e9c2cc4e [core] hash key in entrypoint.sh always 2025-06-25 13:38:53 +07:00
devlikepro 2089a1bb35 [core] Handle empty key in hash case 2025-06-25 13:38:52 +07:00
devlikepro 023c2718e2 [core] Move websocket.gateway.core.ts to /api 2025-06-25 13:38:52 +07:00
devlikepro f7aacb193a [core] Add Auth methods - NoAuth, PlainApiKeyAuth, Hash 2025-06-25 13:38:52 +07:00
devlikepro e710040904 [core] Add base providers 2025-06-25 13:38:52 +07:00
devlikepro fb12826801 [core] Refactor websocket authorization - use DI 2025-06-25 13:38:51 +07:00
devlikepro 833a70c6e0 [core] Remove hot fix for closing websockets 2025-06-25 13:38:50 +07:00
devlikepro c807f9021c [core] Support WAHA_API_KEY as well as env variable
gpt often suggests this env variable, IDK why
2025-06-25 13:38:49 +07:00
devlikepro 46b66bcdf0 [core] Move built-in SSL (DEPRECATED) to WAHA Core
fix #1069
2025-06-25 13:38:48 +07:00
devlikepro 2ebade817e [core] add swagger security to WAHA Core
fix #1069
2025-06-25 13:38:48 +07:00
devlikepro 080342e619 [core] add /ws authentication
fix #1069
2025-06-25 13:38:48 +07:00
devlikepro 75b06d3ba1 [core] Copy security file to WAHA Core
fix #1069
2025-06-25 13:38:48 +07:00
devlikepro 3b6fa3343d [core] 2025.6.4
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-06-21 17:34:41 +07:00
devlikepro 469cdc9b1d [core] no xvfb if not installed (noweb/gows image) 2025-06-21 17:34:41 +07:00
devlikepro 8a179e7194 [core] NOWEB - sleep for sqlite3 in batch 2025-06-21 17:34:41 +07:00
devlikepro c133795753 [core] NOWEB - log length, not json
fix devlikeapro/waha#1054
2025-06-21 17:34:41 +07:00
devlikepro 941ace7461 [core] 2025.6.3
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-06-16 13:05:20 +07:00
devlikepro 8589420f5d [core] do not stop ws socket in gateway 2025-06-16 13:05:20 +07:00
devlikepro ece24e182f [core] WEBJS - xvfb 2025-06-16 13:05:19 +07:00
devlikepro 38e5bb7bdf [core] NOWEB - Add timeout 5s for async lock 2025-06-16 13:05:19 +07:00
devlikepro d09e931827 [core] chrome 137.0.7151.103-1 2025-06-16 13:05:19 +07:00
devlikepro 3f3f1e1c20 [core] Inline WAMessage in message.edited 2025-06-16 13:05:18 +07:00
devlikepro 32f2468ba8 [core] Add WAHAWebhookMessageEdited to webhooks list 2025-06-16 13:05:17 +07:00
devlikepro 1d6540e0d9 [core] editedMessageId 2025-06-16 13:05:17 +07:00
devlikepro 31da4bfca1 [core] WEBJS - message.edited
fix #1041
2025-06-16 13:05:17 +07:00
devlikepro 41d49894e2 [core] WEBJS - message.revoked add revokedMessageId 2025-06-16 13:05:17 +07:00
devlikepro 6dcdd15b1c [core] message.revoked - add revokedMessageId - NOWEB, GOWS 2025-06-16 13:05:17 +07:00
devlikepro c2b275fd90 [core] NOWEB - message.edited 2025-06-16 13:05:16 +07:00
devlikepro e4838ef3ef [core] GOWS - message.edited
fix #916
2025-06-16 13:05:16 +07:00
devlikepro 55ca4660d7 [core] GOWS - message.revoked
fix #917
2025-06-16 13:05:15 +07:00
devlikepro c16c903ec4 [core] node 22.16 2025-06-16 13:05:15 +07:00
devlikepro 937cf38ff6 [core] Up libs 2025-06-16 13:05:15 +07:00
devlikepro 07f76e854d [core] Update axios 2025-06-16 13:05:15 +07:00
devlikepro cde1d084c6 [core] NOWEB fix issue with can not read property of null 2025-06-16 13:05:14 +07:00
devlikepro 9362c5d6ae [core] 2025.6.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-06-11 15:41:56 +07:00
devlikepro 117a9108c0 [core] puppeter 24.10.0 2025-06-11 15:41:56 +07:00
devlikepro 5214e461f2 [core] chrome 137.0.7151.55 2025-06-11 15:41:56 +07:00
devlikepro 9bdb178b7d [core] Use github examples 2025-06-11 15:41:56 +07:00
devlikepro 9c526ffdba [core] Add converting voice and video files
fix #724
2025-06-11 15:41:56 +07:00
devlikepro f80a74edd1 [core] Add avi and mp3 in examples 2025-06-11 12:20:58 +07:00
devlikepro 7d8c6c0a62 [core] 2025.6.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
2025-06-09 14:01:26 +07:00
devlikepro 17b26b869b [core] Add --pprof-host listen to 0.0.0.0 if debug mode enabled 2025-06-09 14:01:26 +07:00
devlikepro dfbe54e92f [core] Up GOWS
fix #919
2025-06-09 14:01:25 +07:00
devlikepro 2321de409b [core] Up WEBJS
fix #1023
fix #1024
fix #1025
fix #1026
fix #1027
2025-06-09 14:01:25 +07:00
devlikepro 502287a258 [core] WEBJS - add exposeFunctionIfAbsent 2025-06-09 14:01:25 +07:00
devlikepro 052ff0ea6e [core] WEBJS - fork-main-2025-06-09 branch 2025-06-09 14:01:24 +07:00
devlikepro 2b76789cdf [core] Run GOWS with pprof if WAHA_DEBUG_MODE=true 2025-06-09 14:01:24 +07:00
devlikepro 7bbf5df231 [core] Update grpc 2025-06-09 14:01:24 +07:00
devlikepro 794a04e7d3 [core] Update grpc-js and grpc-tools 2025-06-09 14:01:23 +07:00
devlikepro 645467f13a [core] echo -e 2025-06-09 14:01:23 +07:00
devlikepro 7e87637341 [core] 2025.5.6
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-05-30 11:18:16 +07:00
devlikepro b7abeed54a [core] 2025.5.5
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-05-28 16:42:04 +07:00
devlikepro d53646eef2 [core] docker-compose.yaml - set logging by default 100m/10 files 2025-05-28 16:42:04 +07:00
devlikepro c54743de6c [core] Up GOWS 2025-05-28 16:42:03 +07:00
devlikepro e1fdc41132 [core] NOWEB - skip updates with no id 2025-05-28 16:42:03 +07:00
devlikepro 060aa82294 [core] WEBJS - Presence - API and Events
fix #636
2025-05-28 16:42:02 +07:00
devlikepro 2df08eac3f [core] NOWEB - hosted account pairing
fix #864
2025-05-28 16:42:02 +07:00
devlikepro 0ecdb9d3d0 [core] GOWS - Always subscribe to presence 2025-05-28 16:42:02 +07:00
devlikepro 6a7fe1bd1f [core] NOWEB - do not resubscribe to presences 2025-05-28 16:42:02 +07:00
devlikepro 87c69cb3d0 [core] NOWEB - Always subscribe to presence 2025-05-28 16:42:01 +07:00
devlikepro c654855c22 [core] Add ids filter to /overview
fix #791
2025-05-28 16:42:00 +07:00
devlikepro 11bb006801 [core] WEBJS - adjust logging on stop 2025-05-28 16:42:00 +07:00
devlikepro e5551f3f64 [core] Refactor path handling to improve security and safety.
Replaced `path.join` with `safeJoin` to validate and normalize paths, ensuring they stay within the base directory and prevent malicious access. Added `safeJoin` implementation in `utils/files.ts`, enforcing strict path validation rules. This improves robustness against path traversal vulnerabilities.
2025-05-28 16:41:59 +07:00
devlikepro 0de148005f [core] require session "name" for deprecated API 2025-05-28 16:41:58 +07:00
devlikepro 27e4ce628e [core] NOWEB - handle no content found messages: TypeError: Cannot read properties of undefined (reading 'pollUpdateMessage') 2025-05-28 16:41:58 +07:00
devlikepro 19b752bf15 [core] WEBJS - avoid "Can not get engine info" warning in logs 2025-05-28 16:41:58 +07:00
devlikepro 6528e5bba9 [core] WEBJS - hide "Fresh look" modal form
fix #987
2025-05-28 16:41:57 +07:00
devlikepro 37e1183e87 [core] Update nestjs 2025-05-28 16:41:56 +07:00
devlikepro 824512cf8e [core] add tini to Dockerfile
fix #990
2025-05-28 16:41:56 +07:00
devlikepro 82f296b17d [core] GOWS - Polls
fix devlikeapro/waha#899
2025-05-28 16:41:56 +07:00
devlikepro 8302530954 [core] 2025.5.4
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-05-23 14:47:37 +07:00
devlikepro 6c271829aa [core] Up Dashboard 2025-05-23 14:47:37 +07:00
devlikepro 3b30d339ee [core] Up GOWS 2025-05-23 14:47:37 +07:00
devlikepro c5a268bdbd [core] Up NOWEB - lid support
fix #957
2025-05-23 14:47:36 +07:00
devlikepro 8457ea8e47 [core] Find a phone number by lid - do not require @lid 2025-05-23 14:47:36 +07:00
devlikepro 72d69eeab5 [core] Add lid to phone number mapping - all engines
fix #957
2025-05-23 14:47:35 +07:00
devlikepro 31885e41af [core] Use maxExecutionTime instead of timeout in async-lock to avoid "async-lock timed out in queue" error
fix #831
2025-05-23 14:47:34 +07:00
devlikepro e63726d18b [core] GOWS - WhatsApp Event Message 2025-05-23 14:47:33 +07:00
devlikepro 3d2ab45d25 [core] GOWS - Labels support
fix #922
2025-05-23 14:47:33 +07:00
devlikepro 246478e1d8 [core] GOWS - add send vcard
fix #902
2025-05-23 14:47:32 +07:00
devlikepro 9418de3b23 [core] add reply_to to sendContactVcard 2025-05-23 14:47:32 +07:00
devlikepro c6b68cad37 [core] add types for toVcard 2025-05-23 14:47:32 +07:00
devlikepro ef36e4d3ac [core] 2025.5.3
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-05-16 18:33:10 +07:00
devlikepro ba2ddd62ad [core] NOWEB - fix poll.vote event
fix #960
2025-05-16 18:33:10 +07:00
devlikepro 6a03e1488c [core] 2025.5.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-05-16 17:42:09 +07:00
devlikepro cabd2ed562 [core] GOWS - pairing with official account
fix #864
2025-05-16 17:42:09 +07:00
devlikepro 4a9296276d [core] NOWEB - fix pairing code issue
fix #961
2025-05-16 17:42:09 +07:00
devlikepro 1fd84d824c [core] senderKeyDistributionMessage - ignore undefined messages 2025-05-16 17:42:08 +07:00
devlikepro faa55ffe2d [core] 2025.5.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
2025-05-10 15:55:42 +07:00
devlikepro d45af4191f [core] WEBJS - properly close remote auth storage 2025-05-10 15:55:41 +07:00
devlikepro 9f8dead8b8 [core] NOWEB + SQL - speedup Contacts upsert
fix #952
2025-05-10 15:55:41 +07:00
devlikepro 564037cd53 [core] NOWEB + SQL - speedup Chats upsert
fix #952
2025-05-10 15:55:40 +07:00
devlikepro 434de6292c [core] SQL - Fix inserting duplicate entities in one batch 2025-05-10 15:55:40 +07:00
devlikepro a12e6990e5 [core] WEBJS - fix sorting groups in /chats and /chats/overview
fix #915
2025-05-10 15:55:40 +07:00
devlikepro ce0c4a094b [core] WEBJS - fix groups participant management (add/remove)
fix #944
2025-05-10 15:55:39 +07:00
devlikepro e36728ec08 [core] NOWEB - use ILogger 2025-05-10 15:55:39 +07:00
devlikepro 2c1f3090a0 [core] NOWEB - fix participant for message.ack in groups 2025-05-10 15:55:39 +07:00
devlikepro 0c6d4b3581 [core] NOWEB - update status only if not old 2025-05-10 15:55:39 +07:00
devlikepro 1d5b9eb241 [core] NOWEB - DistinctAck
fix #948
2025-05-10 15:55:38 +07:00
devlikepro b0567dafe3 [core] DistinctAck function 2025-05-10 15:55:37 +07:00
devlikepro 518f78f067 [core] Add StatusToAck and AckToStatus methods 2025-05-10 15:55:37 +07:00
devlikepro 47a2c21824 [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
2025-05-05 13:08:45 +07:00
devlikepro aca5547965 [core] 2025.4.2 2025-05-05 13:08:45 +07:00
devlikepro 0e07876630 [core] Use POST in /api/chats/messages/read 2025-05-05 13:08:45 +07:00
devlikepro a43901b4a4 [core] /api/sendSeen - If no message id provided - switch to readChatMessages 2025-05-05 13:08:44 +07:00
devlikepro 6515fac16d [core] WEBJS - add message._data.receipts in group/status messages 2025-05-05 13:08:44 +07:00
devlikepro fdaa7e9f48 [core] Up Dashboard 2025-05-05 13:08:44 +07:00
devlikepro cd779850de [core] GOWS - fix missing DEVICE ack for groups 2025-05-05 13:08:44 +07:00
devlikepro ef939f4357 [core] Add "message.ack" event for groups and status
fix #495
fix #900
2025-05-05 13:08:41 +07:00
devlikepro bfb9dd7009 [core] WEBJS - Update chrome 2025-05-05 13:08:39 +07:00
devlikepro f282f14eff [core] WEBJS - Update puppeter 2025-05-05 13:08:38 +07:00
devlikepro 992b289500 [core] WEBJS - catch puppeter ProtocolError and "Promise was collected" and restart the browser
fix #244 - Promise was collected
fix #714 - Promise was collected
fix #685 - ProtocolError
fix #936 - ProtocolError
2025-05-05 13:08:37 +07:00
devlikepro 9dcdebcab2 [core] Add /api/chats/messages/read
fix #783
2025-05-05 13:08:36 +07:00
devlikepro c0b8bacab0 [core] Fix missing first message - GOWS, NOWEB 2025-05-05 13:08:35 +07:00
devlikepro f96e6420fc [core] Get message by id - filter by 'all' 2025-05-05 13:08:35 +07:00
devlikepro d7f97480cc [core] Do not send seen on empty keys 2025-05-05 13:08:35 +07:00
devlikepro 26df41b571 [core] Add isJidCus method 2025-05-05 13:08:34 +07:00
devlikepro 4ca336017d [core] GOWS - /api/sendSeen messageIds 2025-05-05 13:08:33 +07:00
devlikepro 94252ea54c [core] NOWEB - /api/sendSeen messageIds 2025-05-05 13:08:32 +07:00
devlikepro f8220fec1f [core] GOWS - send seen use messageIds 2025-05-05 13:08:32 +07:00
devlikepro a5ded396c9 [core] Add "audio-decode" to dependencies 2025-05-05 13:08:31 +07:00
devlikepro c457cf8064 [core] Add "node-cache" to dependencies 2025-05-05 13:08:30 +07:00
devlikepro 3079f90118 [core] Up NOWEB 2025-05-05 13:08:29 +07:00
devlikepro 4fbb331015 [core] Copy-paste in-memory store
Got removed from the upstream
2025-05-05 13:08:28 +07:00
devlikepro 640c0d305c [core] - Add 'filter.ack' to /messages 2025-05-05 13:08:26 +07:00
devlikepro 0b044c67bb [core] NOWEB fix ListMessages "Cannot read properties of undefined (reading 'protocolMessage')"
fix #932
2025-05-05 13:08:26 +07:00
devlikepro 7a37ba8145 [core] WEBJS - stop processing if we reach out the earliest message we need 2025-05-05 13:08:25 +07:00
devlikepro b447863d9f [core] NOWEB - update message status on /api/sendSeen API call
fix #635
2025-05-05 13:08:25 +07:00
devlikepro 6dd7e14f6f [core] NOWEB - set DEVICE message status for incoming messages if no status found 2025-05-05 13:08:24 +07:00
devlikepro b09ef98dce [core] default limit to 10 in messages 2025-05-05 13:08:24 +07:00
devlikepro ff1cf8a288 [core] gpt 2025-05-05 13:08:24 +07:00
devlikepro 603793662a [core] Wait 60 times in STARTING, approximately 2 minutes
fix #904
2025-05-05 13:08:24 +07:00
devlikepro 4cdab0dbc7 [core] admin/admin 2025-04-16 09:26:32 +07:00
113 changed files with 13993 additions and 4040 deletions

No files matched your search

+86 -170
View File
@@ -1,191 +1,107 @@
# WAHA Configuration
# WAHA - WhatsApp HTTP API
#
# https://waha.devlike.pro/docs/how-to/config/
#
# Common
#
# If you know the right URL, you can use it here.
# Use any port in WHATSAPP_API_PORT and domain in WHATSAPP_API_HOSTNAME
# https://waha.devlike.pro/docs/how-to/config/#common
# ==================
# ===== COMMON =====
# ==================
# Base URL for the API (used for webhooks, file URLs, etc.)
WAHA_BASE_URL=http://localhost:3000
#WHATSAPP_API_SCHEMA=http
#WHATSAPP_API_PORT=3000
#WHATSAPP_API_HOSTNAME=localhost
# Server configuration (if you need to customize hostname/port)
# WHATSAPP_API_SCHEMA=http
# WHATSAPP_API_PORT=3000
# WHATSAPP_API_HOSTNAME=localhost
#
# Set your timezone to see the right time on screenshots
# Find your timezone name in the list
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
#TZ=Europe/Dublin
#
# Security
#
# https://waha.devlike.pro/docs/how-to/security/
#
WHATSAPP_API_KEY=321
WAHA_DASHBOARD_USERNAME=waha
WAHA_DASHBOARD_PASSWORD=waha
#WHATSAPP_SWAGGER_USERNAME=admin
#WHATSAPP_SWAGGER_PASSWORD=admin
#
# Dashboard
#
# https://waha.devlike.pro/docs/how-to/waha-dashboard/#configuration
# Enabled by default
#WAHA_DASHBOARD_ENABLED=true
#WAHA_DASHBOARD_USERNAME=waha
#WAHA_DASHBOARD_PASSWORD=waha
#
# Logging
# https://waha.devlike.pro/docs/how-to/observability/#logging
#
# Set log format to JSON if you consume logs with log management systems
WAHA_LOG_FORMAT=JSON
WAHA_LOG_LEVEL=info
# WAHA_LOG_FORMAT=PRETTY
# If you want to see more logs, you can set the log level to debug
#WAHA_LOG_LEVEL=debug
# DEBUG=1 # shortcut for setting log level to debug
#
# Engine
# https://waha.devlike.pro/docs/how-to/engines/
#
# Choose the right engine for your needs (WEBJS by default)
WHATSAPP_DEFAULT_ENGINE=WEBJS
# Use NOWEB engine for the fastest performance
#WHATSAPP_DEFAULT_ENGINE=NOWEB
# Worker
# WAHA_WORKER_ID=waha1
#
# Sessions
# https://waha.devlike.pro/docs/how-to/config/#sessions
#
# Do not print QR codes in logs
WAHA_PRINT_QR=False
#
# OR you can specify sessions by name, if you have handful amount of sessions
#WHATSAPP_START_SESSION=session1,session2
#
# Restart all sessions when container starts
# !!! Do not use it with multiple workers against one database !!!
# WHATSAPP_RESTART_ALL_SESSIONS=True
#
# Swagger
# https://waha.devlike.pro/docs/how-to/swagger/
#
# ====================
# ===== SECURITY =====
# ====================
# "sha512:{SHA}" format - below is "admin" api key
WAHA_API_KEY=sha512:c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec
WAHA_DASHBOARD_ENABLED=True
WAHA_DASHBOARD_USERNAME=admin
WAHA_DASHBOARD_PASSWORD=admin
WHATSAPP_SWAGGER_ENABLED=True
WHATSAPP_SWAGGER_USERNAME=admin
WHATSAPP_SWAGGER_PASSWORD=admin
# Disable Swagger
#WHATSAPP_SWAGGER_ENABLED=false
# Enable advanced Swagger configuration
#WHATSAPP_SWAGGER_CONFIG_ADVANCED=true
#
# Swagger White Label customization
# https://waha.devlike.pro/docs/how-to/swagger/#white-label
#WHATSAPP_SWAGGER_TITLE=WAHA API
#
#
# PostgresSQL Configuration
# https://waha.devlike.pro/docs/how-to/storages/#sessions---postgresql
#WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
#
# Media - PostgresSQL Storage
# https://waha.devlike.pro/docs/how-to/storages/#media---postgresql
#WAHA_MEDIA_STORAGE=POSTGRESQL
#WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
#
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=WEBJS
#
# Global Proxy Settings
# https://waha.devlike.pro/docs/how-to/config/#global-proxy-configuration
#
# You can also set proxy settings for each session later when starting it
# https://waha.devlike.pro/docs/how-to/sessions/#configure-proxy
#WHATSAPP_PROXY_SERVER=proxy.example.com:3128
#WHATSAPP_PROXY_SERVER_USERNAME=user
#WHATSAPP_PROXY_SERVER_PASSWORD=pass
#
# ===================
# ===== LOGGING =====
# ===================
# Log format: JSON (for log management systems) or PRETTY (for development)
WAHA_LOG_FORMAT=JSON
#
# Media Configuration
#
# https://waha.devlike.pro/docs/how-to/config/#files
#
# Disable media (images, videos, files) download for incoming messages
#WHATSAPP_DOWNLOAD_MEDIA=false
#
# Download only specific media types
#WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
#
# Log level: info, debug, error, warn
WAHA_LOG_LEVEL=info
# Media - Local Storage
# https://waha.devlike.pro/docs/how-to/storages/#media---local
#
# Set to 0 to keep media files forever
# https://waha.devlike.pro/docs/how-to/storages/#save-media-files-between-the-container-restarts
# Don't print QR codes in logs
WAHA_PRINT_QR=False
# =========================
# ===== MEDIA STORAGE =====
# =========================
# Local storage (default)
WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=0
WHATSAPP_FILES_FOLDER=/app/.media
# Keep media files for 180 seconds (3 minutes)
#WHATSAPP_FILES_LIFETIME=180
#
# Media - S3 Storage
# https://waha.devlike.pro/docs/how-to/storages/#media---s3
#WAHA_MEDIA_STORAGE=S3
#WAHA_S3_REGION=eu-west-2
#WAHA_S3_BUCKET=waha
#WAHA_S3_ACCESS_KEY_ID=minioadmin
#WAHA_S3_SECRET_ACCESS_KEY=minioadmin
#WAHA_S3_ENDPOINT=http://minio:9000 # Not required if you're using AWS S3
#WAHA_S3_FORCE_PATH_STYLE=True # Required for Minio
#WAHA_S3_PROXY_FILES=True # Required for docker-compose setup
# Media download settings
# WHATSAPP_DOWNLOAD_MEDIA=true
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
#
# Global Webhooks
#
# https://waha.devlike.pro/docs/how-to/config/#webhooks
# https://waha.devlike.pro/docs/how-to/webhooks/
#WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
#WHATSAPP_HOOK_EVENTS=session.status,message,message.reaction
#
# S3 storage (uncomment to use)
# WAHA_MEDIA_STORAGE=S3
# WAHA_S3_REGION=eu-west-2
# WAHA_S3_BUCKET=waha
# WAHA_S3_ACCESS_KEY_ID=minioadmin
# WAHA_S3_SECRET_ACCESS_KEY=minioadmin
# WAHA_S3_ENDPOINT=http://minio:9000
# WAHA_S3_FORCE_PATH_STYLE=True
# WAHA_S3_PROXY_FILES=True
#
# Local Configuration
#
# WAHA_LOCAL_STORE_BASE_DIR=/app/sessions
# Remember to map the volume to the host machine to the right direction in "volumes" field in docker-compose.yml
# volumes:
# - './.sessions:/app/sessions'
# PostgreSQL storage (uncomment to use)
# WAHA_MEDIA_STORAGE=POSTGRESQL
# WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
# ===========================
# ===== SESSION STORAGE =====
# ===========================
# PostgreSQL for sessions (uncomment to use)
# WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
# MongoDB for sessions (uncomment to use)
# WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
#
# MongoDB Configuration
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
#WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
#
# ==================================
# ===== ADVANCED CONFIGURATION =====
# ==================================
#
# HTTPS Configuration
# https://waha.devlike.pro/docs/how-to/config/#https
#
# Consider using certbot for HTTPS
# https://waha.devlike.pro/blog/setting-up-https-for-waha/#lets-encrypt-certbot--waha
#
#WAHA_HTTPS_ENABLED=true
#WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
#WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem \
#WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
#WHATSAPP_API_SCHEMA=https
#WHATSAPP_API_PORT=3000
#WHATSAPP_API_HOSTNAME=waha.example.pro
#
# Timezone for screenshots and logs
# TZ=Europe/Dublin
# Session management
# WHATSAPP_START_SESSION=session1,session2
# WHATSAPP_RESTART_ALL_SESSIONS=False
# Webhooks
# WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
# WHATSAPP_HOOK_EVENTS=session.status,message,message.reaction
# Proxy configuration
# WHATSAPP_PROXY_SERVER=proxy.example.com:3128
# WHATSAPP_PROXY_SERVER_USERNAME=user
# WHATSAPP_PROXY_SERVER_PASSWORD=pass
# HTTPS configuration
# !DEPRECATED!
# Setup nginx reverse proxy to handle TLS connection
# using Let's encrypt or self-issued certificate
# WAHA_HTTPS_ENABLED=true
# WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
# WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem
# WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
+1 -1
View File
@@ -1 +1 @@
v22.8
v22.16
+48 -6
View File
@@ -1,8 +1,10 @@
ARG NODE_IMAGE_TAG=22.16-bookworm-slim
ARG GOLANG_IMAGE_TAG=1.23-bookworm
#
# Build
#
ARG NODE_VERSION=22.8-bullseye
FROM node:${NODE_VERSION} AS build
FROM node:${NODE_IMAGE_TAG} AS build
ENV PUPPETEER_SKIP_DOWNLOAD=True
# npm packages
@@ -10,6 +12,10 @@ WORKDIR /git
COPY package.json .
COPY yarn.lock .
ENV YARN_CHECKSUM_BEHAVIOR=update
# git
RUN apt-get update && apt-get install -y git
RUN npm install -g corepack && corepack enable
RUN yarn set version 3.6.3
RUN yarn install
@@ -23,11 +29,14 @@ RUN yarn build && find ./dist -name "*.d.ts" -delete
#
# Dashboard
#
FROM node:${NODE_VERSION} AS dashboard
FROM node:${NODE_IMAGE_TAG} AS dashboard
# jq to parse json
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
# wget, unzip
RUN apt-get update && apt-get install -y wget unzip && rm -rf /var/lib/apt/lists/*
COPY waha.config.json /tmp/waha.config.json
RUN \
WAHA_DASHBOARD_GITHUB_REPO=$(jq -r '.waha.dashboard.repo' /tmp/waha.config.json) && \
@@ -42,7 +51,7 @@ RUN \
#
# GOWS
#
FROM golang:1.23-bullseye AS gows
FROM golang:${GOLANG_IMAGE_TAG} AS gows
# jq to parse json
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
@@ -73,7 +82,7 @@ RUN \
#
# Final
#
FROM node:${NODE_VERSION} AS release
FROM node:${NODE_IMAGE_TAG} AS release
ENV PUPPETEER_SKIP_DOWNLOAD=True
# Quick fix for memory potential memory leaks
# https://github.com/devlikeapro/waha/issues/347
@@ -98,6 +107,13 @@ RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Install wget - either for chromium or chrome
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Install fonts if using either chromium or chrome
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
apt-get update \
@@ -119,6 +135,22 @@ RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Install xvfb, xauth
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
apt-get update && apt-get install -y --no-install-recommends \
xvfb \
xauth \
libnss3 \
libxss1 \
libasound2 \
libatk-bridge2.0-0 \
libgtk-3-0 \
libdrm2 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Install Chromium
RUN if [ "$USE_BROWSER" = "chromium" ]; then \
apt-get update \
@@ -131,7 +163,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Install Chrome
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="130.0.6723.69-1"
ARG CHROME_VERSION="137.0.7151.103-1"
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
&& apt-get update \
@@ -146,6 +178,9 @@ RUN apt-get update \
&& apt-get install -y libc6 \
&& rm -rf /var/lib/apt/lists/*
# Install tini for proper init process
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
# Set the ENV for docker image
ENV WHATSAPP_DEFAULT_ENGINE=$WHATSAPP_DEFAULT_ENGINE
@@ -161,6 +196,11 @@ ENV WAHA_GOWS_SOCKET /tmp/gows.sock
COPY entrypoint.sh /entrypoint.sh
# Add healthcheck
COPY ping-check.sh /ping-check.sh
RUN chmod +x /ping-check.sh
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=30s CMD ["/bin/bash", "/ping-check.sh"]
# Chokidar options to monitor file changes
ENV CHOKIDAR_USEPOLLING=1
ENV CHOKIDAR_INTERVAL=5000
@@ -170,4 +210,6 @@ ENV WAHA_ZIPPER=ZIPUNZIP
# Run command, etc
EXPOSE 3000
# Use tini as init system to handle zombie processes properly
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/entrypoint.sh"]
+2 -2
View File
@@ -35,13 +35,13 @@ for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
up-noweb:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels-6712
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-05-01
up-noweb-libsignal:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-channels
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
+1 -1
View File
@@ -146,7 +146,7 @@ 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
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
3. Run the following commands:
```bash
# Install dependencies
+22 -1
View File
@@ -8,6 +8,12 @@ services:
# WAHA Core
# image: devlikeapro/waha:latest
logging:
driver: 'json-file'
options:
max-size: '100m'
max-file: '10'
ports:
- '127.0.0.1:3000:3000/tcp'
@@ -32,13 +38,18 @@ services:
# POSTGRES_PASSWORD: postgres
# POSTGRES_DB: postgres
# ports:
# - "5432:5432"
# - "127.0.0.1:5432:5432"
# volumes:
# - pg_data:/var/lib/postgresql/data
# command:
# - postgres
# - "-c"
# - "max_connections=3000"
# logging:
# driver: "json-file"
# options:
# max-size: "100m"
# max-file: "10"
# NOTE: Only if you're using MongoDB
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
@@ -53,6 +64,11 @@ services:
# environment:
# - MONGO_INITDB_ROOT_USERNAME=mongouser
# - MONGO_INITDB_ROOT_PASSWORD=mongopassword
# logging:
# driver: "json-file"
# options:
# max-size: "100m"
# max-file: "10"
# NOTE: Only if you're using S3 to save media files
# https://waha.devlike.pro/docs/how-to/storages/#media---s3
@@ -71,6 +87,11 @@ services:
# volumes:
# - minio_data:/data
# command: server /data --console-address ":9001"
# logging:
# driver: "json-file"
# options:
# max-size: "100m"
# max-file: "10"
volumes:
mongodb_data: {}
+3 -3
View File
@@ -10,9 +10,9 @@ services:
- './.sessions:/app/.sessions'
- './.media:/app/.media'
environment:
- WHATSAPP_API_KEY=321
- WAHA_DASHBOARD_USERNAME=waha
- WAHA_DASHBOARD_PASSWORD=waha
- WAHA_API_KEY=321
- WAHA_DASHBOARD_USERNAME=admin
- WAHA_DASHBOARD_PASSWORD=admin
- WHATSAPP_DEFAULT_ENGINE=WEBJS
- WAHA_PRINT_QR=False
- WAHA_MEDIA_STORAGE=LOCAL
+4 -4
View File
@@ -29,15 +29,15 @@ services:
# - WHATSAPP_HOOK_EVENTS=message
# - WHATSAPP_API_HOSTNAME=localhost
- WHATSAPP_DEFAULT_ENGINE=WEBJS
- WHATSAPP_API_KEY=321
- WAHA_API_KEY=321
# Username and password for Swagger
- WHATSAPP_SWAGGER_USERNAME=swagger
- WHATSAPP_SWAGGER_PASSWORD=admin
# Username and password for WAHA Dashboard
# you can use the same username and password for both Swagger and Dashboard
# Default - waha / waha
- WAHA_DASHBOARD_USERNAME=waha
- WAHA_DASHBOARD_PASSWORD=waha
- WAHA_DASHBOARD_USERNAME=admin
- WAHA_DASHBOARD_PASSWORD=admin
# Engine specific environment variables
# - WAHA_WEBJS_WEB_VERSION=2.2412.54-videofix
restart: always
@@ -55,7 +55,7 @@ services:
# Environment variables from https://waha.devlike.pro/docs/how-to/config/
# - WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
# - WHATSAPP_HOOK_EVENTS=message
# - WHATSAPP_API_KEY=321
# - WAHA_API_KEY=321
# - WHATSAPP_SWAGGER_USERNAME=admin
# - WHATSAPP_SWAGGER_PASSWORD=123
# - WAHA_DASHBOARD_USERNAME=admin
+57 -2
View File
@@ -1,11 +1,66 @@
#!/bin/sh
#
# Calculate UV_THREADPOOL_SIZE based on number of CPUs
#
cpus=$(node -e "const os = require('os'); console.log(os.cpus().length);")
uv_threadpool_size=$(($cpus * 1))
# Set UV_THREADPOOL_SIZE as an environment variable
export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-$uv_threadpool_size}"
# Start your application using yarn
exec node dist/main
#
# Handle API key hashing
#
# Save WHATSAPP_API_KEY or WAHA_API_KEY in a variable (WHATSAPP_API_KEY has priority)
if [ -n "$WHATSAPP_API_KEY" ]; then
key="$WHATSAPP_API_KEY"
elif [ -n "$WAHA_API_KEY" ]; then
key="$WAHA_API_KEY"
fi
# Unset both environment variables
unset WHATSAPP_API_KEY
unset WAHA_API_KEY
# Process the key if it exists
if [ -n "$key" ]; then
# Check if key is already hashed
if echo "$key" | grep -q "^sha512:"; then
# If already hashed, use it as is
export WAHA_API_KEY="$key"
else
# Display warning about using plain text API key
echo "⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️"
echo "WARNING: Plain text API key detected. Converting to hashed format for security."
echo "For better security, use WAHA_API_KEY=sha512:{SHA512_HASH_FOR_YOUR_API_KEY}"
echo "⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️"
# Hash the key using sha512sum
HASHED_KEY=$(echo -n "$key" | sha512sum | awk '{print $1}')
export WAHA_API_KEY="sha512:$HASHED_KEY"
fi
fi
#
# xvfb-run
#
USE_XVFB=false
if [ -z "$WHATSAPP_DEFAULT_ENGINE" ] || [ "$WHATSAPP_DEFAULT_ENGINE" = "WEBJS" ]; then
# Try to run xvfb-run with a test command
if xvfb-run --auto-servernum echo "xvfb-run is working!"; then
USE_XVFB=true
else
echo "xvfb-run test failed, do not run it"
USE_XVFB=false
fi
fi
#
# Start your application using node with exec to ensure proper signal handling
#
if [ "$USE_XVFB" = "true" ]; then
exec xvfb-run --auto-servernum node dist/main
else
exec node dist/main
fi
Binary file not shown.
Binary file not shown.
+25 -21
View File
@@ -26,35 +26,37 @@
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels-6712",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-05-01",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
"@nestjs/axios": "^3.0.2",
"@nestjs/common": "^9.0.9",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^9.0.9",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.0.9",
"@nestjs/platform-ws": "^9.0.9",
"@nestjs/serve-static": "^2.1.3",
"@nestjs/core": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-ws": "^11.0.0",
"@nestjs/serve-static": "^5.0.0",
"@nestjs/swagger": "^7.1.11",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^9.0.9",
"@nestjs/websockets": "^11.0.0",
"@types/better-sqlite3": "^7.6.10",
"@types/lodash": "^4.14.194",
"@types/passport": "^1.0.17",
"@types/ws": "^8.5.4",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
"axios": "^1.7.7",
"audio-decode": "^2.2.2",
"axios": "^1.9.0",
"axios-retry": "^4.5.0",
"better-sqlite3": "11.3.0",
"check-disk-space": "^3.4.0",
"chokidar": "^3.6.0",
"class-transformer": "^0.5.1",
"class-validator": "0.14.0",
"del": "^6.0.0",
"del": "^8.0.0",
"express-basic-auth": "^1.2.1",
"file-type": "16.5.4",
"fs-extra": "^11.2.0",
@@ -69,23 +71,24 @@
"mime-types": "^2.1.27",
"mongodb": "6.9.0",
"nestjs-pino": "^4.1.0",
"passport": "^0.6.0",
"node-cache": "5.1.2",
"passport": "^0.7.0",
"passport-headerapikey": "^1.2.2",
"pg": "^8.13.1",
"pino-http": "^10.2.0",
"pino-pretty": "^11.2.1",
"pretty-bytes": "5.6.0",
"promise-retry": "^2.0.1",
"puppeteer": "^23.6.0",
"puppeteer": "^24.10.0",
"qrcode": "^1.5.1",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rimraf": "^4.3.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.4",
"swagger-ui-express": "^4.1.4",
"ulid": "^2.3.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-channels",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09",
"write-file-atomic": "^6.0.0"
},
"optionalDependencies": {
@@ -94,16 +97,17 @@
"resolutions": {
"bufferutil": "^4.0.8",
"ws": "^8.18.0",
"puppeteer": "^23.6.0",
"puppeteer": "^24.10.0",
"whatwg-url": "13.0.0",
"libsignal": "github:devlikeapro/libsignal-node#fork-master"
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
"axios": "^1.9.0"
},
"devDependencies": {
"@grpc/grpc-js": "^1.12.4",
"@grpc/grpc-js": "^1.13.4",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.1",
"@nestjs/testing": "^9.0.9",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^4.17.3",
"@types/jest": "26.0.10",
"@types/node": "^18.14.2",
@@ -114,7 +118,7 @@
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"grpc-tools": "^1.12.4",
"grpc-tools": "^1.13.0",
"jest": "^29.7.0",
"prettier": "^1.19.1",
"protoc-gen-ts": "^0.8.7",
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Default port is 3000
PORT=${WHATSAPP_API_PORT:-3000}
# Check if HTTPS is enabled
if [[ "${WAHA_HTTPS_ENABLED}" == "true" || "${WAHA_HTTPS_ENABLED}" == "1" ]]; then
PROTOCOL="https"
else
PROTOCOL="http"
fi
# Execute curl command with SSL verification disabled
curl -f -s -k "${PROTOCOL}://127.0.0.1:${PORT}/ping" || exit 1
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Set source and output file
SOURCE_DIR="src"
OUTPUT_DIR="$HOME/Downloads/chatgpt"
OUTPUT_FILE="$OUTPUT_DIR/waha.ts"
# Create or clear the output file
rm -f "$OUTPUT_FILE"
touch "$OUTPUT_FILE"
# Find all .md files in the source directory and combine them
find "$SOURCE_DIR" -type f -name "*.ts" | while read -r file; do
# Add file name to OUTPUT_FILE
echo "\n-------" >> "$OUTPUT_FILE"
echo "File: $file" >> "$OUTPUT_FILE"
echo "-------\n" >> "$OUTPUT_FILE"
# Append the content of the file to the output file
cat "$file" >> "$OUTPUT_FILE"
done
echo "All .ts files combined into $OUTPUT_FILE with separators."
+2 -2
View File
@@ -36,8 +36,8 @@ class AuthController {
summary: 'Get QR code for pairing WhatsApp API.',
})
@SessionApiParam
@ApiFileAcceptHeader(Base64File, QRCodeValue)
@UseInterceptors(new BufferResponseInterceptor())
@ApiFileAcceptHeader('image/png', Base64File, QRCodeValue)
@UseInterceptors(new BufferResponseInterceptor('image/png'))
async getQR(
@QRCodeSessionParam session: WhatsappSession,
@Query() query: QRCodeQuery,
+3 -3
View File
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { isJidNewsletter } from '@waha/core/utils/jids';
import {
SessionApiParam,
WorkingSessionParam,
@@ -35,7 +36,6 @@ import {
import { SessionManager } from '../core/abc/manager.abc';
import {
isNewsletter,
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
@@ -93,7 +93,7 @@ export class ChannelsController {
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<Channel> {
if (isNewsletter(id)) {
if (isJidNewsletter(id)) {
return session.channelsGetChannel(id);
} else {
const inviteCode = parseChannelInviteLink(id);
@@ -126,7 +126,7 @@ export class ChannelsController {
@Param('id') code: string,
@Query() query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
if (isNewsletter(code)) {
if (isJidNewsletter(code)) {
const channel = await session.channelsGetChannel(code);
code = parseChannelInviteLink(channel.invite);
}
+36 -1
View File
@@ -1,3 +1,4 @@
import { isJidGroup } from '@adiwajshing/baileys';
import {
Body,
Controller,
@@ -29,8 +30,13 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
OverviewBodyRequest,
OverviewFilter,
OverviewPaginationParams,
PinMessageRequest,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
transformAck,
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
@@ -61,8 +67,23 @@ class ChatsController {
getChatsOverview(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: OverviewPaginationParams,
@Query() filter: OverviewFilter,
): Promise<ChatSummary[]> {
return session.getChatsOverview(pagination);
return session.getChatsOverview(pagination, filter);
}
@Post('overview')
@SessionApiParam
@ApiOperation({
summary:
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
})
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
postChatsOverview(
@WorkingSessionParam session: WhatsappSession,
@Body() body: OverviewBodyRequest,
): Promise<ChatSummary[]> {
return session.getChatsOverview(body.pagination, body.filter);
}
@Delete(':chatId')
@@ -100,9 +121,23 @@ class ChatsController {
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
) {
filter = transformAck(filter);
return session.getChatMessages(chatId, query, filter);
}
@Post(':chatId/messages/read')
@SessionApiParam
@ApiOperation({ summary: 'Read unread messages in the chat' })
@ChatIdApiParam
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
readChatMessages(
@Query() query: ReadChatMessagesQuery,
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
): Promise<ReadChatMessagesResponse> {
return session.readChatMessages(chatId, query);
}
@Get(':chatId/messages/:messageId')
@SessionApiParam
@ApiOperation({ summary: 'Gets message by id' })
+15 -1
View File
@@ -10,7 +10,11 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import {
GetChatMessagesFilter,
ReadChatMessagesQuery,
transformAck,
} from '@waha/structures/chats.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import { SessionManager } from '../core/abc/manager.abc';
@@ -136,6 +140,15 @@ export class ChattingController {
@Post('/sendSeen')
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
const query: ReadChatMessagesQuery = {
messages: null,
days: 7,
};
return whatsapp.readChatMessages(chat.chatId, query);
}
const whatsapp = await this.manager.getWorkingSession(chat.session);
return whatsapp.sendSeen(chat);
}
@@ -223,6 +236,7 @@ export class ChattingController {
@Query() query: GetMessageQuery,
@Query() filter: GetChatMessagesFilter,
) {
filter = transformAck(filter);
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getChatMessages(query.chatId, query, filter);
}
+50
View File
@@ -0,0 +1,50 @@
import {
Body,
Controller,
Param,
Post,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { SessionManager } from '../core/abc/manager.abc';
import {
EventCancelRequest,
EventMessageRequest,
} from '../structures/events.dto';
import { WAMessage } from '../structures/responses.dto';
@ApiSecurity('api_key')
@Controller('api/:session/events')
@ApiTags('📅 Events')
export class EventsController {
constructor(private manager: SessionManager) {}
@Post()
@ApiOperation({ summary: 'Send an event message' })
@UsePipes(new ValidationPipe())
@SessionApiParam
async sendEvent(
@WorkingSessionParam session: WhatsappSession,
@Body() request: EventMessageRequest,
): Promise<WAMessage> {
return session.sendEvent(request);
}
// @Post(':id/cancel')
// @ApiOperation({ summary: 'Cancel an event by ID' })
// @UsePipes(new ValidationPipe())
// @SessionApiParam
// async cancelEvent(
// @WorkingSessionParam session: WhatsappSession,
// @Param('id') id: string,
// ): Promise<WAMessage> {
// return session.cancelEvent(id);
// }
}
+102
View File
@@ -0,0 +1,102 @@
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import {
Controller,
Get,
Param,
Query,
UnprocessableEntityException,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { CountResponse } from '@waha/structures/base.dto';
import {
LidsListQueryParams,
LidToPhoneNumber,
} from '@waha/structures/lids.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { SessionManager } from '../core/abc/manager.abc';
@ApiSecurity('api_key')
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
export class LidsController {
constructor(private manager: SessionManager) {}
@Get('/')
@SessionApiParam
@ApiOperation({ summary: 'Get all known lids to phone number mapping' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@WorkingSessionParam session: WhatsappSession,
@Query() params: LidsListQueryParams,
): Promise<Array<LidToPhoneNumber>> {
// Always lid
const pagination: PaginationParams = params;
pagination.sortBy = 'lid';
pagination.sortOrder = SortOrder.ASC;
const lids = await session.getAllLids(pagination);
return lids;
}
@Get('/count')
@SessionApiParam
@ApiOperation({ summary: 'Get the number of known lids' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getLidsCount(
@WorkingSessionParam session: WhatsappSession,
): Promise<CountResponse> {
const count = await session.getLidsCount();
return {
count: count,
};
}
@Get('/:lid')
@SessionApiParam
@ApiOperation({ summary: 'Get phone number by lid' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async findPNByLid(
@WorkingSessionParam session: WhatsappSession,
@Param('lid') lid: string,
): Promise<LidToPhoneNumber> {
if (!lid.includes('@')) {
lid = lid + '@lid';
}
if (!isLidUser(lid)) {
throw new UnprocessableEntityException(
'Invalid LID - it must end with @lid',
);
}
const result = await session.findPNByLid(lid);
result.pn = result.pn || null;
return result;
}
@Get('/pn/:phoneNumber')
@SessionApiParam
@ApiOperation({ summary: 'Get lid by phone number (chat id)' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async findLIDByPhoneNumber(
@WorkingSessionParam session: WhatsappSession,
@Param('phoneNumber') phoneNumber: string,
): Promise<LidToPhoneNumber> {
if (isLidUser(phoneNumber)) {
return {
lid: phoneNumber,
pn: null,
};
}
const result = await session.findLIDByPhoneNumber(phoneNumber);
result.lid = result.lid || null;
return result;
}
}
+83
View File
@@ -0,0 +1,83 @@
import {
Body,
Controller,
Post,
UnprocessableEntityException,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import {
FileDTO,
VideoFileDTO,
VoiceFileDTO,
} from '@waha/structures/media.dto';
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
@ApiSecurity('api_key')
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
class MediaController {
constructor(private manager: SessionManager) {}
@Post('convert/voice')
@ApiOperation({
summary: 'Convert voice to WhatsApp format (opus)',
})
@SessionApiParam
@UseInterceptors(
new BufferResponseInterceptor(WAMimeType.VOICE, 'output.opus'),
)
@ApiFileAcceptHeader(WAMimeType.VOICE)
async convertVoice(
@WorkingSessionParam session: WhatsappSession,
@Body() file: VoiceFileDTO,
): Promise<Buffer> {
const data = await this.buffer(session, file);
const content = await session.mediaConverter.voice(data);
return content;
}
@Post('convert/video')
@ApiOperation({
summary: 'Convert video to WhatsApp format (mp4)',
})
@SessionApiParam
@UseInterceptors(
new BufferResponseInterceptor(WAMimeType.VIDEO, 'output.mp4'),
)
@ApiFileAcceptHeader(WAMimeType.VIDEO)
async convertVideo(
@WorkingSessionParam session: WhatsappSession,
@Body() file: VideoFileDTO,
): Promise<Buffer> {
const data = await this.buffer(session, file);
const content = await session.mediaConverter.video(data);
return content;
}
private async buffer(
session: WhatsappSession,
file: FileDTO,
): Promise<Buffer> {
if ('url' in file) {
return session.fetch(file.url);
} else if ('data' in file) {
return Buffer.from(file.data, 'base64');
} else {
throw new UnprocessableEntityException(
'Either "url" or "data" must be specified.',
);
}
}
}
export { MediaController };
+2 -2
View File
@@ -21,8 +21,8 @@ export class ScreenshotController {
constructor(private manager: SessionManager) {}
@Get('/screenshot')
@UseInterceptors(new BufferResponseInterceptor())
@ApiFileAcceptHeader()
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
async screenshot(
@Res({ passthrough: true }) res: Response,
@Query() sessionQuery: SessionQuery,
+9
View File
@@ -233,6 +233,9 @@ class SessionsController {
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
const name = request.name;
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
if (this.manager.isRunning(name)) {
const msg = `Session '${name}' is already started.`;
throw new UnprocessableEntityException(msg);
@@ -255,6 +258,9 @@ class SessionsController {
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
const name = request.name;
if (request.logout) {
// Old API did remove the session complete
@@ -283,6 +289,9 @@ class SessionsController {
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
const name = request.name;
await this.withLock(name, async () => {
await this.manager.unassign(name);
@@ -12,6 +12,7 @@ import {
WebSocketServer,
} from '@nestjs/websockets';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { WebsocketHeartbeatJob } from '@waha/nestjs/ws/WebsocketHeartbeatJob';
import { WebSocket } from '@waha/nestjs/ws/ws';
import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
@@ -21,6 +22,15 @@ import { IncomingMessage } from 'http';
import * as url from 'url';
import { Server } from 'ws';
export enum WebSocketCloseCode {
NORMAL = 1000,
GOING_AWAY = 1001,
PROTOCOL_ERROR = 1002,
UNSUPPORTED_DATA = 1003,
POLICY_VIOLATION = 1008,
INTERNAL_ERROR = 1011,
}
@WebSocketGateway({
path: '/ws',
cors: true,
@@ -41,7 +51,10 @@ export class WebsocketGatewayCore
private heartbeat: WebsocketHeartbeatJob;
private eventUnmask = new EventWildUnmask(WAHAEvents, WAHAEventsWild);
constructor(private manager: SessionManager) {
constructor(
private manager: SessionManager,
private auth: WebSocketAuth,
) {
this.logger = new Logger('WebsocketGateway');
this.heartbeat = new WebsocketHeartbeatJob(
this.logger,
@@ -51,14 +64,23 @@ export class WebsocketGatewayCore
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
// wsc - websocket client
const id = generatePrefixedId('wsc');
socket.id = id;
this.logger.debug(`New client connected: ${request.url}`);
socket.id = generatePrefixedId('wsc');
if (!this.auth.validateRequest(request)) {
// Not authorized - close connection
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
this.logger.debug(
`Unauthorized websocket connection attempt: ${request.url} - ${socket.id}`,
);
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const params = this.getParams(request);
const session: string = params.session;
const events: WAHAEvents[] = params.events;
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${id}`,
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
);
const sub = this.manager
@@ -97,26 +119,11 @@ export class WebsocketGatewayCore
async beforeApplicationShutdown(signal?: string) {
this.logger.log('Shutting down websocket server');
this.heartbeat?.stop();
// Allow pending messages to be sent, it can be even 1ms, just to release the event loop
await sleep(100);
// Close clients and server
await this.close(this.server);
this.logger.log('Websocket server is down');
}
// Cherry-pick from nestjs new version
// https://github.com/nestjs/nest/pull/13531/files
private async close(server: any) {
const closeEventSignal = new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve(undefined))),
);
for (const ws of server.clients) {
ws.terminate();
}
await closeEventSignal;
}
afterInit(server: Server) {
this.logger.debug('Websocket server initialized');
+4 -1
View File
@@ -138,7 +138,10 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
}
getApiKey(): string | undefined {
return this.configService.get('WHATSAPP_API_KEY', '');
return (
this.configService.get('WHATSAPP_API_KEY', '') ||
this.configService.get('WAHA_API_KEY', '')
);
}
getExcludedPaths(): string[] {
+29 -2
View File
@@ -1,6 +1,8 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import { DECORATORS } from '@nestjs/swagger/dist/constants';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { DashboardConfigServiceCore } from '@waha/core/config/DashboardConfigServiceCore';
import { Logger } from 'nestjs-pino';
import { WhatsappConfigService } from '../config.service';
@@ -9,16 +11,22 @@ import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
export class SwaggerConfiguratorCore {
protected logger: any;
private config: SwaggerConfigServiceCore;
constructor(protected app: INestApplication) {
this.logger = app.get(Logger);
this.config = app.get(SwaggerConfigServiceCore);
}
get title() {
return 'WAHA - WhatsApp HTTP API';
return this.config.title || 'WAHA - WhatsApp HTTP API';
}
get description() {
if (this.config.description) {
return this.config.description;
}
return (
'<b>WhatsApp HTTP API</b> that you can run in a click!<br/>' +
'<a href="/dashboard"><b>📊 Dashboard</b></a><br/>' +
@@ -43,10 +51,19 @@ export class SwaggerConfiguratorCore {
}
get externalDocUrl() {
return 'https://waha.devlike.pro/';
return this.config.externalDocUrl || 'https://waha.devlike.pro/';
}
configure(webhooks: any[]) {
if (!this.config.enabled) {
return;
}
const credentials = this.config.credentials;
if (credentials) {
this.setUpAuth(credentials);
}
const app = this.app;
const builder = new DocumentBuilder();
@@ -71,10 +88,12 @@ export class SwaggerConfiguratorCore {
)
.addTag('👥 Groups', `Groups methods.<br>`)
.addTag('✅ Presence', `Presence information`)
.addTag('📅 Events', `Event Message`)
.addTag(
'🏷️ Labels',
'Labels - available only for WhatsApp Business accounts',
)
.addTag('🖼️ Media', 'Media methods')
.addTag('🔍 Observability', 'Other methods')
.addTag('🗄️ Storage', 'Storage methods')
.addApiKey({
@@ -161,4 +180,12 @@ export class SwaggerConfiguratorCore {
document.webhooks = webhooks;
return document;
}
setUpAuth(credentials: [string, string]): void {
const [username, password] = credentials;
const dashboardConfig = this.app.get(DashboardConfigServiceCore);
const exclude = ['/api/', dashboardConfig.dashboardUri, '/health', '/ws'];
const authFunction = BasicAuthFunction(username, password, exclude);
this.app.use(authFunction);
}
}
+2 -2
View File
@@ -50,7 +50,6 @@ export abstract class SessionManager
WAIT_SESSION_RUNNING_TIMEOUT = 5_000;
WAIT_STATUS_INTERVAL = 500;
WAIT_STATUS_TIMEOUT = 10_000;
LOCK_TIMEOUT = 10_000;
protected constructor(
protected log: PinoLogger,
@@ -58,8 +57,9 @@ export abstract class SessionManager
protected gowsConfigService: GowsEngineConfigService,
) {
this.lock = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
timeout: this.LOCK_TIMEOUT,
maxExecutionTime: 30_000,
});
this.log.setContext(SessionManager.name);
}
+88 -5
View File
@@ -1,4 +1,10 @@
import { isJidBroadcast } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import {
CoreMediaConverter,
IMediaConverter,
} from '@waha/core/media/IConverter';
import { MessagesForRead } from '@waha/core/utils/convertors';
import { isJidNewsletter } from '@waha/core/utils/jids';
import {
Channel,
ChannelListResult,
@@ -14,10 +20,14 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
OverviewFilter,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
} from '@waha/structures/chats.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { MessageSource, WAMessage } from '@waha/structures/responses.dto';
import { DefaultMap } from '@waha/utils/DefaultMap';
@@ -25,6 +35,8 @@ import { generatePrefixedId } from '@waha/utils/ids';
import { LoggerBuilder } from '@waha/utils/logging';
import { complete } from '@waha/utils/reactive/complete';
import { SwitchObservable } from '@waha/utils/reactive/SwitchObservable';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import * as fs from 'fs';
import * as lodash from 'lodash';
import * as NodeCache from 'node-cache';
@@ -71,6 +83,10 @@ import {
WAHAPresenceStatus,
WAHASessionStatus,
} from '../../structures/enums.dto';
import {
EventCancelRequest,
EventMessageRequest,
} from '../../structures/events.dto';
import {
CreateGroupRequest,
GroupField,
@@ -96,9 +112,12 @@ import { NotImplementedByEngineError } from '../exceptions';
import { IMediaManager } from '../media/IMediaManager';
import { QR } from '../QR';
import { DataStore } from './DataStore';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const qrcode = require('qrcode-terminal');
axiosRetry(axios, { retries: 3 });
const CHROME_PATH = '/usr/bin/google-chrome-stable';
const CHROMIUM_PATH = '/usr/bin/chromium';
@@ -155,6 +174,8 @@ export abstract class WhatsappSession {
stdTTL: 10 * 60, // 10 minutes
});
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public constructor({
name,
printQR,
@@ -462,6 +483,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
sendEvent(request: EventMessageRequest): Promise<WAMessage> {
throw new NotImplementedByEngineError();
}
cancelEvent(eventId: string): Promise<WAMessage> {
throw new NotImplementedByEngineError();
}
/**
* Chats methods
*/
@@ -471,6 +500,7 @@ export abstract class WhatsappSession {
public getChatsOverview(
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
throw new NotImplementedByEngineError();
}
@@ -483,10 +513,35 @@ export abstract class WhatsappSession {
chatId: string,
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
) {
): Promise<WAMessage[]> {
throw new NotImplementedByEngineError();
}
abstract readChatMessages(
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse>;
protected async readChatMessagesWSImpl(
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse> {
const { query, filter } = MessagesForRead(chatId, request);
const messages = await this.getChatMessages(chatId, query, filter);
this.logger.debug(`Found ${messages.length} messages to read`);
if (messages.length === 0) {
return { ids: [] };
}
const ids = messages.map((m) => m.id);
const seen: SendSeenRequest = {
chatId: chatId,
messageIds: ids,
session: '',
};
await this.sendSeen(seen);
return { ids: ids };
}
public getChatMessage(
chatId: string,
messageId: string,
@@ -586,6 +641,29 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
/**
* Lid to Phone Number methods
*/
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
throw new NotImplementedByEngineError();
}
public async getLidsCount(): Promise<number> {
throw new NotImplementedByEngineError();
}
public async findPNByLid(lid: string): Promise<LidToPhoneNumber> {
throw new NotImplementedByEngineError();
}
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
throw new NotImplementedByEngineError();
}
/**
* Fetch the latest profile picture of the contact (group, newsletter, etc.)
* @param id
@@ -607,7 +685,7 @@ export abstract class WhatsappSession {
let fn: Promise<string>;
if (isJidBroadcast(id)) {
return null;
} else if (isNewsletter(id)) {
} else if (isJidNewsletter(id)) {
fn = this.channelsGetChannel(id).then(
(channel: Channel) => channel.picture || channel.preview,
);
@@ -923,10 +1001,15 @@ export abstract class WhatsappSession {
const api = this.sentMessageIds.has(id);
return api ? MessageSource.API : MessageSource.APP;
}
}
export function isNewsletter(jid: string) {
return jid.endsWith('@newsletter');
/**
* Fetches the content from the specified URL and returns it as a Buffer.
*/
public async fetch(url: string): Promise<Buffer> {
return axios.get(url, { responseType: 'arraybuffer' }).then((res) => {
return Buffer.from(res.data);
});
}
}
export function getGroupInviteLink(code: string) {
+77 -17
View File
@@ -1,29 +1,42 @@
import { INestApplication, Module } from '@nestjs/common';
import * as process from 'node:process';
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
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 { ProfileController } from '@waha/api/profile.controller';
import {
ServerController,
ServerDebugController,
} from '@waha/api/server.controller';
import { WebsocketGatewayCore } from '@waha/core/api/websocket.gateway.core';
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { parseBool } from '@waha/helpers';
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
import { HttpsExpress } from '@waha/nestjs/HttpsExpress';
import {
getPinoHttpUseLevel,
getPinoLogLevel,
getPinoTransport,
} from '@waha/utils/logging';
import { noSlashAtTheEnd } from '@waha/utils/string';
import * as Joi from 'joi';
import { LoggerModule } from 'nestjs-pino';
import { Logger as NestJSPinoLogger } from 'nestjs-pino';
import { join } from 'path';
import { Logger } from 'pino';
@@ -31,9 +44,11 @@ import { AuthController } from '../api/auth.controller';
import { ChatsController } from '../api/chats.controller';
import { ChattingController } from '../api/chatting.controller';
import { ContactsController } from '../api/contacts.controller';
import { EventsController } from '../api/events.controller';
import { GroupsController } from '../api/groups.controller';
import { HealthController } from '../api/health.controller';
import { LabelsController } from '../api/labels.controller';
import { MediaController } from '../api/media.controller';
import { PingController } from '../api/ping.controller';
import { PresenceController } from '../api/presence.controller';
import { ScreenshotController } from '../api/screenshot.controller';
@@ -43,6 +58,7 @@ import { VersionController } from '../api/version.controller';
import { WhatsappConfigService } from '../config.service';
import { SessionManager } from './abc/manager.abc';
import { WAHAHealthCheckService } from './abc/WAHAHealthCheckService';
import { ApiKeyAuthFactory } from './auth/ApiKeyAuthFactory';
import { DashboardConfigServiceCore } from './config/DashboardConfigServiceCore';
import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
@@ -60,6 +76,7 @@ export const IMPORTS_CORE = [
autoLogging: {
ignore: (req) => {
return (
req.url.startsWith('/ping') ||
req.url.startsWith('/dashboard/') ||
req.url.startsWith('/api/files/') ||
req.url.startsWith('/api/s3/')
@@ -129,15 +146,40 @@ export const CONTROLLERS = [
StatusController,
LabelsController,
ContactsController,
LidsController,
GroupsController,
PresenceController,
ScreenshotController,
EventsController,
PingController,
HealthController,
ServerController,
ServerDebugController,
VersionController,
MediaController,
];
export const PROVIDERS_BASE: Provider[] = [
{
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
},
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
GowsEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
WebSocketAuth,
ApiKeyStrategy,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
inject: [WhatsappConfigService, NestJSPinoLogger],
},
];
const PROVIDERS = [
{
provide: SessionManager,
@@ -147,19 +189,8 @@ const PROVIDERS = [
provide: WAHAHealthCheckService,
useClass: WAHAHealthCheckServiceCore,
},
{
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
},
ChannelsInfoServiceCore,
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
GowsEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
...PROVIDERS_BASE,
];
@Module({
@@ -170,15 +201,44 @@ const PROVIDERS = [
export class AppModuleCore {
public startTimestamp: number;
constructor(protected config: WhatsappConfigService) {
constructor(
protected config: WhatsappConfigService,
private dashboardConfig: DashboardConfigServiceCore,
) {
this.startTimestamp = Date.now();
}
static getHttpsOptions(logger: Logger) {
return undefined;
const httpsEnabled = parseBool(process.env.WAHA_HTTPS_ENABLED);
if (!httpsEnabled) {
return undefined;
}
const httpsExpress = new HttpsExpress(logger);
return httpsExpress.readSync();
}
static appReady(app: INestApplication, logger: Logger) {
return;
const httpsEnabled = parseBool(process.env.WAHA_HTTPS_ENABLED);
if (!httpsEnabled) {
return;
}
const httpd = app.getHttpServer();
const httpsExpress = new HttpsExpress(logger);
httpsExpress.watchCertChanges(httpd);
}
configure(consumer: MiddlewareConsumer) {
const exclude = this.config.getExcludedPaths();
consumer
.apply(AuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'ws');
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
const password = dashboardCredentials[1];
const route = noSlashAtTheEnd(this.dashboardConfig.dashboardUri);
consumer.apply(BasicAuthFunction(username, password)).forRoutes(route);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { LoggerService } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
import {
HashAuth,
IApiKeyAuth,
NoAuth,
PlainApiKeyAuth,
} from '@waha/core/auth/auth';
export function ApiKeyAuthFactory(
config: WhatsappConfigService,
logger: LoggerService,
): IApiKeyAuth {
const apiKey = config.getApiKey();
if (!apiKey) {
setTimeout(() => {
logger.warn('🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫');
logger.warn('WARNING: No API key detected. This is a security risk.');
logger.warn(
'Your API is publicly accessible without any authentication.',
);
logger.warn(
'To secure your API, set environment variable: WAHA_API_KEY=your_api_key',
);
logger.warn(
'For better security, use WAHA_API_KEY=sha512:{SHA512_HASH_FOR_YOUR_API_KEY}',
);
logger.warn('🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫🚫');
}, 3000);
return new NoAuth();
}
if (apiKey.startsWith('sha512:')) {
const hash = apiKey.slice(7);
return new HashAuth(hash, 'sha512');
}
// Fallback to plain text
setTimeout(() => {
logger.warn('⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️');
logger.warn(
'WARNING: Plain text API key detected. This is a security risk.',
);
logger.warn(
'Your API key can be exposed in environment variables or process lists.',
);
logger.warn(
'For better security, use WAHA_API_KEY=sha512:{SHA512_HASH_FOR_YOUR_API_KEY}',
);
logger.warn('⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️');
}, 2000);
return new PlainApiKeyAuth(apiKey);
}
+34
View File
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { IApiKeyAuth } from './auth';
@Injectable()
export class WebSocketAuth {
constructor(private auth: IApiKeyAuth) {}
validateRequest(request: IncomingMessage) {
if (this.auth.skipAuth()) {
return true;
}
const provided = this.getKeyFromQueryParams(request);
return this.auth.isValid(provided);
}
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;
}, {});
const provided = query['x-api-key'];
// Check if it's array - return first
if (Array.isArray(provided)) {
return provided[0];
}
return provided;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
constructor(private auth: IApiKeyAuth) {
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
const isValid = this.auth.isValid(apikey);
return done(isValid);
});
}
validate(apikey: string, done: (result: boolean) => void): void {
const isValid = this.auth.isValid(apikey);
return done(isValid);
}
}
+28
View File
@@ -0,0 +1,28 @@
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);
}
}
+79
View File
@@ -0,0 +1,79 @@
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 {
constructor(private key: string) {}
isValid(plain: string): boolean {
return compare(plain, this.key);
}
skipAuth(): boolean {
return false;
}
}
export class HashAuth implements IApiKeyAuth {
constructor(
private hash: string,
private algorithm: string,
) {
this.algorithm = algorithm;
}
isValid(plain: string): boolean {
if (!plain) {
return false;
}
const hash = crypto.createHash(this.algorithm).update(plain).digest('hex');
return compare(hash, this.hash);
}
skipAuth(): boolean {
return false;
}
}
/**
* Securely compare 2 strings
*/
export function compare(provided: string, stored: string | undefined): boolean {
if (!stored || !provided) {
return false;
}
try {
// Convert strings to buffers for constant-time comparison
const providedBuffer = Buffer.from(provided);
const storedBuffer = Buffer.from(stored);
// If lengths are different, return false but use a dummy comparison to prevent timing attacks
if (providedBuffer.length !== storedBuffer.length) {
// Create a dummy buffer of the same length as the provided key
const dummyBuffer = Buffer.alloc(providedBuffer.length);
// Perform comparison with dummy buffer to maintain constant time
crypto.timingSafeEqual(providedBuffer, dummyBuffer);
return false;
}
// Perform constant-time comparison
return crypto.timingSafeEqual(providedBuffer, storedBuffer);
} catch (error) {
return false;
}
}
+21
View File
@@ -0,0 +1,21 @@
import * as basicAuth from 'express-basic-auth';
export function BasicAuthFunction(username, password, exclude: string[] = []) {
function authFunction(req, res, next) {
const ignore = exclude.filter((url) => req.url.startsWith(url)).length > 0;
if (ignore) {
next();
return;
}
const auth = basicAuth({
challenge: true,
users: {
[username]: password,
},
});
auth(req, res, next);
}
return authFunction;
}
@@ -18,4 +18,20 @@ export class DashboardConfigServiceCore {
const value = this.configService.get('WAHA_DASHBOARD_ENABLED', 'true');
return parseBool(value);
}
get credentials(): [string, string] | null {
const user = this.configService.get('WAHA_DASHBOARD_USERNAME', '');
const password = this.configService.get('WAHA_DASHBOARD_PASSWORD', '');
if (!user && !password) {
return null;
}
if ((user && !password) || (!user && password)) {
this.logger.warn(
'Set up both WAHA_DASHBOARD_USERNAME and WAHA_DASHBOARD_PASSWORD ' +
'to enable dashboard authentication.',
);
return null;
}
return [user, password];
}
}
+6 -1
View File
@@ -1,16 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WhatsappConfigService } from '@waha/config.service';
import { BootstrapConfig } from '@waha/core/engines/gows/GowsBootstrap';
import { GowsConfig } from '@waha/core/engines/gows/session.gows.core';
@Injectable()
export class GowsEngineConfigService {
constructor(protected configService: ConfigService) {}
constructor(
protected configService: ConfigService,
protected whatsappConfigService: WhatsappConfigService,
) {}
getBootstrapConfig(): BootstrapConfig {
return {
path: this.configService.get('WAHA_GOWS_PATH'),
socket: this.getSocket(),
pprof: this.whatsappConfigService.debugModeEnabled,
};
}
@@ -19,4 +19,43 @@ export class SwaggerConfigServiceCore {
);
return parseBool(value);
}
get enabled(): boolean {
const value = this.configService.get('WHATSAPP_SWAGGER_ENABLED', 'true');
return parseBool(value);
}
get credentials(): [string, string] | undefined {
const user = this.configService.get<string>(
'WHATSAPP_SWAGGER_USERNAME',
undefined,
);
const password = this.configService.get<string>(
'WHATSAPP_SWAGGER_PASSWORD',
undefined,
);
if (!user && !password) {
return null;
}
if ((user && !password) || (!user && password)) {
this.logger.warn(
'Set up both WHATSAPP_SWAGGER_USERNAME and WHATSAPP_SWAGGER_PASSWORD ' +
'to enable swagger authentication.',
);
return null;
}
return [user, password];
}
get title() {
return this.configService.get('WHATSAPP_SWAGGER_TITLE', '');
}
get description() {
return this.configService.get('WHATSAPP_SWAGGER_DESCRIPTION', '');
}
get externalDocUrl() {
return this.configService.get('WHATSAPP_SWAGGER_EXTERNAL_DOC_URL', '');
}
}
+2
View File
@@ -22,6 +22,7 @@ export async function isUnixSocket(socketPath: string): Promise<boolean> {
export interface BootstrapConfig {
path: string;
socket: string;
pprof?: boolean;
}
export class GowsBootstrap implements EngineBootstrap {
@@ -44,6 +45,7 @@ export class GowsBootstrap implements EngineBootstrap {
this.logger,
this.config.path,
this.config.socket,
this.config.pprof,
);
this.gows.start(() => {
this.logger.info(`GOWS stopped, exiting...`);
+10 -1
View File
@@ -14,13 +14,22 @@ export class GowsSubprocess {
private logger: Logger,
readonly path: string,
readonly socket: string,
readonly pprof: boolean = false,
) {}
start(onExit: (code: number) => void) {
this.logger.info('Starting GOWS subprocess...');
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
this.child = spawn(this.path, ['--socket', this.socket], {
const args = ['--socket', this.socket];
if (this.pprof) {
this.logger.info('Debug mode enabled, adding pprof flags');
args.push('--pprof');
args.push('--pprof-port=6060');
args.push('--pprof-host=0.0.0.0');
}
this.child = spawn(this.path, args, {
detached: true,
});
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
+3
View File
@@ -0,0 +1,3 @@
export function isFromFullSync(event: any): boolean {
return event.FromFullSync;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import {
GroupInfoFull,
JoinedGroupEvent,
} from '@waha/core/engines/gows/types.group';
import { toJID } from '@waha/core/engines/noweb/session.noweb.core';
import { toJID } from '@waha/core/utils/jids';
import {
GroupId,
GroupInfo,
File diff suppressed because it is too large. Load diff
+220 -2
View File
@@ -15,6 +15,28 @@ function deserialize_messages_ButtonReplyRequest(buffer_arg) {
return gows_pb.ButtonReplyRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_CancelEventMessageRequest(arg) {
if (!(arg instanceof gows_pb.CancelEventMessageRequest)) {
throw new Error('Expected argument of type messages.CancelEventMessageRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_CancelEventMessageRequest(buffer_arg) {
return gows_pb.CancelEventMessageRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_ChatLabelRequest(arg) {
if (!(arg instanceof gows_pb.ChatLabelRequest)) {
throw new Error('Expected argument of type messages.ChatLabelRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_ChatLabelRequest(buffer_arg) {
return gows_pb.ChatLabelRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_ChatPresenceRequest(arg) {
if (!(arg instanceof gows_pb.ChatPresenceRequest)) {
throw new Error('Expected argument of type messages.ChatPresenceRequest');
@@ -70,6 +92,17 @@ function deserialize_messages_CreateNewsletterRequest(buffer_arg) {
return gows_pb.CreateNewsletterRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_DeleteLabelRequest(arg) {
if (!(arg instanceof gows_pb.DeleteLabelRequest)) {
throw new Error('Expected argument of type messages.DeleteLabelRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_DeleteLabelRequest(buffer_arg) {
return gows_pb.DeleteLabelRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_DownloadMediaRequest(arg) {
if (!(arg instanceof gows_pb.DownloadMediaRequest)) {
throw new Error('Expected argument of type messages.DownloadMediaRequest');
@@ -158,6 +191,28 @@ function deserialize_messages_GetContactsRequest(buffer_arg) {
return gows_pb.GetContactsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_GetLabelsRequest(arg) {
if (!(arg instanceof gows_pb.GetLabelsRequest)) {
throw new Error('Expected argument of type messages.GetLabelsRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_GetLabelsRequest(buffer_arg) {
return gows_pb.GetLabelsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_GetLidsRequest(arg) {
if (!(arg instanceof gows_pb.GetLidsRequest)) {
throw new Error('Expected argument of type messages.GetLidsRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_GetLidsRequest(buffer_arg) {
return gows_pb.GetLidsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_GetMessagesRequest(arg) {
if (!(arg instanceof gows_pb.GetMessagesRequest)) {
throw new Error('Expected argument of type messages.GetMessagesRequest');
@@ -389,6 +444,17 @@ function deserialize_messages_OptionalString(buffer_arg) {
return gows_pb.OptionalString.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_OptionalUInt64(arg) {
if (!(arg instanceof gows_pb.OptionalUInt64)) {
throw new Error('Expected argument of type messages.OptionalUInt64');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_OptionalUInt64(buffer_arg) {
return gows_pb.OptionalUInt64.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_PairCodeRequest(arg) {
if (!(arg instanceof gows_pb.PairCodeRequest)) {
throw new Error('Expected argument of type messages.PairCodeRequest');
@@ -576,6 +642,17 @@ function deserialize_messages_UpdateParticipantsRequest(buffer_arg) {
return gows_pb.UpdateParticipantsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_UpsertLabelRequest(arg) {
if (!(arg instanceof gows_pb.UpsertLabelRequest)) {
throw new Error('Expected argument of type messages.UpsertLabelRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_UpsertLabelRequest(buffer_arg) {
return gows_pb.UpsertLabelRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
//
// Events
@@ -594,7 +671,7 @@ var EventStreamService = exports.EventStreamService = {
},
};
exports.EventStreamClient = grpc.makeGenericClientConstructor(EventStreamService);
exports.EventStreamClient = grpc.makeGenericClientConstructor(EventStreamService, 'EventStream');
var MessageServiceService = exports.MessageServiceService = {
//
// Session management
@@ -691,6 +768,53 @@ setProfileName: {
responseDeserialize: deserialize_messages_Empty,
},
//
// Lids
//
getAllLids: {
path: '/messages.MessageService/GetAllLids',
requestStream: false,
responseStream: false,
requestType: gows_pb.GetLidsRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_GetLidsRequest,
requestDeserialize: deserialize_messages_GetLidsRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
getLidsCount: {
path: '/messages.MessageService/GetLidsCount',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.OptionalUInt64,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_OptionalUInt64,
responseDeserialize: deserialize_messages_OptionalUInt64,
},
findPNByLid: {
path: '/messages.MessageService/FindPNByLid',
requestStream: false,
responseStream: false,
requestType: gows_pb.EntityByIdRequest,
responseType: gows_pb.OptionalString,
requestSerialize: serialize_messages_EntityByIdRequest,
requestDeserialize: deserialize_messages_EntityByIdRequest,
responseSerialize: serialize_messages_OptionalString,
responseDeserialize: deserialize_messages_OptionalString,
},
findLIDByPhoneNumber: {
path: '/messages.MessageService/FindLIDByPhoneNumber',
requestStream: false,
responseStream: false,
requestType: gows_pb.EntityByIdRequest,
responseType: gows_pb.OptionalString,
requestSerialize: serialize_messages_EntityByIdRequest,
requestDeserialize: deserialize_messages_EntityByIdRequest,
responseSerialize: serialize_messages_OptionalString,
responseDeserialize: deserialize_messages_OptionalString,
},
//
// Groups
//
fetchGroups: {
@@ -1090,6 +1214,100 @@ getSubscribedNewsletters: {
responseDeserialize: deserialize_messages_Empty,
},
//
// Labels
//
getLabels: {
path: '/messages.MessageService/GetLabels',
requestStream: false,
responseStream: false,
requestType: gows_pb.GetLabelsRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_GetLabelsRequest,
requestDeserialize: deserialize_messages_GetLabelsRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
upsertLabel: {
path: '/messages.MessageService/UpsertLabel',
requestStream: false,
responseStream: false,
requestType: gows_pb.UpsertLabelRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_UpsertLabelRequest,
requestDeserialize: deserialize_messages_UpsertLabelRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
deleteLabel: {
path: '/messages.MessageService/DeleteLabel',
requestStream: false,
responseStream: false,
requestType: gows_pb.DeleteLabelRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_DeleteLabelRequest,
requestDeserialize: deserialize_messages_DeleteLabelRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
addChatLabel: {
path: '/messages.MessageService/AddChatLabel',
requestStream: false,
responseStream: false,
requestType: gows_pb.ChatLabelRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_ChatLabelRequest,
requestDeserialize: deserialize_messages_ChatLabelRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
removeChatLabel: {
path: '/messages.MessageService/RemoveChatLabel',
requestStream: false,
responseStream: false,
requestType: gows_pb.ChatLabelRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_ChatLabelRequest,
requestDeserialize: deserialize_messages_ChatLabelRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
getLabelsByJid: {
path: '/messages.MessageService/GetLabelsByJid',
requestStream: false,
responseStream: false,
requestType: gows_pb.EntityByIdRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_EntityByIdRequest,
requestDeserialize: deserialize_messages_EntityByIdRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
getChatsByLabelId: {
path: '/messages.MessageService/GetChatsByLabelId',
requestStream: false,
responseStream: false,
requestType: gows_pb.EntityByIdRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_EntityByIdRequest,
requestDeserialize: deserialize_messages_EntityByIdRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
//
// Events
//
cancelEventMessage: {
path: '/messages.MessageService/CancelEventMessage',
requestStream: false,
responseStream: false,
requestType: gows_pb.CancelEventMessageRequest,
responseType: gows_pb.MessageResponse,
requestSerialize: serialize_messages_CancelEventMessageRequest,
requestDeserialize: deserialize_messages_CancelEventMessageRequest,
responseSerialize: serialize_messages_MessageResponse,
responseDeserialize: deserialize_messages_MessageResponse,
},
//
// Media
//
downloadMedia: {
@@ -1163,4 +1381,4 @@ getMessageById: {
},
};
exports.MessageServiceClient = grpc.makeGenericClientConstructor(MessageServiceService);
exports.MessageServiceClient = grpc.makeGenericClientConstructor(MessageServiceService, 'MessageService');
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
import { toCusFormat } from '@waha/core/engines/noweb/session.noweb.core';
import { Label, LabelChatAssociation } from '@waha/structures/labels.dto';
import * as gows from './types';
/**
* Converts a GOWS LabelEdit event to a Label DTO
*/
export function eventToLabelDTO(labelEdit: gows.LabelEdit): Label {
return {
id: labelEdit.LabelID,
name: labelEdit.Action.name,
color: labelEdit.Action.color,
colorHex: Label.toHex(labelEdit.Action.color),
} as Label;
}
/**
* Converts a GOWS LabelAssociationChat event to a LabelChatAssociation DTO
*/
export function eventToLabelChatAssociationDTO(
labelAssoc: gows.LabelAssociationChat,
): LabelChatAssociation {
return {
labelId: labelAssoc.LabelID,
label: null, // We don't have the label info here
chatId: toCusFormat(labelAssoc.JID),
};
}
/**
* Checks if a LabelEdit event is for a label creation or update (not deletion)
*/
export function isLabelUpsertEvent(labelEdit: gows.LabelEdit): boolean {
return !labelEdit.Action.deleted;
}
/**
* Checks if a LabelAssociationChat event is for adding a label to a chat
*/
export function isLabelChatAddedEvent(
labelAssoc: gows.LabelAssociationChat,
): boolean {
return labelAssoc.Action.labeled === true;
}
+565 -41
View File
@@ -1,9 +1,13 @@
import {
aggregateMessageKeysNotFromMe,
getContentType,
getUrlFromDirectPath,
isJidGroup,
jidNormalizedUser,
normalizeMessageContent,
WAMessageKey,
} from '@adiwajshing/baileys';
import { isJidBroadcast } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import * as grpc from '@grpc/grpc-js';
import { connectivityState } from '@grpc/grpc-js';
import { UnprocessableEntityException } from '@nestjs/common';
@@ -30,9 +34,8 @@ import {
} from '@waha/core/engines/gows/helpers';
import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore';
import {
parseMessageIdSerialized,
getDestination,
toCusFormat,
toJID,
} from '@waha/core/engines/noweb/session.noweb.core';
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
import {
@@ -41,6 +44,9 @@ import {
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { QR } from '@waha/core/QR';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { toJID } from '@waha/core/utils/jids';
import {
Channel,
ChannelListResult,
@@ -58,16 +64,21 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
OverviewFilter,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
} from '@waha/structures/chats.dto';
import {
ChatRequest,
CheckNumberStatusQuery,
EditMessageRequest,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
MessageImageRequest,
MessageLinkCustomPreviewRequest,
MessageLocationRequest,
MessagePollRequest,
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
@@ -83,6 +94,12 @@ import {
WAHASessionStatus,
WAMessageAck,
} from '@waha/structures/enums.dto';
import {
EventCancelRequest,
EventMessageRequest,
EventResponse,
EventResponsePayload,
} from '@waha/structures/events.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
@@ -91,6 +108,7 @@ import {
ParticipantsRequest,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import {
WAHAChatPresences,
@@ -105,18 +123,21 @@ import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
StatusRequest,
TextStatus,
} from '@waha/structures/status.dto';
import { EnginePayload, WAMessageAckBody } from '@waha/structures/webhooks.dto';
import {
EnginePayload,
PollVotePayload,
WAMessageAckBody,
WAMessageEditedBody,
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { onlyEvent } from '@waha/utils/reactive/ops/onlyEvent';
import * as NodeCache from 'node-cache';
import {
debounceTime,
filter,
groupBy,
merge,
mergeMap,
Observable,
@@ -128,9 +149,24 @@ import { map } from 'rxjs/operators';
import { promisify } from 'util';
import * as gows from './types';
import { MessageStatus } from './types';
import MessageServiceClient = messages.MessageServiceClient;
import { isJidBroadcast } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { ReplyToMessage } from '@waha/structures/message.dto';
import { isFromFullSync } from '@waha/core/engines/gows/appstate';
import { toVcard } from '@waha/core/helpers';
import { AckToStatus } from '@waha/core/utils/acks';
import { ParseEventResponseType } from '@waha/core/utils/events';
import { DistinctAck } from '@waha/core/utils/reactive';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { exclude } from '@waha/utils/reactive/ops/exclude';
import * as lodash from 'lodash';
import {
eventToLabelChatAssociationDTO,
eventToLabelDTO,
isLabelChatAddedEvent,
isLabelUpsertEvent,
} from './labels.gows';
enum WhatsMeowEvent {
CONNECTED = 'gows.ConnectedEventData',
@@ -147,6 +183,13 @@ enum WhatsMeowEvent {
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
// Labels
LABEL_EDIT = 'events.LabelEdit',
LABEL_ASSOCIATION_CHAT = 'events.LabelAssociationChat',
// Events
EVENT_MESSAGE_RESPONSE = 'gows.EventMessageResponse',
// Polls
POLL_VOTE_EVENT = 'gows.PollVoteEvent',
}
const gRPCClientConfig = {
@@ -347,10 +390,18 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
let [messagesFromMe$, messagesFromOthers$] = partition(messages$, isMine);
messagesFromMe$ = messagesFromMe$.pipe(
map((msg) => {
msg.Status = MessageStatus.ServerAck;
return msg;
}),
mergeMap((msg) => this.processIncomingMessage(msg, true)),
share(), // share it so we don't process twice in message.any
);
messagesFromOthers$ = messagesFromOthers$.pipe(
map((msg) => {
msg.Status = MessageStatus.DeliveryAck;
return msg;
}),
mergeMap((msg) => this.processIncomingMessage(msg, true)),
share(), // share it so we don't process twice in message.any
);
@@ -358,17 +409,57 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.events2.get(WAHAEvents.MESSAGE).switch(messagesFromOthers$);
this.events2.get(WAHAEvents.MESSAGE_ANY).switch(messagesFromAll$);
// Handle revoked messages
const messagesRevoked$ = messages$.pipe(
filter((msg) => {
return (
msg?.Message?.protocolMessage?.type === 0 &&
msg?.Message?.protocolMessage?.key !== undefined
);
}),
mergeMap(async (message): Promise<WAMessageRevokedBody> => {
const afterMessage = await this.toWAMessage(message);
// Extract the revoked message ID from protocolMessage.key
const revokedMessageId = message.Message.protocolMessage.key?.ID;
return {
after: afterMessage,
before: null,
revokedMessageId: revokedMessageId,
_data: message,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_REVOKED).switch(messagesRevoked$);
// Handle edited messages
const messagesEdited$ = messages$.pipe(
filter((msg) => {
return (
msg?.Message?.protocolMessage?.type === 14 &&
msg?.Message?.protocolMessage?.editedMessage !== undefined
);
}),
mergeMap(async (message): Promise<WAMessageEditedBody> => {
const waMessage = await this.toWAMessage(message);
// Extract the body from editedMessage using extractBody function
const body =
this.extractBody(message.Message.protocolMessage.editedMessage) || '';
// Extract the original message ID from protocolMessage.key
const editedMessageId = message.Message.protocolMessage.key?.ID;
return {
...waMessage,
body: body,
editedMessageId: editedMessageId,
_data: message,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_EDITED).switch(messagesEdited$);
const receipt$ = all$.pipe(onlyEvent(WhatsMeowEvent.RECEIPT));
const messageAck$ = receipt$.pipe(
mergeMap(this.receiptToMessageAck.bind(this)),
// Create a composite key for deduplication
groupBy((message) => `${message.id}-${message.ack}`),
mergeMap((group$) =>
group$.pipe(
debounceTime(1000), // Wait 1 second for deduplication
map((message) => message), // Pass the latest message after debounce
),
),
DistinctAck(),
);
this.events2.get(WAHAEvents.MESSAGE_ACK).switch(messageAck$);
@@ -415,6 +506,92 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$);
// Label Events
// First, create streams for the raw label events
const labelEditEvents$ = all$.pipe(
onlyEvent(WhatsMeowEvent.LABEL_EDIT),
exclude(isFromFullSync),
);
// Split the raw label edit events into upsert and deleted events
const [labelUpsertEvents$, labelDeletedEvents$] = partition(
labelEditEvents$,
isLabelUpsertEvent,
);
// Convert the events to DTOs
const labelUpsert$ = labelUpsertEvents$.pipe(map(eventToLabelDTO));
const labelDeleted$ = labelDeletedEvents$.pipe(map(eventToLabelDTO));
this.events2.get(WAHAEvents.LABEL_UPSERT).switch(labelUpsert$);
this.events2.get(WAHAEvents.LABEL_DELETED).switch(labelDeleted$);
//
// Polls
//
const pollVoteEvent$ = all$.pipe(
onlyEvent(WhatsMeowEvent.POLL_VOTE_EVENT),
map(this.toPollVotePayload.bind(this)),
filter(Boolean),
);
// Split into successful and failed responses
const [pollVoteSuccess$, pollVoteFailed$] = partition(
pollVoteEvent$,
(payload: PollVotePayload) => !!payload.vote.selectedOptions,
);
this.events2.get(WAHAEvents.POLL_VOTE).switch(pollVoteSuccess$);
this.events2.get(WAHAEvents.POLL_VOTE_FAILED).switch(pollVoteFailed$);
//
// Event Message
//
const eventMessageResponse$ = all$.pipe(
onlyEvent(WhatsMeowEvent.EVENT_MESSAGE_RESPONSE),
map(this.toEventResponsePayload.bind(this)),
filter(Boolean),
);
// Split into successful and failed responses
const [eventResponseSuccess$, eventResponseFailed$] = partition(
eventMessageResponse$,
(payload: EventResponsePayload) => !!payload.eventResponse,
);
this.events2.get(WAHAEvents.EVENT_RESPONSE).switch(eventResponseSuccess$);
this.events2
.get(WAHAEvents.EVENT_RESPONSE_FAILED)
.switch(eventResponseFailed$);
//
// Labels
//
// Handle label association events
const labelAssociationEvents$ = all$.pipe(
onlyEvent(WhatsMeowEvent.LABEL_ASSOCIATION_CHAT),
exclude(isFromFullSync),
);
// Split the raw label association events into added and deleted events
const [labelChatAddedEvents$, labelChatDeletedEvents$] = partition(
labelAssociationEvents$,
isLabelChatAddedEvent,
);
// Convert the events to DTOs
const labelChatAdded$ = labelChatAddedEvents$.pipe(
map(eventToLabelChatAssociationDTO),
);
const labelChatDeleted$ = labelChatDeletedEvents$.pipe(
map(eventToLabelChatAssociationDTO),
);
this.events2.get(WAHAEvents.LABEL_CHAT_ADDED).switch(labelChatAdded$);
this.events2.get(WAHAEvents.LABEL_CHAT_DELETED).switch(labelChatDeleted$);
}
async fetchContactProfilePicture(id: string): Promise<string> {
@@ -583,6 +760,37 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(jid, data);
}
async sendContactVCard(request: MessageContactVcardRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
const contacts = request.contacts.map((el) => ({ vcard: toVcard(el) }));
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
contacts: contacts.map((contact) => new messages.vCardContact(contact)),
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
async sendPoll(request: MessagePollRequest) {
const jid = toJID(request.chatId);
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
poll: new messages.PollMessage({
name: request.poll.name,
options: request.poll.options,
multipleAnswers: request.poll.multipleAnswers,
}),
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
public async deleteMessage(chatId: string, messageId: string) {
const jid = toJID(this.ensureSuffix(chatId));
const key = parseMessageIdSerialized(messageId);
@@ -703,15 +911,24 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
async sendSeen(request: SendSeenRequest) {
const key = parseMessageIdSerialized(request.messageId);
const req = new messages.MarkReadRequest({
session: this.session,
jid: key.remoteJid,
messageId: key.id,
sender: key.fromMe ? this.me.id : key.participant,
});
const response = await promisify(this.client.MarkRead)(req);
response.toObject();
const keys = ExtractMessageKeysForRead(request);
if (keys.length === 0) {
return;
}
const receipts = aggregateMessageKeysNotFromMe(keys);
for (const receipt of receipts) {
if (receipt.messageIds.length === 0) {
return;
}
const req = new messages.MarkReadRequest({
session: this.session,
jid: receipt.jid,
messageIds: receipt.messageIds,
sender: receipt.participant,
});
const response = await promisify(this.client.MarkRead)(req);
response.toObject();
}
return;
}
@@ -933,6 +1150,59 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(key.remoteJid, data);
}
async sendEvent(request: EventMessageRequest): Promise<WAMessage> {
const jid = toJID(this.ensureSuffix(request.chatId));
const event = request.event;
// Create EventLocation if provided
let location = null;
if (event.location) {
location = new messages.EventLocation({
name: event.location.name,
// Doesn't work right now
degreesLatitude: 0,
degreesLongitude: 0,
});
}
// Create event payload
const eventMessage = new messages.EventMessage({
name: event.name,
description: event.description,
startTime: event.startTime,
endTime: event.endTime,
location: location,
extraGuestsAllowed: event.extraGuestsAllowed,
});
// Create message
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
event: eventMessage,
replyTo: getMessageIdFromSerialized(request.reply_to),
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data) as any;
}
async cancelEvent(eventId: string): Promise<WAMessage> {
throw new Error('Method not implemented.');
const key = parseMessageIdSerialized(eventId, false);
const jid = key.remoteJid;
const request = new messages.CancelEventMessageRequest({
session: this.session,
jid: jid,
messageId: key.id,
});
const response = await promisify(this.client.CancelEventMessage)(request);
const data = response.toObject();
return this.messageResponse(jid, data) as any;
}
public async setPresence(presence: WAHAPresenceStatus, chatId?: string) {
let request: any;
let method: any;
@@ -993,13 +1263,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getPresence(chatId: string): Promise<WAHAChatPresences> {
const remoteJid = toJID(chatId);
if (!(remoteJid in this.presences.keys())) {
await this.subscribePresence(remoteJid);
const jid = toJID(chatId);
await this.subscribePresence(jid);
if (!(jid in this.presences.keys())) {
await sleep(1000);
}
const result = this.presences.get(remoteJid) || [];
return this.toWahaPresences(remoteJid, result);
const result = this.presences.get(jid) || [];
return this.toWahaPresences(jid, result);
}
async subscribePresence(chatId: string) {
@@ -1233,11 +1503,68 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return data.map(this.toWAContact.bind(this));
}
/**
* Lid to Phone Number methods
*/
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
const request = new messages.GetLidsRequest({
session: this.session,
});
const response = await promisify(this.client.GetAllLids)(request);
const data = parseJsonList(response);
const lids = data.map((item) => ({
lid: item.lid,
pn: toCusFormat(item.pn),
}));
// Use in-memory pagination
const paginator = new PaginatorInMemory(pagination);
return paginator.apply(lids);
}
public async getLidsCount(): Promise<number> {
const response = await promisify(this.client.GetLidsCount)(this.session);
return response?.value;
}
public async findPNByLid(lid: string): Promise<LidToPhoneNumber> {
const request = new messages.EntityByIdRequest({
session: this.session,
id: lid,
});
const response = await promisify(this.client.FindPNByLid)(request);
const phoneNumber = response?.value;
return {
lid: lid,
pn: phoneNumber ? toCusFormat(phoneNumber) : null,
};
}
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = toJID(phoneNumber);
const request = new messages.EntityByIdRequest({
session: this.session,
id: pn,
});
const response = await promisify(this.client.FindLIDByPhoneNumber)(request);
const lid = response.value;
return {
lid: lid || null,
pn: toCusFormat(pn),
};
}
/**
* Chats methods
*/
public async getChatsOverview(
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
if (!pagination.sortBy) {
pagination.sortBy = 'timestamp';
@@ -1245,7 +1572,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!pagination.sortOrder) {
pagination.sortOrder = SortOrder.DESC;
}
const chats = await this.getChats(pagination);
const chats = await this.getChats(pagination, filter);
const promises = [];
for (const chat of chats) {
@@ -1284,10 +1611,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
public async getChats(pagination: PaginationParams) {
public async getChats(
pagination: PaginationParams,
filter: OverviewFilter | null = null,
) {
if (pagination.sortBy === ChatSortField.CONVERSATION_TIMESTAMP) {
pagination.sortBy = 'timestamp';
}
let jids = [];
if (filter?.ids && filter.ids.length > 0) {
jids = filter.ids.map((id) => toJID(id));
}
const request = new messages.GetChatsRequest({
session: this.session,
pagination: new messages.Pagination({
@@ -1301,6 +1635,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
? messages.SortBy.Order.DESC
: messages.SortBy.Order.ASC,
}),
filter: new messages.ChatFilter({
jids: jids,
}),
});
const response = await promisify(this.client.GetChats)(request);
const data = parseJsonList(response);
@@ -1322,6 +1659,8 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
});
}
const status =
filter['filter.ack'] != null ? AckToStatus(filter['filter.ack']) : null;
const request = new messages.GetMessagesRequest({
session: this.session,
filters: new messages.MessageFilters({
@@ -1335,6 +1674,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
messages.OptionalUInt64,
),
fromMe: optional(filter['filter.fromMe'], messages.OptionalBool),
status: optional(status, messages.OptionalUInt32),
}),
pagination: new messages.Pagination({
limit: query.limit,
@@ -1352,6 +1692,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return result;
}
public readChatMessages(
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse> {
return this.readChatMessagesWSImpl(chatId, request);
}
public async getChatMessage(
chatId: string,
messageId: string,
@@ -1367,6 +1714,119 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.processIncomingMessage(msg, query.downloadMedia);
}
/**
* Labels methods
*/
public async getLabels(): Promise<Label[]> {
const request = new messages.GetLabelsRequest({
session: this.session,
});
const response = await promisify(this.client.GetLabels)(request);
const labels = parseJsonList(response);
return labels.map(this.toLabel);
}
public async createLabel(labelDto: LabelDTO): Promise<Label> {
const labels = await this.getLabels();
const highestLabelId = lodash.max(
labels.map((label) => parseInt(label.id)),
);
const labelId = highestLabelId ? highestLabelId + 1 : 1;
const label: Label = {
id: labelId.toString(),
name: labelDto.name,
color: labelDto.color,
colorHex: Label.toHex(labelDto.color),
};
return this.updateLabel(label);
}
protected toLabel(label: any): Label {
const color = label.color;
return {
id: label.id,
name: label.name,
color: color,
colorHex: Label.toHex(color),
};
}
public async updateLabel(label: Label): Promise<Label> {
const request = new messages.UpsertLabelRequest({
session: this.session,
label: new messages.Label({
id: label.id,
name: label.name,
color: label.color,
}),
});
await promisify(this.client.UpsertLabel)(request);
return label;
}
public async deleteLabel(label: Label): Promise<void> {
const request = new messages.DeleteLabelRequest({
session: this.session,
label: new messages.Label({
id: label.id,
name: label.name,
color: label.color,
}),
});
await promisify(this.client.DeleteLabel)(request);
}
public async getChatsByLabelId(labelId: string) {
const request = new messages.EntityByIdRequest({
session: this.session,
id: labelId,
});
const response = await promisify(this.client.GetChatsByLabelId)(request);
const ids = parseJsonList(response);
return ids.map((jid) => {
return {
id: toCusFormat(jid),
};
});
}
public async getChatLabels(chatId: string): Promise<Label[]> {
const jid = toJID(chatId);
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
});
const response = await promisify(this.client.GetLabelsByJid)(request);
const labels = parseJsonList(response);
return labels.map(this.toLabel);
}
public async putLabelsToChat(chatId: string, labels: LabelID[]) {
const jid = toJID(chatId);
const labelsIds = labels.map((label) => label.id);
const currentLabels = await this.getChatLabels(jid);
const currentLabelsIds = currentLabels.map((label) => label.id);
const addLabelsIds = lodash.difference(labelsIds, currentLabelsIds);
const removeLabelsIds = lodash.difference(currentLabelsIds, labelsIds);
for (const labelId of addLabelsIds) {
const request = new messages.ChatLabelRequest({
session: this.session,
labelId: labelId,
chatId: jid,
});
await promisify(this.client.AddChatLabel)(request);
}
for (const labelId of removeLabelsIds) {
const request = new messages.ChatLabelRequest({
session: this.session,
labelId: labelId,
chatId: jid,
});
await promisify(this.client.RemoveChatLabel)(request);
}
}
//
// END - Methods for API
//
@@ -1375,14 +1835,21 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
// if there is no text or media message
if (!message) return;
if (!message.Message) return;
// Ignore reactions, we have dedicated handler for that
// Ignore reactions, we have a dedicated handler for that
if (message.Message.reactionMessage) return;
// Ignore poll votes, we have dedicated handler for that
// Ignore poll votes, we have a dedicated handler for that
if (message.Message.pollUpdateMessage) return;
// Ignore event response, we have a dedicated handler for that
if (message.Message.encEventResponseMessage) return;
// Ignore protocol messages
if (message.Message.protocolMessage) return;
// Ignore key distribution messages
if (message.Message.senderKeyDistributionMessage) return;
const normalizedContent = normalizeMessageContent(message.Message);
const hasSomeContent = !!getContentType(normalizedContent);
if (!hasSomeContent) {
// Ignore key distribution messages
if (message?.Message?.senderKeyDistributionMessage) return;
}
if (downloadMedia) {
try {
@@ -1405,11 +1872,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const id = buildMessageId(message);
const body = this.extractBody(message.Message);
const replyTo = this.extractReplyTo(message.Message);
let ack;
if (message.Status) {
ack = statusToAck(message.Status);
} else {
ack = message.Info.IsFromMe ? WAMessageAck.SERVER : WAMessageAck.DEVICE;
let ack = statusToAck(message.Status);
if (ack === WAMessageAck.ERROR) {
// GOWS error because of how golang treats it as null
// It'll be UNKNOWN instead of ERROR
ack = null;
}
const mediaContent = extractMediaContent(message.Message);
const source = this.getSourceDeviceByMsg(message);
@@ -1436,6 +1903,63 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
private toPollVotePayload(event: any): PollVotePayload {
// Extract event creation message key from the message
const creationKey = event.Message?.pollUpdateMessage.pollCreationMessageKey;
const key: WAMessageKey = {
remoteJid: creationKey.remoteJID,
fromMe: creationKey.fromMe,
id: creationKey.ID,
participant: creationKey.participant,
};
const fromToParticipant = getFromToParticipant(event);
const pollCreationKey = getDestination(key);
return {
poll: pollCreationKey,
vote: {
id: buildMessageId(event),
from: toCusFormat(fromToParticipant.from),
fromMe: event.Info.IsFromMe,
to: toCusFormat(fromToParticipant.to),
participant: toCusFormat(fromToParticipant.participant),
selectedOptions: event.Votes,
timestamp: event.Message.pollUpdateMessage.senderTimestampMS,
},
_data: event,
};
}
private toEventResponsePayload(event: any): EventResponsePayload {
const msg = this.toWAMessage(event);
let response: EventResponse | null = null;
if (event.EventResponse) {
response = {
response: ParseEventResponseType(event.EventResponse.response),
timestampMs: event.EventResponse.timestampMS,
extraGuestCount: event.EventResponse.extraGuestCount || 0,
};
}
// Extract event creation message key from the message
const message = event.Message || event.message;
const eventCreationMessageKey =
message?.encEventResponseMessage?.eventCreationMessageKey;
const key: WAMessageKey = {
remoteJid: eventCreationMessageKey.remoteJID,
fromMe: eventCreationMessageKey.fromMe,
id: eventCreationMessageKey.ID,
participant: eventCreationMessageKey.participant,
};
const eventCreationKey = getDestination(key);
return {
...msg,
eventCreationKey: eventCreationKey,
eventResponse: response,
_data: event,
};
}
private getSourceDeviceByMsg(message): MessageSource {
if (!message.Info.IsFromMe) {
return MessageSource.APP;
+28
View File
@@ -39,3 +39,31 @@ export enum MessageStatus {
Read = 4,
Played = 5,
}
export interface LabelEditAction {
name: string;
color: number;
deleted: boolean;
orderIndex: number;
isActive: boolean;
type: number;
}
export interface LabelEdit {
Timestamp: string;
LabelID: string;
Action: LabelEditAction;
FromFullSync: boolean;
}
export interface LabelAssociationChatAction {
labeled: boolean;
}
export interface LabelAssociationChat {
JID: string;
Timestamp: string;
LabelID: string;
Action: LabelAssociationChatAction;
FromFullSync: boolean;
}
+248 -103
View File
@@ -2,6 +2,7 @@ import makeWASocket, {
Browsers,
Chat,
Contact,
decryptPollVote,
DisconnectReason,
downloadMediaMessage,
extractMessageContent,
@@ -36,17 +37,16 @@ import {
LabelAssociationType,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message';
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
import {
isJidBroadcast,
isLidUser,
} from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { Logger as BaileysLogger } from '@adiwajshing/baileys/node_modules/pino';
import { UnprocessableEntityException } from '@nestjs/common';
import {
ensureSuffix,
getChannelInviteLink,
getPublicUrlFromDirectPath,
isNewsletter,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
@@ -66,6 +66,11 @@ import { toVcard } from '@waha/core/helpers';
import { createAgentProxy } from '@waha/core/helpers.proxy';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { QR } from '@waha/core/QR';
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { isJidNewsletter, toJID } from '@waha/core/utils/jids';
import { DistinctAck } from '@waha/core/utils/reactive';
import { flipObject, splitAt } from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import { CallData } from '@waha/structures/calls.dto';
@@ -85,7 +90,10 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
OverviewFilter,
PinDuration,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
} from '@waha/structures/chats.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import {
@@ -131,6 +139,7 @@ import {
LabelDTO,
LabelID,
} from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import {
@@ -150,6 +159,7 @@ import {
PollVote,
PollVotePayload,
WAMessageAckBody,
WAMessageEditedBody,
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { LoggerBuilder } from '@waha/utils/logging';
@@ -174,13 +184,13 @@ import {
share,
} from 'rxjs';
import { map } from 'rxjs/operators';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
import { INowebStore } from './store/INowebStore';
import { NowebPersistentStore } from './store/NowebPersistentStore';
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
import { ensureNumber, extractMediaContent } from './utils';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
export const BaileysEvents = {
CONNECTION_UPDATE: 'connection.update',
@@ -214,7 +224,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
private autoRestartJob: SinglePeriodicJobRunner;
private msgRetryCounterCache: NodeCache;
private placeholderResendCache: NodeCache;
protected engineLogger: BaileysLogger;
protected engineLogger: ILogger;
private authNOWEBStore: any;
@@ -242,7 +252,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.engineLogger = this.loggerBuilder.child({
name: 'NOWEBEngine',
}) as unknown as BaileysLogger;
}) as unknown as ILogger;
// Restart job if session failed
this.startDelayedJob = new SingleDelayedJobRunner(
@@ -378,7 +388,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
resubscribeToKnownPresences() {
for (const jid in this.store.presences) {
this.sock.presenceSubscribe(jid);
this.subscribePresence(jid);
}
}
@@ -391,6 +401,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.sock = await this.makeSocket();
this.issueMessageUpdateOnEdits();
this.issueMessageUpdateOnPoll();
this.fixMessageUpsertStatus();
this.issuePresenceUpdateOnMessageUpsert();
if (this.isDebugEnabled()) {
this.listenEngineEventsInDebugMode();
@@ -458,7 +470,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
} else if (connection === 'open') {
this.qr.save('');
this.status = WAHASessionStatus.WORKING;
this.resubscribeToKnownPresences();
// Do we need to resubscribe?
// Ideally not, we need to explicitly call interesting
// jids every 1 minute
// this.resubscribeToKnownPresences();
return;
} else if (connection === 'close') {
this.qr.save('');
@@ -560,6 +575,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
await this.store?.close();
}
private fixMessageUpsertStatus() {
// If no status - set it to WAMessageAck.DEVICE
this.sock.ev.on('messages.upsert', ({ messages }) => {
for (const message of messages) {
if (message.status == null) {
message.status = AckToStatus(WAMessageAck.DEVICE);
}
}
});
}
private issueMessageUpdateOnEdits() {
// Remove it after it's been merged
// https://github.com/WhiskeySockets/Baileys/pull/855/
@@ -570,7 +596,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (
protocolMsg !== null &&
protocolMsg !== undefined &&
protocolMsg.editedMessage
protocolMsg.editedMessage &&
protocolMsg.key
) {
this.sock?.ev.emit('messages.update', [
{
@@ -586,6 +613,67 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
});
}
private issueMessageUpdateOnPoll() {
// Fix for https://github.com/devlikeapro/waha/issues/960
this.sock.ev.on('messages.upsert', async ({ messages }) => {
const meId = this.getSessionMeInfo().id;
if (!meId) {
return;
}
for (const message of messages) {
const content = normalizeMessageContent(message.message);
if (!content?.pollUpdateMessage) {
continue;
}
const creationMsgKey = content.pollUpdateMessage.pollCreationMessageKey;
// we need to fetch the poll creation message to get the poll enc key
const pollMsg = await this.getMessage(creationMsgKey);
if (!pollMsg) {
this.logger.warn(
{ creationMsgKey },
'poll creation message not found, cannot decrypt update',
);
continue;
}
const meIdNormalised = jidNormalizedUser(meId);
const pollCreatorJid = getKeyAuthor(creationMsgKey, meIdNormalised);
const voterJid = getKeyAuthor(message.key, meIdNormalised);
const pollEncKey = pollMsg.messageContextInfo?.messageSecret;
try {
const voteMsg = decryptPollVote(content.pollUpdateMessage.vote, {
pollEncKey,
pollCreatorJid,
pollMsgId: creationMsgKey.id,
voterJid,
});
this.sock.ev.emit('messages.update', [
{
key: creationMsgKey,
update: {
pollUpdates: [
{
pollUpdateMessageKey: message.key,
vote: voteMsg,
senderTimestampMs: (
content.pollUpdateMessage.senderTimestampMs as Long
).toNumber(),
},
],
},
},
]);
} catch (err) {
this.logger.warn(
{ err, creationMsgKey },
'failed to decrypt poll vote',
);
}
}
});
}
private issuePresenceUpdateOnMessageUpsert() {
//
// Fix for "typing" after sending a message
@@ -893,16 +981,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
async sendSeen(request: SendSeenRequest) {
const key = parseMessageIdSerialized(request.messageId);
const participant = request.participant
? toJID(this.ensureSuffix(request.participant))
: undefined;
const data = {
remoteJid: key.remoteJid,
id: key.id,
participant: participant,
};
return this.sock.readMessages([data]);
const keys = ExtractMessageKeysForRead(request);
if (keys.length === 0) {
return;
}
// Send read
await this.sock.readMessages(keys);
// Emit events for our reads
const updates = keys.map((key) => ({
key: key,
update: { status: AckToStatus(WAMessageAck.READ) },
}));
this.sock?.ev.emit('messages.update', updates);
}
async startTyping(request: ChatRequest) {
@@ -937,6 +1029,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return result;
}
public readChatMessages(
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse> {
return this.readChatMessagesWSImpl(chatId, request);
}
public async getChatMessage(
chatId: string,
messageId: string,
@@ -978,7 +1077,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
async setReaction(request: MessageReactionRequest) {
const key = parseMessageIdSerialized(request.messageId);
if (isNewsletter(key.remoteJid)) {
if (isJidNewsletter(key.remoteJid)) {
let serverId = Number(key.id);
if (!serverId) {
const msg = await this.store.getMessageById(key.remoteJid, key.id);
@@ -1034,8 +1133,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
public async getChatsOverview(
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
const chats = await this.store.getChats(pagination, false);
// Convert customer format IDs to JID format if filter is provided
let jidFilter;
if (filter?.ids && filter.ids.length > 0) {
jidFilter = {
ids: filter.ids.map((id) => toJID(id)),
};
}
const chats = await this.store.getChats(pagination, false, jidFilter);
// Remove unreadCount, it's not ready yet
chats.forEach((chat) => delete chat.unreadCount);
@@ -1247,6 +1355,44 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
throw new NotImplementedByEngineError();
}
/**
* Lid to Phone Number methods
*/
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
const lids = await this.store.getAllLids(pagination);
return lids.map((value) => {
return {
lid: value.lid,
pn: toCusFormat(value.pn),
};
});
}
public async getLidsCount(): Promise<number> {
return this.store.getLidsCount();
}
public async findPNByLid(lid: string): Promise<LidToPhoneNumber> {
const pn = await this.store.findPNByLid(lid);
return {
lid: lid,
pn: pn ? toCusFormat(pn) : null,
};
}
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = toJID(phoneNumber);
const lid = await this.store.findLidByPN(pn);
return {
lid: lid || null,
pn: toCusFormat(pn),
};
}
/**
* Group methods
*/
@@ -1381,14 +1527,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
public async getPresence(chatId: string): Promise<WAHAChatPresences> {
const remoteJid = toJID(chatId);
if (!(remoteJid in this.store.presences)) {
this.store.presences[remoteJid] = {};
await this.sock.presenceSubscribe(remoteJid);
const jid = toJID(chatId);
await this.subscribePresence(jid);
if (!(jid in this.store.presences)) {
this.store.presences[jid] = {};
await sleep(1000);
}
const result = this.store.presences[remoteJid];
return this.toWahaPresences(remoteJid, result);
const result = this.store.presences[jid];
return this.toWahaPresences(jid, result);
}
public subscribePresence(id: string): Promise<void> {
@@ -1680,20 +1826,50 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
// @ts-ignore
filter(
(message) =>
message.message.protocolMessage?.type ===
message.message?.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.REVOKE,
),
mergeMap(async (message): Promise<WAMessageRevokedBody> => {
const afterMessage = await this.toWAMessage(message);
// Extract the revoked message ID from protocolMessage.key
const revokedMessageId = message.message.protocolMessage.key?.id;
return {
after: afterMessage,
before: null,
revokedMessageId: revokedMessageId,
_data: message,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_REVOKED).switch(messagesRevoked$);
// Handle edited messages
// @ts-ignore
const messagesEdited$ = messagesUpsert$.pipe(
filter(
(message) =>
// @ts-ignore
message.message?.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.MESSAGE_EDIT &&
message.message?.protocolMessage?.editedMessage !== undefined,
),
mergeMap(async (message): Promise<WAMessageEditedBody> => {
const waMessage = await this.toWAMessage(message);
// Extract the body from editedMessage using extractBody function
const body =
this.extractBody(message.message.protocolMessage.editedMessage) || '';
// Extract the original message ID from protocolMessage.key
const editedMessageId = message.message.protocolMessage.key?.id;
return {
...waMessage,
body: body,
editedMessageId: editedMessageId,
_data: message,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_EDITED).switch(messagesEdited$);
//
// Message Reactions
//
@@ -1728,7 +1904,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
filter(isMine), // ack comes only for MY messages
map(this.convertMessageReceiptUpdateToMessageAck.bind(this)),
);
const messageAck$ = merge(messageAckDirect$, messageAckGroups$);
const messageAck$ = merge(messageAckDirect$, messageAckGroups$).pipe(
DistinctAck(),
);
this.events2.get(WAHAEvents.MESSAGE_ACK).switch(messageAck$);
//
@@ -1925,7 +2103,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!message.message.reactionMessage) return null;
const id = buildMessageId(message.key);
const fromToParticipant = getFromToParticipant(message);
const fromToParticipant = getFromToParticipant(message.key);
const reactionMessage = message.message.reactionMessage;
const messageId = buildMessageId(reactionMessage.key);
const source = this.getMessageSource(message.key.id);
@@ -1955,19 +2133,30 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (message.message.pollUpdateMessage) return;
// Ignore calls, we have dedicated handler for that
if (message.message.call?.callKey) return;
// Ignore revoke, we have a dedicated handler for that
// Ignore revoke, we have a dedicated event for that
if (
message.message.protocolMessage?.type ===
message.message?.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.REVOKE
)
return;
// Ignore edit, we have a dedicated event for that
if (
message.message.protocolMessage?.type ===
message.message?.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.MESSAGE_EDIT
)
return;
if (
message.message?.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.EPHEMERAL_SYNC_RESPONSE
)
return;
// Ignore key distribution messages
if (message.message.senderKeyDistributionMessage) return;
const normalizedContent = normalizeMessageContent(message.message);
const hasSomeContent = !!getContentType(normalizedContent);
if (!hasSomeContent) {
// Ignore key distribution messages
if (message?.message?.senderKeyDistributionMessage) return;
}
if (downloadMedia) {
try {
@@ -1989,11 +2178,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
protected toWAMessage(message): Promise<WAMessage> {
const fromToParticipant = getFromToParticipant(message);
const fromToParticipant = getFromToParticipant(message.key);
const id = buildMessageId(message.key);
const body = this.extractBody(message.message);
const replyTo = this.extractReplyTo(message.message);
const ack = message.ack || message.status - 1;
const ack = message.ack || StatusToAck(message.status);
const mediaContent = extractMediaContent(message.message);
const source = this.getMessageSource(message.key.id);
return Promise.resolve({
@@ -2080,9 +2269,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected convertMessageUpdateToMessageAck(event): WAMessageAckBody {
const message = event;
const fromToParticipant = getFromToParticipant(message);
const fromToParticipant = getFromToParticipant(message.key);
const id = buildMessageId(message.key);
const ack = message.update.status - 1;
const ack = StatusToAck(message.update.status);
const body: WAMessageAckBody = {
id: id,
from: toCusFormat(fromToParticipant.from),
@@ -2096,8 +2285,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
protected convertMessageReceiptUpdateToMessageAck(event): WAMessageAckBody {
const fromToParticipant = getFromToParticipant(event);
const id = buildMessageId(event.key);
const fromToParticipant = getFromToParticipant(event.key);
const receipt = event.receipt;
let ack;
@@ -2108,6 +2296,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
} else if (receipt.readTimestamp) {
ack = WAMessageAck.READ;
}
const key = { ...event.key };
if (key.fromMe) {
key.participant = this.getSessionMeInfo()?.id;
} else {
key.participant = event.receipt.userJid;
}
const id = buildMessageId(key);
const body: WAMessageAckBody = {
id: id,
from: toCusFormat(fromToParticipant.from),
@@ -2116,6 +2313,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
fromMe: event.key.fromMe,
ack: ack,
ackName: WAMessageAck[ack] || ACK_UNKNOWN,
_data: event,
};
return body;
}
@@ -2291,7 +2489,7 @@ function hasPath(url: string) {
}
export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
private readonly logger: BaileysLogger;
private readonly logger: ILogger;
constructor(
public session: WhatsappSessionNoWebCore,
@@ -2299,7 +2497,7 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
) {
this.logger = loggerBuilder.child({
name: NOWEBEngineMediaProcessor.name,
}) as unknown as BaileysLogger;
}) as unknown as ILogger;
}
hasMedia(message: any): boolean {
@@ -2366,7 +2564,7 @@ export function toCusFormat(remoteJid) {
if (isLidUser(remoteJid)) {
return remoteJid;
}
if (isNewsletter(remoteJid)) {
if (isJidNewsletter(remoteJid)) {
return remoteJid;
}
if (!remoteJid) {
@@ -2383,27 +2581,6 @@ export function toCusFormat(remoteJid) {
export const ALL_JID = 'all@s.whatsapp.net';
/**
* Convert from 11111111111@c.us to 11111111111@s.whatsapp.net
* @param chatId
*/
export function toJID(chatId) {
if (isJidGroup(chatId)) {
return chatId;
}
if (isJidBroadcast(chatId)) {
return chatId;
}
if (isNewsletter(chatId)) {
return chatId;
}
if (isLidUser(chatId)) {
return chatId;
}
const number = chatId.split('@')[0];
return number + '@s.whatsapp.net';
}
/**
* Build WAHA message id from engine one
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
@@ -2418,38 +2595,6 @@ function buildMessageId({ id, remoteJid, fromMe, participant }: WAMessageKey) {
return parts.join('_');
}
/**
* Parse message id from WAHA to engine
* false_11111111111@c.us_AAA
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
*/
export function parseMessageIdSerialized(
messageId: string,
soft: boolean = false,
): WAMessageKey {
if (!messageId.includes('_') && soft) {
return { id: messageId };
}
const parts = messageId.split('_');
if (parts.length != 3 && parts.length != 4) {
throw new Error(
'Message id be in format false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA[_participant]',
);
}
const fromMe = parts[0] == 'true';
const chatId = parts[1];
const remoteJid = toJID(chatId);
const id = parts[2];
const participant = parts[3] ? toJID(parts[3]) : undefined;
return {
fromMe: fromMe,
id: id,
remoteJid: remoteJid,
participant: participant,
};
}
function getId(object) {
return object.id;
}
@@ -2466,15 +2611,15 @@ function isAckUpdateMessageEvent(event) {
return event?.update.status != null;
}
function getFromToParticipant(message) {
const isGroupMessage = Boolean(message.key.participant);
export function getFromToParticipant(key) {
const isGroupMessage = Boolean(key.participant);
let participant: string;
let to: string;
if (isGroupMessage) {
participant = message.key.participant;
to = message.key.remoteJid;
participant = key.participant;
to = key.remoteJid;
}
const from = message.key.remoteJid;
const from = key.remoteJid;
return {
from: from,
to: to,
@@ -2506,7 +2651,7 @@ function getFrom(key, meId) {
return key.remoteJid;
}
function getDestination(key, meId = undefined): MessageDestination {
export function getDestination(key, meId = undefined): MessageDestination {
return {
id: buildMessageId(key),
to: toCusFormat(getTo(key, meId)),
@@ -1,4 +1,5 @@
import { Chat } from '@adiwajshing/baileys';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
export interface IChatRepository {
@@ -9,6 +10,7 @@ export interface IChatRepository {
getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
): Promise<Chat[]>;
getById(id: string): Promise<Chat | null>;
@@ -18,4 +20,6 @@ export interface IChatRepository {
deleteById(id: string): Promise<void>;
save(chat: Chat): Promise<void>;
upsertMany(chats: Chat[]): Promise<void>;
}
@@ -11,4 +11,8 @@ export interface IContactRepository {
deleteById(id: string): Promise<void>;
save(contact: Contact): Promise<void>;
upsertMany(contacts: Contact[]): Promise<void>;
getEntitiesByIds(ids: string[]): Promise<Map<string, Contact | null>>;
}
@@ -0,0 +1,33 @@
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
export class LidToPN {
id: string;
pn: string;
}
export interface INowebLidPNRepository {
/**
* Save lid to phone number mapping
*/
saveLids(lids: LidToPN[]): Promise<void>;
/**
* Get all lids with pagination
*/
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]>;
/**
* Get a total count of lids
*/
getLidsCount(): Promise<number>;
/**
* Find a phone number by lid
*/
findPNByLid(lid: string): Promise<string | null>;
/**
* Find lid by phone number
*/
findLidByPN(pn: string): Promise<string | null>;
}
@@ -5,6 +5,7 @@ import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsReposit
import { IChatRepository } from './IChatRepository';
import { IContactRepository } from './IContactRepository';
import { IMessagesRepository } from './IMessagesRepository';
import { INowebLidPNRepository } from './INowebLidPNRepository';
export abstract class INowebStorage {
abstract init(): Promise<void>;
@@ -22,4 +23,6 @@ export abstract class INowebStorage {
abstract getLabelsRepository(): ILabelsRepository;
abstract getLabelAssociationRepository(): ILabelAssociationRepository;
abstract getLidPNRepository(): INowebLidPNRepository;
}
+23 -3
View File
@@ -6,8 +6,15 @@ import {
} from '@adiwajshing/baileys';
import { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import {
GetChatMessagesFilter,
OverviewFilter,
} from '@waha/structures/chats.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import {
LimitOffsetParams,
PaginationParams,
} from '@waha/structures/pagination.dto';
export interface INowebStore {
presences: any;
@@ -28,7 +35,11 @@ export interface INowebStore {
getMessageById(chatId: string, messageId: string): Promise<any>;
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]>;
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
): Promise<Chat[]>;
getChat(jid: string): Promise<Chat | null>;
@@ -47,4 +58,13 @@ export interface INowebStore {
getGroups(pagination: PaginationParams): Promise<GroupMetadata[]>;
resetGroupsCache(): void;
// Lid Repository methods
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPhoneNumber[]>;
getLidsCount(): Promise<number>;
findPNByLid(lid: string): Promise<string | null>;
findLidByPN(pn: string): Promise<string | null>;
}
@@ -2,16 +2,20 @@ import makeWASocket, {
Chat,
Contact,
GroupMetadata,
makeInMemoryStore,
proto,
} from '@adiwajshing/baileys';
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { BadRequestException } from '@nestjs/common';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import {
LimitOffsetParams,
PaginationParams,
} from '@waha/structures/pagination.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
import { INowebStore } from './INowebStore';
import makeInMemoryStore from './memory/make-in-memory-store';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const logger = require('pino')();
@@ -107,4 +111,23 @@ export class NowebInMemoryStore implements INowebStore {
resetGroupsCache() {
return;
}
//
// Lids methods
//
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPhoneNumber[]> {
throw new BadRequestException(this.errorMessage);
}
findLidByPN(pn: string): Promise<string | null> {
throw new BadRequestException(this.errorMessage);
}
findPNByLid(lid: string): Promise<string | null> {
throw new BadRequestException(this.errorMessage);
}
getLidsCount(): Promise<number> {
throw new BadRequestException(this.errorMessage);
}
}
@@ -5,6 +5,7 @@ import makeWASocket, {
ChatUpdate,
Contact,
GroupParticipant,
isJidUser,
isRealMessage,
jidNormalizedUser,
ParticipantAction,
@@ -18,13 +19,22 @@ import {
LabelAssociation,
LabelAssociationType,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import {
GetChatMessagesFilter,
OverviewFilter,
} from '@waha/structures/chats.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import {
LimitOffsetParams,
PaginationParams,
SortOrder,
} from '@waha/structures/pagination.dto';
import { DefaultMap } from '@waha/utils/DefaultMap';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { waitUntil } from '@waha/utils/promiseTimeout';
import * as lodash from 'lodash';
import { toNumber } from 'lodash';
import { Logger } from 'pino';
@@ -32,6 +42,7 @@ import { Logger } from 'pino';
import { IChatRepository } from './IChatRepository';
import { IContactRepository } from './IContactRepository';
import { IMessagesRepository } from './IMessagesRepository';
import { INowebLidPNRepository, LidToPN } from './INowebLidPNRepository';
import { INowebStorage } from './INowebStorage';
import { INowebStore } from './INowebStore';
@@ -49,9 +60,17 @@ export class NowebPersistentStore implements INowebStore {
private messagesRepo: IMessagesRepository;
private labelsRepo: ILabelsRepository;
private labelAssociationsRepo: ILabelAssociationRepository;
private lidRepo: INowebLidPNRepository;
public presences: any;
private lock: any;
private lock: any = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
maxExecutionTime: 60_000,
});
private groupsFetchLock: any = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
maxExecutionTime: 60_000,
});
@@ -71,8 +90,8 @@ export class NowebPersistentStore implements INowebStore {
this.messagesRepo = storage.getMessagesRepository();
this.labelsRepo = storage.getLabelsRepository();
this.labelAssociationsRepo = storage.getLabelAssociationRepository();
this.lidRepo = storage.getLidPNRepository();
this.presences = {};
this.lock = new AsyncLock({ maxPending: Infinity });
}
init(): Promise<void> {
@@ -112,9 +131,16 @@ export class NowebPersistentStore implements INowebStore {
ev.on('groups.upsert', (data) =>
this.withLock('groups', () => this.onGroupUpsert(data)),
);
ev.on('groups.update', (data) =>
this.withLock('groups', () => this.onGroupUpdate(data)),
);
ev.on('groups.update', (data) => {
this.withLock('groups', () => this.onGroupUpdate(data));
this.withLock('lids', async () => {
const participants = lodash.flatMap(data, (g) => g?.participants || []);
const lids = await this.handleLidPNUpdates(participants);
this.logger.debug(
`groups.update - '${lids.length}' synced lid to pn mapping`,
);
});
});
ev.on('group-participants.update', (data) =>
this.withLock(`group-${data.id}`, () =>
this.onGroupParticipantsUpdate(data),
@@ -122,12 +148,24 @@ export class NowebPersistentStore implements INowebStore {
);
// Contacts
ev.on('contacts.upsert', (data) =>
this.withLock('contacts', () => this.onContactsUpsert(data)),
);
ev.on('contacts.update', (data) =>
this.withLock('contacts', () => this.onContactUpdate(data)),
);
ev.on('contacts.upsert', (data) => {
this.withLock('contacts', () => this.onContactsUpsert(data));
this.withLock('lids', async () => {
const lids = await this.handleLidPNUpdates(data);
this.logger.debug(
`contacts.upsert - '${lids.length}' synced lid to pn mapping`,
);
});
});
ev.on('contacts.update', (data) => {
this.withLock('contacts', () => this.onContactUpdate(data));
this.withLock('lids', async () => {
const lids = await this.handleLidPNUpdates(data);
this.logger.debug(
`contacts.update - '${lids.length}' synced lid to pn mapping`,
);
});
});
ev.on('labels.edit', (data) => this.onLabelsEdit(data));
ev.on('labels.association', ({ association, type }) =>
this.onLabelsAssociation(association, type),
@@ -162,6 +200,12 @@ export class NowebPersistentStore implements INowebStore {
await this.onContactsUpsert(contacts);
this.logger.info(`history sync - '${contacts.length}' synced contacts`);
}),
this.withLock('lids', async () => {
const lids = await this.handleLidPNUpdates(contacts);
this.logger.info(
`history sync - '${lids.length}' synced lid to pn mapping`,
);
}),
this.withLock('chats', () => this.onChatUpsert(chats)),
this.withLock('messages', () => this.syncMessagesHistory(messages)),
]);
@@ -192,11 +236,31 @@ export class NowebPersistentStore implements INowebStore {
for (const update of updates) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const jid = jidNormalizedUser(update.key.remoteJid!);
if (!update.key.id) {
continue;
}
const message = await this.messagesRepo.getByJidById(jid, update.key.id);
if (!message) {
this.logger.warn(
`got update for non-existent message. update: '${JSON.stringify(
update,
)}'`,
);
continue;
}
const fields = { ...update.update };
// check if fields has only "status" field
const onlyStatusField =
Object.keys(fields).length === 1 &&
'status' in fields &&
fields.status !== null;
if (onlyStatusField) {
// if so, check the message don't have a newer status
if (message.status >= fields.status) {
continue;
}
}
// It can overwrite the key, so we need to delete it
delete fields['key'];
Object.assign(message, fields);
@@ -226,8 +290,8 @@ export class NowebPersistentStore implements INowebStore {
for (const chat of chats) {
delete chat['messages'];
chat.conversationTimestamp = toNumber(chat.conversationTimestamp) || null;
await this.chatRepo.save(chat);
}
await this.chatRepo.upsertMany(chats);
this.logger.info(`store sync - '${chats.length}' synced chats`);
}
@@ -333,15 +397,19 @@ export class NowebPersistentStore implements INowebStore {
}
private async onContactsUpsert(contacts: Contact[]) {
const upserts = [];
const ids = contacts.map((c) => c.id);
const contactById = await this.contactRepo.getEntitiesByIds(ids);
for (const update of contacts) {
const contact = await this.contactRepo.getById(update.id);
const contact = contactById.get(update.id) || {};
// remove undefined from data
Object.keys(update).forEach(
(key) => update[key] === undefined && delete update[key],
);
const result = { ...(contact || {}), ...update };
await this.contactRepo.save(result);
const result = { ...contact, ...update };
upserts.push(result);
}
await this.contactRepo.upsertMany(upserts);
}
private async onContactUpdate(updates: Partial<Contact>[]) {
@@ -461,10 +529,14 @@ export class NowebPersistentStore implements INowebStore {
return this.messagesRepo.getByJidById(chatId, messageId);
}
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]> {
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
): Promise<Chat[]> {
pagination.sortBy ||= 'conversationTimestamp';
pagination.sortOrder ||= SortOrder.DESC;
return this.chatRepo.getAllWithMessages(pagination, broadcast);
return this.chatRepo.getAllWithMessages(pagination, broadcast, filter);
}
async getChat(jid: string): Promise<Chat | null> {
@@ -538,4 +610,64 @@ export class NowebPersistentStore implements INowebStore {
const ids = associations.map((association) => association.labelId);
return await this.labelsRepo.getAllByIds(ids);
}
//
// Lid methods
//
private async handleLidPNUpdates(contacts: Array<Partial<Contact>>) {
let lids: LidToPN[] = [];
for (const contact of contacts) {
// contact.id = pn, contact.lid = lid
if (isJidUser(contact.id) && isLidUser(contact.lid)) {
lids.push({
pn: contact.id,
id: contact.lid,
});
}
// contact.pn = pn, contact.lid = lid
else if (isJidUser(contact.pn) && isLidUser(contact.lid)) {
lids.push({
pn: contact.pn,
id: contact.lid,
});
}
// contact.pn = pn, contact.id = lid
else if (isJidUser(contact.pn) && isLidUser(contact.id)) {
lids.push({
pn: contact.pn,
id: contact.id,
});
}
}
// make lids unique by id
lids = lodash.uniqBy(lids, 'id');
if (lids.length > 0) {
await this.lidRepo.saveLids(lids);
}
return lids;
}
async getAllLids(
pagination?: LimitOffsetParams,
): Promise<LidToPhoneNumber[]> {
const lids = await this.lidRepo.getAllLids(pagination);
return lids.map((value) => {
return {
lid: value.id,
pn: value.pn,
};
});
}
getLidsCount(): Promise<number> {
return this.lidRepo.getLidsCount();
}
findPNByLid(lid: string): Promise<string | null> {
return this.lidRepo.findPNByLid(lid);
}
findLidByPN(pn: string): Promise<string | null> {
return this.lidRepo.findLidByPN(pn);
}
}
@@ -0,0 +1,564 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
BaileysEventEmitter,
Chat,
ConnectionState,
Contact,
GroupMetadata,
PresenceData,
WAMessage,
WAMessageCursor,
WAMessageKey,
} from '@adiwajshing/baileys/lib/Types';
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
import {
LabelAssociation,
LabelAssociationType,
MessageLabelAssociation,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import {
md5,
toNumber,
updateMessageWithReaction,
updateMessageWithReceipt,
} from '@adiwajshing/baileys/lib/Utils';
import {
jidDecode,
jidNormalizedUser,
} from '@adiwajshing/baileys/lib/WABinary';
import { proto } from '@adiwajshing/baileys/WAProto';
import type { Comparable } from '@adiwajshing/keyed-db/lib/Types';
import type { Logger } from 'pino';
import makeOrderedDictionary from './make-ordered-dictionary';
import { ObjectRepository } from './object-repository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const KeyedDB = require('@adiwajshing/keyed-db').default;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { writeFileSync } = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { readFileSync, existsSync } = require('fs');
type WASocket = ReturnType<any>;
export const waChatKey = (pin: boolean) => ({
key: (c: Chat) =>
(pin ? (c.pinned ? '1' : '0') : '') +
(c.archived ? '0' : '1') +
(c.conversationTimestamp
? c.conversationTimestamp.toString(16).padStart(8, '0')
: '') +
c.id,
compare: (k1: string, k2: string) => k2.localeCompare(k1),
});
export const waMessageID = (m: WAMessage) => m.key.id || '';
export const waLabelAssociationKey: Comparable<LabelAssociation, string> = {
key: (la: LabelAssociation) =>
la.type === LabelAssociationType.Chat
? la.chatId + la.labelId
: la.chatId + la.messageId + la.labelId,
compare: (k1: string, k2: string) => k2.localeCompare(k1),
};
export type BaileysInMemoryStoreConfig = {
chatKey?: Comparable<Chat, string>;
labelAssociationKey?: Comparable<LabelAssociation, string>;
logger: Logger;
socket?: WASocket;
};
const makeMessagesDictionary = () => makeOrderedDictionary(waMessageID);
export default (config: BaileysInMemoryStoreConfig) => {
const socket = config.socket;
const chatKey = config.chatKey || waChatKey(true);
const labelAssociationKey =
config.labelAssociationKey || waLabelAssociationKey;
const logger: Logger = config.logger;
const chats = new KeyedDB(chatKey, (c) => c.id) as any;
const messages: { [_: string]: ReturnType<typeof makeMessagesDictionary> } =
{};
const contacts: { [_: string]: Contact } = {};
const groupMetadata: { [_: string]: GroupMetadata } = {};
let presences: { [id: string]: { [participant: string]: PresenceData } } = {};
const state: ConnectionState = { connection: 'close' };
const labels = new ObjectRepository<Label>();
const labelAssociations = new KeyedDB(
labelAssociationKey,
labelAssociationKey.key,
) as any;
const assertMessageList = (jid: string) => {
if (!messages[jid]) {
messages[jid] = makeMessagesDictionary();
}
return messages[jid];
};
const contactsUpsert = (newContacts: Contact[]) => {
const oldContacts = new Set(Object.keys(contacts));
for (const contact of newContacts) {
oldContacts.delete(contact.id);
contacts[contact.id] = Object.assign(contacts[contact.id] || {}, contact);
}
return oldContacts;
};
const labelsUpsert = (newLabels: Label[]) => {
for (const label of newLabels) {
labels.upsertById(label.id, label);
}
};
/**
* binds to a BaileysEventEmitter.
* It listens to all events and constructs a state that you can query accurate data from.
* Eg. can use the store to fetch chats, contacts, messages etc.
* @param ev typically the event emitter from the socket connection
*/
const bind = (ev: BaileysEventEmitter) => {
ev.on('connection.update', (update) => {
Object.assign(state, update);
});
ev.on(
'messaging-history.set',
({
chats: newChats,
contacts: newContacts,
messages: newMessages,
isLatest,
syncType,
}) => {
if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) {
return; // FOR NOW,
//TODO: HANDLE
}
if (isLatest) {
chats.clear();
for (const id in messages) {
delete messages[id];
}
}
const chatsAdded = chats.insertIfAbsent(...newChats).length;
logger.debug({ chatsAdded }, 'synced chats');
const oldContacts = contactsUpsert(newContacts);
if (isLatest) {
for (const jid of oldContacts) {
delete contacts[jid];
}
}
logger.debug(
{ deletedContacts: isLatest ? oldContacts.size : 0, newContacts },
'synced contacts',
);
for (const msg of newMessages) {
const jid = msg.key.remoteJid!;
const list = assertMessageList(jid);
list.upsert(msg, 'prepend');
}
logger.debug({ messages: newMessages.length }, 'synced messages');
},
);
ev.on('contacts.upsert', (contacts) => {
contactsUpsert(contacts);
});
ev.on('contacts.update', async (updates) => {
for (const update of updates) {
let contact: Contact;
if (contacts[update.id!]) {
contact = contacts[update.id!];
} else {
const contactHashes = await Promise.all(
Object.keys(contacts).map(async (contactId) => {
const { user } = jidDecode(contactId)!;
return [
contactId,
(await md5(Buffer.from(user + 'WA_ADD_NOTIF', 'utf8')))
.toString('base64')
.slice(0, 3),
];
}),
);
contact =
contacts[contactHashes.find(([, b]) => b === update.id)?.[0] || '']; // find contact by attrs.hash, when user is not saved as a contact
}
if (contact) {
if (update.imgUrl === 'changed') {
contact.imgUrl = socket
? await socket?.profilePictureUrl(contact.id)
: undefined;
} else if (update.imgUrl === 'removed') {
delete contact.imgUrl;
}
} else {
return logger.debug(
{ update },
'got update for non-existant contact',
);
}
Object.assign(contacts[contact.id], contact);
}
});
ev.on('chats.upsert', (newChats) => {
chats.upsert(...newChats);
});
ev.on('chats.update', (updates) => {
for (let update of updates) {
const result = chats.update(update.id!, (chat) => {
if (update.unreadCount! > 0) {
update = { ...update };
update.unreadCount = (chat.unreadCount || 0) + update.unreadCount!;
}
Object.assign(chat, update);
});
if (!result) {
logger.debug({ update }, 'got update for non-existant chat');
}
}
});
ev.on('labels.edit', (label: Label) => {
if (label.deleted) {
return labels.deleteById(label.id);
}
// WhatsApp can store only up to 20 labels
if (labels.count() < 20) {
return labels.upsertById(label.id, label);
}
logger.error('Labels count exceed');
});
ev.on('labels.association', ({ type, association }) => {
switch (type) {
case 'add':
labelAssociations.upsert(association);
break;
case 'remove':
labelAssociations.delete(association);
break;
default:
console.error(`unknown operation type [${type}]`);
}
});
ev.on('presence.update', ({ id, presences: update }) => {
presences[id] = presences[id] || {};
Object.assign(presences[id], update);
});
ev.on('chats.delete', (deletions) => {
for (const item of deletions) {
if (chats.get(item)) {
chats.deleteById(item);
}
}
});
ev.on('messages.upsert', ({ messages: newMessages, type }) => {
switch (type) {
case 'append':
case 'notify':
for (const msg of newMessages) {
const jid = jidNormalizedUser(msg.key.remoteJid!);
const list = assertMessageList(jid);
list.upsert(msg, 'append');
if (type === 'notify' && !chats.get(jid)) {
ev.emit('chats.upsert', [
{
id: jid,
conversationTimestamp: toNumber(msg.messageTimestamp),
unreadCount: 1,
},
]);
}
}
break;
}
});
ev.on('messages.update', (updates) => {
for (const { update, key } of updates) {
const list = assertMessageList(jidNormalizedUser(key.remoteJid!));
if (update?.status) {
const listStatus = list.get(key.id!)?.status;
if (listStatus && update?.status <= listStatus) {
logger.debug(
{ update, storedStatus: listStatus },
'status stored newer then update',
);
delete update.status;
logger.debug({ update }, 'new update object');
}
}
const result = list.updateAssign(key.id!, update);
if (!result) {
logger.debug({ update }, 'got update for non-existent message');
}
}
});
ev.on('messages.delete', (item) => {
if ('all' in item) {
const list = messages[item.jid];
list?.clear();
} else {
const jid = item.keys[0].remoteJid!;
const list = messages[jid];
if (list) {
const idSet = new Set(item.keys.map((k) => k.id));
list.filter((m) => !idSet.has(m.key.id));
}
}
});
ev.on('groups.update', (updates) => {
for (const update of updates) {
const id = update.id!;
if (groupMetadata[id]) {
Object.assign(groupMetadata[id], update);
} else {
logger.debug(
{ update },
'got update for non-existant group metadata',
);
}
}
});
ev.on('group-participants.update', ({ id, participants, action }) => {
const metadata = groupMetadata[id];
if (metadata) {
switch (action) {
case 'add':
metadata.participants.push(
...participants.map((id) => ({
id,
isAdmin: false,
isSuperAdmin: false,
})),
);
break;
case 'demote':
case 'promote':
for (const participant of metadata.participants) {
if (participants.includes(participant.id)) {
participant.isAdmin = action === 'promote';
}
}
break;
case 'remove':
metadata.participants = metadata.participants.filter(
(p) => !participants.includes(p.id),
);
break;
}
}
});
ev.on('message-receipt.update', (updates) => {
for (const { key, receipt } of updates) {
const obj = messages[key.remoteJid!];
const msg = obj?.get(key.id!);
if (msg) {
updateMessageWithReceipt(msg, receipt);
}
}
});
ev.on('messages.reaction', (reactions) => {
for (const { key, reaction } of reactions) {
const obj = messages[key.remoteJid!];
const msg = obj?.get(key.id!);
if (msg) {
updateMessageWithReaction(msg, reaction);
}
}
});
};
const toJSON = () => ({
chats,
contacts,
messages,
labels,
labelAssociations,
});
const fromJSON = (json: {
chats: Chat[];
contacts: { [id: string]: Contact };
messages: { [id: string]: WAMessage[] };
labels: { [labelId: string]: Label };
labelAssociations: LabelAssociation[];
}) => {
chats.upsert(...json.chats);
labelAssociations.upsert(...(json.labelAssociations || []));
contactsUpsert(Object.values(json.contacts));
labelsUpsert(Object.values(json.labels || {}));
for (const jid in json.messages) {
const list = assertMessageList(jid);
for (const msg of json.messages[jid]) {
list.upsert(proto.WebMessageInfo.fromObject(msg), 'append');
}
}
};
return {
chats,
contacts,
messages,
groupMetadata,
state,
presences,
setPresences: (obj: any) => {
presences = obj;
},
labels,
labelAssociations,
bind,
/** loads messages from the store, if not found -- uses the legacy connection */
loadMessages: async (
jid: string,
count: number,
cursor: WAMessageCursor,
) => {
const list = assertMessageList(jid);
const mode = !cursor || 'before' in cursor ? 'before' : 'after';
const cursorKey = !!cursor
? 'before' in cursor
? cursor.before
: cursor.after
: undefined;
const cursorValue = cursorKey ? list.get(cursorKey.id!) : undefined;
let messages: WAMessage[];
if (list && mode === 'before' && (!cursorKey || cursorValue)) {
if (cursorValue) {
const msgIdx = list.array.findIndex(
(m) => m.key.id === cursorKey?.id,
);
messages = list.array.slice(0, msgIdx);
} else {
messages = list.array;
}
const diff = count - messages.length;
if (diff < 0) {
messages = messages.slice(-count); // get the last X messages
}
} else {
messages = [];
}
return messages;
},
/**
* Get all available labels for profile
*
* Keep in mind that the list is formed from predefined tags and tags
* that were "caught" during their editing.
*/
getLabels: () => {
return labels;
},
/**
* Get labels for chat
*
* @returns Label IDs
**/
getChatLabels: (chatId: string) => {
return labelAssociations.filter((la) => la.chatId === chatId).all();
},
/**
* Get labels for message
*
* @returns Label IDs
**/
getMessageLabels: (messageId: string) => {
const associations = labelAssociations
.filter((la: MessageLabelAssociation) => la.messageId === messageId)
.all();
return associations.map(({ labelId }) => labelId);
},
loadMessage: async (jid: string, id: string) => messages[jid]?.get(id),
mostRecentMessage: async (jid: string) => {
const message: WAMessage | undefined = messages[jid]?.array.slice(-1)[0];
return message;
},
fetchImageUrl: async (jid: string, sock: WASocket | undefined) => {
const contact = contacts[jid];
if (!contact) {
return sock?.profilePictureUrl(jid);
}
if (typeof contact.imgUrl === 'undefined') {
contact.imgUrl = await sock?.profilePictureUrl(jid);
}
return contact.imgUrl;
},
fetchGroupMetadata: async (jid: string, sock: WASocket | undefined) => {
if (!groupMetadata[jid]) {
const metadata = await sock?.groupMetadata(jid);
if (metadata) {
groupMetadata[jid] = metadata;
}
}
return groupMetadata[jid];
},
// fetchBroadcastListInfo: async(jid: string, sock: WASocket | undefined) => {
// if(!groupMetadata[jid]) {
// const metadata = await sock?.getBroadcastListInfo(jid)
// if(metadata) {
// groupMetadata[jid] = metadata
// }
// }
// return groupMetadata[jid]
// },
fetchMessageReceipts: async ({ remoteJid, id }: WAMessageKey) => {
const list = messages[remoteJid!];
const msg = list?.get(id!);
return msg?.userReceipt;
},
toJSON,
fromJSON,
writeToFile: (path: string) => {
writeFileSync(path, JSON.stringify(toJSON()));
},
readFromFile: (path: string) => {
if (existsSync(path)) {
logger.debug({ path }, 'reading from file');
const jsonStr = readFileSync(path, { encoding: 'utf-8' });
const json = JSON.parse(jsonStr);
fromJSON(json);
}
},
};
};
@@ -0,0 +1,86 @@
function makeOrderedDictionary<T>(idGetter: (item: T) => string) {
const array: T[] = [];
const dict: { [_: string]: T } = {};
const get = (id: string): T | undefined => dict[id];
const update = (item: T) => {
const id = idGetter(item);
const idx = array.findIndex((i) => idGetter(i) === id);
if (idx >= 0) {
array[idx] = item;
dict[id] = item;
}
return false;
};
const upsert = (item: T, mode: 'append' | 'prepend') => {
const id = idGetter(item);
if (get(id)) {
update(item);
} else {
if (mode === 'append') {
array.push(item);
} else {
array.splice(0, 0, item);
}
dict[id] = item;
}
};
const remove = (item: T) => {
const id = idGetter(item);
const idx = array.findIndex((i) => idGetter(i) === id);
if (idx >= 0) {
array.splice(idx, 1);
delete dict[id];
return true;
}
return false;
};
return {
array,
get,
upsert,
update,
remove,
updateAssign: (id: string, update: Partial<T>) => {
const item = get(id);
if (item) {
Object.assign(item, update);
delete dict[id];
dict[idGetter(item)] = item;
return true;
}
return false;
},
clear: () => {
array.splice(0, array.length);
for (const key of Object.keys(dict)) {
delete dict[key];
}
},
filter: (contain: (item: T) => boolean) => {
let i = 0;
while (i < array.length) {
if (!contain(array[i])) {
delete dict[idGetter(array[i])];
array.splice(i, 1);
} else {
i += 1;
}
}
},
toJSON: () => array,
fromJSON: (newItems: T[]) => {
array.splice(0, array.length, ...newItems);
},
};
}
export default makeOrderedDictionary;
@@ -0,0 +1,32 @@
export class ObjectRepository<T extends any> {
readonly entityMap: Map<string, T>;
constructor(entities: Record<string, T> = {}) {
this.entityMap = new Map(Object.entries(entities));
}
findById(id: string) {
return this.entityMap.get(id);
}
findAll() {
return Array.from(this.entityMap.values());
}
upsertById(id: string, entity: T) {
// @ts-ignore
return this.entityMap.set(id, { ...entity });
}
deleteById(id: string) {
return this.entityMap.delete(id);
}
count() {
return this.entityMap.size;
}
toJSON() {
return this.findAll();
}
}
+14
View File
@@ -65,6 +65,15 @@ export const NowebLabelAssociationsSchema = new Schema(
],
);
export const NowebLidMapSchema = new Schema(
'lid_map',
[new Field('id', 'TEXT'), new Field('pn', 'TEXT'), new Field('data', 'TEXT')],
[
new Index('lid_map_id_index', ['id']),
new Index('lid_map_pn_index', ['pn']),
],
);
export const NOWEB_STORE_SCHEMA = [
NowebContactSchema,
NowebChatSchema,
@@ -72,6 +81,7 @@ export const NOWEB_STORE_SCHEMA = [
NowebMessagesSchema,
NowebLabelsSchema,
NowebLabelAssociationsSchema,
NowebLidMapSchema,
];
export const Migrations: string[] = [
@@ -100,4 +110,8 @@ export const Migrations: string[] = [
'CREATE INDEX IF NOT EXISTS label_assoc_type_label_index ON "labelAssociations" (type, "labelId")',
'CREATE INDEX IF NOT EXISTS label_assoc_type_chat_index ON "labelAssociations" (type, "chatId")',
'CREATE INDEX IF NOT EXISTS label_assoc_type_message_index ON "labelAssociations" (type, "messageId")',
// Lid Map
'CREATE TABLE IF NOT EXISTS lid_map (id TEXT PRIMARY KEY, pn TEXT, data TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS lid_map_id_index ON lid_map (id)',
'CREATE INDEX IF NOT EXISTS lid_map_pn_index ON lid_map (pn)',
];
@@ -1,5 +1,6 @@
import { 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';
export class SqlChatMethods {
@@ -8,15 +9,23 @@ export class SqlChatMethods {
async getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
): Promise<Chat[]> {
// Get chats with conversationTimestamp is not Null
let query = this.repository.select().whereNotNull('conversationTimestamp');
if (!broadcast) {
// filter out chat by id if it ends at @newsletter or @broadcast
query = query
.andWhereNot('id', 'like', '%@broadcast')
.andWhereNot('id', 'like', '%@newsletter');
}
// Filter by IDs if provided
if (filter?.ids && filter.ids.length > 0) {
query = query.whereIn('id', filter.ids);
}
query = this.repository.pagination(query, pagination);
return await this.repository.all(query);
}
@@ -1,5 +1,6 @@
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 { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
@@ -35,14 +36,26 @@ export class SqlMessagesMethods {
}
if (filter['filter.fromMe'] != null) {
// filter by data json inside
const sql = this.repository.filterJson('data', 'key.fromMe');
query = query.whereRaw(sql, [filter['filter.fromMe'] ? 'true' : 'false']);
const [sql, value] = this.repository.filterJson(
'data',
'key.fromMe',
filter['filter.fromMe'],
);
query = query.whereRaw(sql, [value]);
}
if (filter['filter.ack'] != null) {
const status = AckToStatus(filter['filter.ack']);
const [sql, value] = this.repository.filterJson('data', '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> {
if (jid === ALL_JID) {
return this.repository.getBy({ id: id });
}
return this.repository.getBy({ jid: jid, id: id });
}
@@ -1,6 +1,7 @@
import { Chat } from '@adiwajshing/baileys';
import { NowebChatSchema } from '@waha/core/engines/noweb/store/schemas';
import { SqlChatMethods } from '@waha/core/engines/noweb/store/sql/SqlChatMethods';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { KnexPaginator } from '@waha/utils/Paginator';
@@ -28,7 +29,8 @@ export class Sqlite3ChatRepository
getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
): Promise<Chat[]> {
return this.methods.getAllWithMessages(pagination, broadcast);
return this.methods.getAllWithMessages(pagination, broadcast, filter);
}
}
@@ -0,0 +1,41 @@
import { NowebLidMapSchema } from '@waha/core/engines/noweb/store/schemas';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import { KnexPaginator } from '@waha/utils/Paginator';
import { INowebLidPNRepository, LidToPN } from '../INowebLidPNRepository';
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
export class LidPaginator extends KnexPaginator {
indexes = ['id', 'pn'];
}
export class Sqlite3LidPNRepository
extends NOWEBSqlite3KVRepository<LidToPN>
implements INowebLidPNRepository
{
get schema() {
return NowebLidMapSchema;
}
saveLids(lids: LidToPN[]): Promise<void> {
return this.upsertMany(lids);
}
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
return this.getAll(pagination);
}
getLidsCount(): Promise<number> {
return this.getCount();
}
async findLidByPN(pn: string): Promise<string | null> {
const value = await this.getBy({ pn: pn });
return value?.id || null;
}
async findPNByLid(lid: string): Promise<string | null> {
const value = await this.getBy({ id: lid });
return value?.pn || null;
}
}
@@ -1,8 +1,10 @@
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import { Sqlite3GroupRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3GroupRepository';
import { Sqlite3LabelAssociationsRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LabelAssociationsRepository';
import { Sqlite3LabelsRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LabelsRepository';
import { Sqlite3LidPNRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LidPNRepository';
import { Schema } from '@waha/core/storage/Schema';
import { INowebStorage } from '../INowebStorage';
@@ -74,4 +76,8 @@ export class Sqlite3Storage extends INowebStorage {
getMessagesRepository() {
return new Sqlite3MessagesRepository(this.db);
}
getLidPNRepository(): INowebLidPNRepository {
return new Sqlite3LidPNRepository(this.db);
}
}
@@ -18,7 +18,11 @@ const AsyncLock = require('async-lock');
// https://github.com/nodejs/node/issues/26338
// Default pending is 1000, set it to infinity
// https://github.com/rogierschouten/async-lock/issues/63
const fileLock = new AsyncLock({ maxPending: Infinity });
const fileLock = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
maxExecutionTime: 30_000,
});
/**
* stores the full authentication state in a single folder.
+21
View File
@@ -0,0 +1,21 @@
/**
* Expose a function to the page if it does not exist
*
* NOTE:
* Rewrite it to 'upsertFunction' after updating Puppeteer to 20.6 or higher
* using page.removeExposedFunction
* https://pptr.dev/api/puppeteer.page.removeExposedFunction
*
* @param {import(puppeteer).Page} page
* @param {string} name
* @param {Function} fn
*/
export async function exposeFunctionIfAbsent(page, name, fn) {
const exist = await page.evaluate((name) => {
return !!window[name];
}, name);
if (exist) {
return;
}
await page.exposeFunction(name, fn);
}
+122
View File
@@ -0,0 +1,122 @@
import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import { Key } from 'node-cache';
import { Page } from 'puppeteer';
export interface CallErrorEvent {
method: string | symbol;
error: unknown;
}
export const PAGE_CALL_ERROR_EVENT = 'page.call.error';
/**
* WPage is a puppeter.Page wrapper that use ALL original methods from Page,
* but in case if there were any errors in the call (original) -
* it calls the errorCallback with method name, error and arguments
*
* Find in webjs used functions like "pupPage.{function}"
* >>> cd src
* >>> grep -rho 'page\.[a-zA-Z0-9_]\+(' . --include="*.js" | sed 's/($//' | sort -u
*
* Find in webjs used functions like "page.{function}"
* >>> cd src
* >>> grep -rho 'page\.[a-zA-Z0-9_]\+(' . --include="*.js" | sed 's/($//' | sort -u
*
*/
export class WPage extends EventEmitter {
constructor(private page: Page) {
super();
}
async evaluate(fn, ...args: any[]) {
try {
return await this.page.evaluate(fn, ...args);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, { method: 'evaluate', error: err });
throw err;
}
}
async screenshot(options) {
try {
return await this.page.screenshot(options);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, { method: 'screenshot', error: err });
throw err;
}
}
// waitForNavigation
async waitForNavigation(options: any) {
try {
return await this.page.waitForNavigation(options);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'waitForNavigation',
error: err,
});
throw err;
}
}
// waitForFunction
async waitForFunction(fn, options, ...args) {
try {
return await this.page.waitForFunction(fn, options, ...args);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'waitForFunction',
error: err,
});
throw err;
}
}
// setRequestInterception
async setRequestInterception(value: boolean) {
try {
return await this.page.setRequestInterception(value);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'setRequestInterception',
error: err,
});
throw err;
}
}
// exposeFunction
async exposeFunction(name: string, fn: (...args: any[]) => any) {
try {
return await this.page.exposeFunction(name, fn);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'exposeFunction',
error: err,
});
throw err;
}
}
async reload(options?: any): Promise<any> {
try {
return await this.page.reload(options);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'reload',
error: err,
});
throw err;
}
}
on(event: string | symbol, listener: (...args: any[]) => void): this {
if (event === PAGE_CALL_ERROR_EVENT) {
super.on(event, listener);
return this;
}
this.page.on(event, listener);
return this.page as any;
}
}
+189 -9
View File
@@ -1,12 +1,18 @@
import { exposeFunctionIfAbsent } from '@waha/core/engines/webjs/Puppeteer';
import { WebJSPresence } from '@waha/core/engines/webjs/types';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { Label } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { TextStatus } from '@waha/structures/status.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import * as lodash from 'lodash';
import { Page } from 'puppeteer';
import { Client, Events } from 'whatsapp-web.js';
import { Message } from 'whatsapp-web.js/src/structures';
import { exposeFunctionIfAbsent } from 'whatsapp-web.js/src/util/Puppeter';
import { CallErrorEvent, PAGE_CALL_ERROR_EVENT, WPage } from './WPage';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadWAHA } = require('./_WAHA.js');
@@ -22,6 +28,7 @@ const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
export class WebjsClientCore extends Client {
public events = new EventEmitter();
private wpage: WPage = null;
constructor(options) {
super(options);
@@ -32,12 +39,44 @@ export class WebjsClientCore extends Client {
});
}
async initialize() {
const result = await super.initialize();
if (this.pupPage && !(this.pupPage instanceof WPage)) {
this.wpage = new WPage(this.pupPage);
this.wpage.on(PAGE_CALL_ERROR_EVENT as any, (event: CallErrorEvent) => {
this.events.emit(PAGE_CALL_ERROR_EVENT as any, event);
});
this.pupPage = this.wpage as any as Page;
}
return result;
}
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
}
/**
* @result indicating whether the UX fresh look was successfully hidden.
*/
hideUXFreshLook(): Promise<boolean> {
return this.pupPage.evaluate(() => {
const WAWebUserPrefsUiRefresh = window.require('WAWebUserPrefsUiRefresh');
if (!WAWebUserPrefsUiRefresh) {
return false;
}
if (WAWebUserPrefsUiRefresh.getUiRefreshNuxAcked()) {
return false;
}
WAWebUserPrefsUiRefresh.incrementNuxViewCount();
WAWebUserPrefsUiRefresh.setUiRefreshNuxAcked(true);
const WAWebModalManager = window.require('WAWebModalManager');
WAWebModalManager.ModalManager.close();
return true;
});
}
async attachCustomEventListeners() {
await exposeFunctionIfAbsent(
this.pupPage,
@@ -51,6 +90,7 @@ export class WebjsClientCore extends Client {
async destroy() {
this.events.removeAllListeners();
this.wpage?.removeAllListeners();
await super.destroy();
}
@@ -82,7 +122,7 @@ export class WebjsClientCore extends Client {
}
async createLabel(name: string, color: number): Promise<number> {
const labelId: number = await this.pupPage.evaluate(
const labelId: number = (await this.pupPage.evaluate(
async (name, color) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelAddAction(
@@ -92,7 +132,7 @@ export class WebjsClientCore extends Client {
},
name,
color,
);
)) as any;
return labelId;
}
@@ -119,7 +159,7 @@ export class WebjsClientCore extends Client {
}, label);
}
async getChats(pagination?: PaginationParams) {
async getChats(pagination?: PaginationParams, filter?: { ids?: string[] }) {
if (lodash.isEmpty(pagination)) {
return await super.getChats();
}
@@ -128,10 +168,14 @@ export class WebjsClientCore extends Client {
pagination.limit ||= Infinity;
pagination.offset ||= 0;
const chats = await this.pupPage.evaluate(async (pagination) => {
// @ts-ignore
return await window.WAHA.getChats(pagination);
}, pagination);
const chats = await this.pupPage.evaluate(
async (pagination, filter) => {
// @ts-ignore
return await window.WAHA.getChats(pagination, filter);
},
pagination,
filter,
);
return chats.map((chat) => ChatFactory.create(this, chat));
}
@@ -199,6 +243,9 @@ export class WebjsClientCore extends Client {
) {
return false;
}
if (filter['filter.ack'] != null && m.ack !== filter['filter.ack']) {
return false;
}
return true;
};
@@ -210,8 +257,17 @@ export class WebjsClientCore extends Client {
const loadedMessages =
// @ts-ignore
await window.Store.ConversationMsgs.loadEarlierMsgs(chat);
if (!loadedMessages || !loadedMessages.length) break;
if (!loadedMessages || loadedMessages.length == 0) break;
msgs = [...loadedMessages.filter(msgFilter), ...msgs];
msgs = msgs.sort((a, b) => b.t - a.t);
// Check if the earliest message is already outside the timerange filter
const earliest = msgs[msgs.length - 1];
if (earliest.t < (filter['filter.timestamp.gte'] || Infinity)) {
// Add only messages that pass the filter and stop loading more
break;
}
}
if (msgs.length > pagination.limit + pagination.offset) {
@@ -233,4 +289,128 @@ export class WebjsClientCore extends Client {
return messages.map((m) => new Message(this, m));
}
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
const lids: Array<LidToPhoneNumber> = (await this.pupPage.evaluate(
async (pagination) => {
pagination.limit ||= Infinity;
pagination.offset ||= 0;
pagination.sortBy ||= 'lid';
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
const values = Array.from(lidMap.values());
const result = values.map((map) => {
return {
// @ts-ignore
lid: map.lid._serialized,
// @ts-ignore
pn: map.phoneNumber._serialized,
};
});
// @ts-ignore
const paginator = new window.Paginator(pagination);
const page = paginator.apply(result);
return page;
},
pagination,
)) as any;
return lids;
}
public async getLidsCount(): Promise<number> {
const count: number = (await this.pupPage.evaluate(async () => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
return lidMap.size;
})) as any;
return count;
}
public async findPNByLid(lid: string): Promise<string> {
const pn = await this.pupPage.evaluate(async (lid) => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
// @ts-ignore
const WAWebWidFactory = window.Store.WidFactory;
const wid = WAWebWidFactory.createWid(lid);
const result = WAWebApiContact.getPhoneNumber(wid);
return result ? result._serialized : null;
}, lid);
return pn;
}
public async findLIDByPhoneNumber(phoneNumber: string): Promise<string> {
const lid: string = (await this.pupPage.evaluate(async (pn) => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
// @ts-ignore
const WAWebWidFactory = window.Store.WidFactory;
const wid = WAWebWidFactory.createWid(pn);
const result = WAWebApiContact.getCurrentLid(wid);
return result ? result._serialized : null;
}, phoneNumber)) as any;
return lid;
}
/**
* Presences methods
*/
public async subscribePresence(chatId: string): Promise<void> {
await this.pupPage.evaluate(async (chatId) => {
const d = require;
const WidFactory = d('WAWebWidFactory');
const wid = WidFactory.createWidFromWidLike(chatId);
const chat = d('WAWebChatCollection').ChatCollection.get(wid);
const tc = chat == null ? void 0 : chat.getTcToken();
await d('WAWebContactPresenceBridge').subscribePresence(wid, tc);
}, chatId);
}
private async getCurrentPresence(chatId: string): Promise<WebJSPresence[]> {
const result = await this.pupPage.evaluate(async (chatId) => {
const d = require;
const WidFactory = d('WAWebWidFactory');
const PresenceCollection = d(
'WAWebPresenceCollection',
).PresenceCollection;
const wid = WidFactory.createWidFromWidLike(chatId);
const presence = PresenceCollection.get(wid);
if (!presence) {
return [];
}
let chatstates = [];
if (chatId.endsWith('@c.us')) {
chatstates = [presence.chatstate];
} else {
chatstates = presence.chatstates.getModelsArray();
}
return chatstates.map((chatstate) => {
return {
participant: chatstate.id._serialized,
lastSeen: chatstate.t,
state: chatstate.type,
};
});
}, chatId);
return result;
}
public async getPresence(chatId: string): Promise<WebJSPresence[]> {
await this.sendPresenceAvailable();
await this.subscribePresence(chatId);
await sleep(3_000);
return await this.getCurrentPresence(chatId);
}
}
+11 -1
View File
@@ -17,7 +17,7 @@ exports.LoadPaginator = () => {
}
return window.lodash.orderBy(
data,
[this.pagination.sortBy],
[this.NullLast(this.pagination.sortBy)],
[this.pagination.sortOrder || 'asc'],
);
}
@@ -30,6 +30,16 @@ exports.LoadPaginator = () => {
const limit = this.pagination.limit || Infinity;
return data.slice(offset, offset + limit);
}
NullLast(field) {
return (item) => {
const value = item?.[field];
if (value == null) {
return -Infinity;
}
return value;
};
}
}
window.Paginator = Paginator;
};
+7 -1
View File
@@ -4,8 +4,14 @@ exports.LoadWAHA = () => {
'WAWebBizLabelEditingAction',
);
window.WAHA.getChats = async (pagination) => {
window.WAHA.getChats = async (pagination, filter) => {
let chats = window.Store.Chat.getModelsArray().slice();
// Filter chats by IDs if filter is provided
if (filter && filter.ids && filter.ids.length > 0) {
chats = chats.filter((chat) => filter.ids.includes(chat.id._serialized));
}
const paginator = new window.Paginator(pagination);
chats = paginator.apply(chats);
const chatPromises = chats.map((chat) => window.WWebJS.getChatModel(chat));
+163
View File
@@ -0,0 +1,163 @@
import {
areJidsSameUser,
BinaryNode,
getBinaryNodeChildren,
getStatusFromReceiptType,
isJidGroup,
isJidStatusBroadcast,
jidEncode,
jidNormalizedUser,
proto,
} from '@adiwajshing/baileys';
export interface ReceiptEvent {
key: proto.IMessageKey;
participant?: string;
messageIds: string[];
status: number;
_node: any;
}
interface Me {
id: string;
lid?: string;
}
export function jid(field: any) {
if (!field) {
return field;
}
const data = field['$1'];
if (!data) {
return data;
}
let server = data.server;
if (!server) {
server =
data.domainType === 0 || data.domainType === 128
? 's.whatsapp.net'
: 'lid';
}
return jidEncode(data.user, server, data.device);
}
export function TagReceiptNodeToReceiptEvent(
node: BinaryNode,
me: Me,
): ReceiptEvent[] {
const { attrs, content } = node;
const status = getStatusFromReceiptType(attrs.type);
if (status == null) {
return null;
}
const from = jidNormalizedUser(jid(attrs.from));
const participant = jidNormalizedUser(jid(attrs.participant));
const recipient = jidNormalizedUser(jid(attrs.recipient));
const isLid = from.includes('lid');
const isNodeFromMe = areJidsSameUser(
participant || from,
isLid ? me?.lid : me?.id,
);
const remoteJid = !isNodeFromMe || isJidGroup(from) ? from : recipient;
const fromMe = !recipient || (attrs.type === 'retry' && isNodeFromMe);
// basically, we only want to know when a message from us has been delivered to/read by the other person
// or another device of ours has read some messages
if (status < proto.WebMessageInfo.Status.SERVER_ACK && isNodeFromMe) {
return [];
}
const key: proto.IMessageKey = {
remoteJid: remoteJid,
id: '',
fromMe: fromMe,
};
const ids = [attrs.id];
if (Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item');
ids.push(...items.map((i) => i.attrs.id));
}
if (isJidGroup(remoteJid) || isJidStatusBroadcast(remoteJid)) {
if (participant) {
key.participant = fromMe ? (isLid ? me.lid : me.id) : recipient;
const eventParticipant = fromMe ? participant : isLid ? me.lid : me.id;
return [
{
key: key,
messageIds: ids,
status: status as any,
participant: eventParticipant,
_node: node,
},
];
} else {
// Handle grouped receipts
return handleGroupedReceipts(node, key, status, fromMe, isLid, me);
}
}
return [
{
key: key,
messageIds: ids,
status: status as any,
_node: node,
},
];
}
function handleGroupedReceipts(
node: BinaryNode,
key: proto.IMessageKey,
status: number,
fromMe: boolean,
isLid: boolean,
me: Me,
): ReceiptEvent[] | null {
const { content } = node;
if (!Array.isArray(content)) {
return [];
}
const participantsTags = content.filter((c) => c.tag === 'participants');
if (participantsTags.length === 0) {
return null;
}
const receiptEvents: ReceiptEvent[] = [];
for (const participants of participantsTags) {
const participantKey = participants.attrs?.key;
if (!participantKey) continue;
const users = getBinaryNodeChildren(participants, 'user');
for (const user of users) {
const userAttrs = user.attrs;
if (!userAttrs) continue;
const userJid = jidNormalizedUser(jid(userAttrs.jid));
if (!userJid) continue;
key.participant = fromMe ? (isLid ? me.lid : me.id) : userJid;
const eventParticipant = fromMe ? userJid : isLid ? me.lid : me.id;
const receiptEvent: ReceiptEvent = {
key: {
...key,
id: participantKey,
},
messageIds: [participantKey],
status: status as any,
participant: eventParticipant,
_node: node,
};
receiptEvents.push(receiptEvent);
}
}
return receiptEvents;
}
+67
View File
@@ -0,0 +1,67 @@
import { BinaryNode, WAPresence } from '@adiwajshing/baileys';
import { toCusFormat } from '@waha/core/engines/noweb/session.noweb.core';
import { WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { WAHAChatPresences } from '@waha/structures/presence.dto';
import { jid } from './ack.webjs';
export function TagPresenceToPresence(node: BinaryNode): WAHAChatPresences {
const { attrs } = node;
const id = jid(attrs.from);
const state =
attrs.type === 'unavailable'
? WAHAPresenceStatus.OFFLINE
: WAHAPresenceStatus.ONLINE;
const lastSeen = attrs.last && attrs.last !== 'deny' ? +attrs.last : null;
return {
id: toCusFormat(id),
presences: [
{
participant: toCusFormat(id),
lastKnownPresence: state,
lastSeen: lastSeen,
},
],
};
}
export function TagChatstateToPresence(node: BinaryNode): WAHAChatPresences {
const { attrs, content } = node;
const id = jid(attrs.from);
const participant = jid(attrs.participant) || jid(attrs.from);
const firstChild = content[0] as BinaryNode;
const type = firstChild.tag as WAPresence;
let status = WAHAPresenceStatus.OFFLINE;
switch (type) {
case 'unavailable':
status = WAHAPresenceStatus.OFFLINE;
break;
case 'available':
status = WAHAPresenceStatus.ONLINE;
break;
case 'paused':
status = WAHAPresenceStatus.PAUSED;
break;
case 'composing':
status = WAHAPresenceStatus.TYPING;
break;
}
if (firstChild.attrs?.media === 'audio') {
status = WAHAPresenceStatus.RECORDING;
}
return {
id: toCusFormat(id),
presences: [
{
participant: toCusFormat(participant),
lastKnownPresence: status,
lastSeen: null,
},
],
};
}
+299 -23
View File
@@ -1,8 +1,17 @@
import { isJidGroup, isJidStatusBroadcast } from '@adiwajshing/baileys';
import { UnprocessableEntityException } from '@nestjs/common';
import {
getChannelInviteLink,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
getFromToParticipant,
toCusFormat,
} from '@waha/core/engines/noweb/session.noweb.core';
import {
ReceiptEvent,
TagReceiptNodeToReceiptEvent,
} from '@waha/core/engines/webjs/ack.webjs';
import {
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
@@ -10,13 +19,27 @@ import {
ToGroupV2UpdateEvent,
} from '@waha/core/engines/webjs/groups.webjs';
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
import {
TagChatstateToPresence,
TagPresenceToPresence,
} from '@waha/core/engines/webjs/presence';
import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
import {
CallErrorEvent,
PAGE_CALL_ERROR_EVENT,
} from '@waha/core/engines/webjs/WPage';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { QR } from '@waha/core/QR';
import { StatusToAck } from '@waha/core/utils/acks';
import {
parseMessageIdSerialized,
SerializeMessageKey,
} from '@waha/core/utils/ids';
import { DistinctAck } from '@waha/core/utils/reactive';
import { splitAt } from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import {
@@ -36,6 +59,9 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
OverviewFilter,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
} from '@waha/structures/chats.dto';
import {
ChatRequest,
@@ -72,26 +98,31 @@ import {
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import {
MessageSource,
WAMessage,
WAMessageReaction,
} from '@waha/structures/responses.dto';
WAHAChatPresences,
WAHAPresenceData,
} from '@waha/structures/presence.dto';
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { StatusRequest, TextStatus } from '@waha/structures/status.dto';
import {
EnginePayload,
WAMessageAckBody,
WAMessageEditedBody,
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
import * as lodash from 'lodash';
import { filter, fromEvent, merge, mergeMap, Observable } from 'rxjs';
import { ProtocolError } from 'puppeteer';
import { filter, fromEvent, merge, mergeMap, Observable, share } from 'rxjs';
import { map } from 'rxjs/operators';
import {
AuthStrategy,
Call,
Channel as WEBJSChannel,
Chat,
@@ -109,6 +140,8 @@ import {
} from 'whatsapp-web.js';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
export interface WebJSConfig {
webVersion?: string;
cacheType: 'local' | 'none';
@@ -235,15 +268,36 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
.then(() => {
// Listen for browser disconnected event
this.whatsapp.pupBrowser.on('disconnected', () => {
this.logger.error('The browser has been disconnected');
if (this.shouldRestart) {
this.logger.error('The browser has been disconnected');
} else {
this.logger.info('The browser has been disconnected');
}
this.failed();
});
// Listen for page close event
this.whatsapp.pupPage.on('close', () => {
this.logger.error('The WhatsApp Web page has been closed');
this.failed();
});
// Listen for function call errors
this.whatsapp.events.on(
PAGE_CALL_ERROR_EVENT,
(event: CallErrorEvent) => {
if (event.error instanceof ProtocolError) {
this.logger.error(
`ProtocolError when calling page method: ${String(
event.method,
)}, restarting client...`,
);
this.logger.error(event.error);
this.failed();
}
},
);
// Listen for page error event
if (this.isDebugEnabled()) {
this.logger.debug("Logging 'console' event for web page");
@@ -302,10 +356,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
private async end() {
this.engineStateCheckDelayedJob.cancel();
this.whatsapp?.removeAllListeners();
this.whatsapp?.pupBrowser?.removeAllListeners();
this.whatsapp?.pupPage?.removeAllListeners();
try {
this.whatsapp?.removeAllListeners();
this.whatsapp?.pupBrowser?.removeAllListeners();
this.whatsapp?.pupPage?.removeAllListeners();
// It's possible that browser yet starting
await waitUntil(
async () => {
@@ -316,11 +371,30 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
1_000,
10_000,
);
this.whatsapp?.destroy().catch((error) => {
this.logger.warn(error, 'Failed to destroy the client');
});
this.logger.debug(
'Successfully waited for browser to be ready for closing',
);
} catch (error) {
this.logger.error(error);
this.logger.error(
error,
'Failed while waiting for browser to be ready for closing',
);
}
try {
await this.whatsapp?.destroy();
this.logger.debug('Successfully destroyed whatsapp client');
} catch (error) {
this.logger.error(error, 'Failed to destroy whatsapp client');
}
try {
// @ts-ignore
const strategy: AuthStrategy = this.whatsapp?.authStrategy;
await strategy?.destroy();
this.logger.debug('Successfully destroyed auth strategy');
} catch (error) {
this.logger.error(error, 'Failed to destroy auth strategy');
}
}
@@ -363,6 +437,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.logger.info(`Session '${this.name}' is ready!`);
});
//
// Temp fix for hiding "Fresh look" modal
// https://github.com/devlikeapro/waha/issues/987
//
this.whatsapp.on(Events.READY, async () => {
try {
const hidden = await this.whatsapp.hideUXFreshLook();
if (hidden) {
this.logger.info('"Fresh look" modal has been hidden');
}
} catch (err) {
this.logger.warn('Failed to hide "Fresh look" modal');
this.logger.warn(err, err.stack);
}
});
this.whatsapp.on(Events.AUTHENTICATED, (args) => {
this.qr.save('');
this.logger.info({ args: args }, `Session has been authenticated!`);
@@ -615,7 +705,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
/**
* Chats methods
*/
getChats(pagination: PaginationParams) {
getChats(pagination: PaginationParams, filter: OverviewFilter | null = null) {
switch (pagination.sortBy) {
case ChatSortField.ID:
pagination.sortBy = 'id._serialized';
@@ -624,18 +714,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
pagination.sortBy = 't';
break;
}
return this.whatsapp.getChats(pagination);
return this.whatsapp.getChats(pagination, filter);
}
public async getChatsOverview(
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
pagination = {
...pagination,
sortBy: ChatSortField.CONVERSATION_TIMESTAMP,
sortOrder: SortOrder.DESC,
};
const chats = await this.getChats(pagination);
const chats = await this.getChats(pagination, filter);
const promises = [];
for (const chat of chats) {
@@ -691,6 +782,17 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return result;
}
public async readChatMessages(
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse> {
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(chatId),
);
await chat.sendSeen();
return { ids: null };
}
public async getChatMessage(
chatId: string,
messageId: string,
@@ -698,6 +800,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
): Promise<null | WAMessage> {
const message = await this.whatsapp.getMessageById(messageId);
if (!message) return null;
if (
isJidGroup(message.id.remote) ||
isJidStatusBroadcast(message.id.remote)
) {
// @ts-ignore
message.rawData.receipts = await message.getInfo().catch((error) => {
this.logger.error(
{ error: error, msg: message.id._serialized },
'Failed to get receipts',
);
return null;
});
}
return await this.processIncomingMessage(message, query.downloadMedia);
}
@@ -840,6 +955,38 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
await contact.unblock();
}
/**
* Lid to Phone Number methods
*/
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
return this.whatsapp.getAllLids(pagination);
}
public async getLidsCount(): Promise<number> {
return this.whatsapp.getLidsCount();
}
public async findPNByLid(lid: string): Promise<LidToPhoneNumber> {
const phoneNumber = await this.whatsapp.findPNByLid(lid);
return {
lid: lid,
pn: phoneNumber,
};
}
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = toCusFormat(phoneNumber);
const lid = await this.whatsapp.findLIDByPhoneNumber(pn);
return {
lid: lid,
pn: pn,
};
}
/**
* Group methods
*/
@@ -1143,6 +1290,52 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
public getPresences(): Promise<WAHAChatPresences[]> {
throw new NotImplementedByEngineError();
}
public async getPresence(id: string): Promise<WAHAChatPresences> {
const chatId = toCusFormat(id);
const presences = await this.whatsapp.getPresence(chatId);
return this.toWahaPresences(chatId, presences);
}
public async subscribePresence(id: string): Promise<any> {
const chatId = toCusFormat(id);
await this.whatsapp.subscribePresence(chatId);
}
private toWahaPresences(
chatId: string,
data: WebJSPresence[],
): WAHAChatPresences {
const presences: WAHAPresenceData[] = data.map((presence) => {
let status: WAHAPresenceStatus = WAHAPresenceStatus.OFFLINE;
switch (presence.state) {
case WAJSPresenceChatStateType.AVAILABLE:
status = WAHAPresenceStatus.ONLINE;
break;
case WAJSPresenceChatStateType.UNAVAILABLE:
status = WAHAPresenceStatus.OFFLINE;
break;
case WAJSPresenceChatStateType.TYPING:
status = WAHAPresenceStatus.TYPING;
break;
case WAJSPresenceChatStateType.RECORDING_AUDIO:
status = WAHAPresenceStatus.RECORDING;
}
return {
participant: presence.participant,
lastSeen: presence.lastSeen || null,
lastKnownPresence: status,
};
});
return {
id: toCusFormat(chatId),
presences: presences,
};
}
/**
* Status methods
*/
@@ -1226,9 +1419,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const beforeMessage = event.before
? this.toWAMessage(event.before)
: null;
// Extract the revoked message ID from the protocolMessageKey.id field
const revokedMessageId = afterMessage?._data?.protocolMessageKey?.id;
return {
after: afterMessage,
before: beforeMessage,
revokedMessageId: revokedMessageId,
};
}),
);
@@ -1240,18 +1436,51 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
this.events2.get(WAHAEvents.MESSAGE_REACTION).switch(messagesReaction$);
const messageAck$ = fromEvent(
const messageEdit$ = fromEvent(
this.whatsapp,
Events.MESSAGE_EDIT,
(message, newBody, prevBody) => {
return { message, newBody, prevBody };
},
);
const messagesEdit$ = messageEdit$.pipe(
map((event): WAMessageEditedBody => {
const message = this.toWAMessage(event.message);
return {
...message,
body: event.newBody,
editedMessageId: message._data?.id?.id,
_data: event,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_EDITED).switch(messagesEdit$);
const messageAckWEBJS$ = fromEvent(
this.whatsapp,
Events.MESSAGE_ACK,
(message, ack) => {
return { message, ack };
},
);
const messagesAck$ = messageAck$.pipe(
const messagesAckDM$ = messageAckWEBJS$.pipe(
map((event) => event.message),
map(this.toWAMessage.bind(this)),
map<any, WAMessage>(this.toWAMessage.bind(this)),
filter((ack) => !isJidGroup(ack.to) && !isJidStatusBroadcast(ack.to)),
);
this.events2.get(WAHAEvents.MESSAGE_ACK).switch(messagesAck$);
const tagReceiptNode$ = fromEvent(this.whatsapp, Events.TAG_RECEIPT);
const messageAckGroups$ = tagReceiptNode$.pipe(
mergeMap((node) =>
TagReceiptNodeToReceiptEvent(node as any, this.getSessionMeInfo()),
),
mergeMap(this.TagReceiptToMessageAck.bind(this)),
filter((ack) => isJidGroup(ack.to) || isJidStatusBroadcast(ack.to)),
);
const messageAckAll$ = merge(messagesAckDM$, messageAckGroups$);
const messageAck$ = messageAckAll$.pipe(DistinctAck());
this.events2.get(WAHAEvents.MESSAGE_ACK).switch(messageAck$);
//
// Others
@@ -1259,6 +1488,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const stateChanged$ = fromEvent(this.whatsapp, Events.STATE_CHANGED);
this.events2.get(WAHAEvents.STATE_CHANGE).switch(stateChanged$);
//
// Presence
//
const tagPresenceNode$ = fromEvent(this.whatsapp, Events.TAG_PRESENCE);
const presences$ = tagPresenceNode$.pipe(
map(TagPresenceToPresence),
filter(Boolean),
);
const tagChatstateNode$ = fromEvent(this.whatsapp, 'tag:chatstate');
const chatstatePresences$ = tagChatstateNode$.pipe(
map(TagChatstateToPresence),
filter(Boolean),
);
const presenceUpdate$ = merge(presences$, chatstatePresences$);
this.events2.get(WAHAEvents.PRESENCE_UPDATE).switch(presenceUpdate$);
//
// Groups
//
@@ -1383,15 +1628,44 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
};
}
protected TagReceiptToMessageAck(receipt: ReceiptEvent): WAMessageAckBody[] {
const ids = receipt.messageIds;
const acks = [];
for (const id_ of ids) {
const messageKey = {
fromMe: receipt.key.fromMe,
remoteJid: toCusFormat(receipt.key.remoteJid),
participant: toCusFormat(receipt.key.participant),
id: id_,
};
const fromToParticipant = getFromToParticipant(messageKey);
const id = SerializeMessageKey(messageKey);
const ack = StatusToAck(receipt.status);
acks.push({
id: id,
from: fromToParticipant.from,
to: fromToParticipant.to,
participant: toCusFormat(receipt.participant),
fromMe: !receipt.key.fromMe, // reverted, it's right
ack: ack,
ackName: WAMessageAck[ack] || ACK_UNKNOWN,
_data: receipt._node,
});
}
return acks;
}
protected toWAMessage(message: Message): WAMessage {
const replyTo = this.extractReplyTo(message);
const source = this.getMessageSource(message.id.id);
const key = parseMessageIdSerialized(message.id._serialized);
// @ts-ignore
return {
id: message.id._serialized,
timestamp: message.timestamp,
from: message.from,
fromMe: message.fromMe,
participant: toCusFormat(key.participant),
source: source,
to: message.to,
body: message.body,
@@ -1427,10 +1701,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
public async getEngineInfo() {
// Add 1 seconds timeout
if (!this.whatsapp || !this.whatsapp.pupPage) {
return null;
}
return {
WWebVersion: await this.whatsapp?.getWWebVersion(),
state: await this.whatsapp?.getState(),
WWebVersion: await this.whatsapp.getWWebVersion(),
state: await this.whatsapp.getState(),
};
}
+12
View File
@@ -0,0 +1,12 @@
export enum WAJSPresenceChatStateType {
AVAILABLE = 'available', // User is online
UNAVAILABLE = 'unavailable', // User went offline (may include `t`)
TYPING = 'typing', // User is typing text
RECORDING_AUDIO = 'recording_audio', // User is recording a voice message
}
export interface WebJSPresence {
participant: string;
lastSeen?: number;
state: WAJSPresenceChatStateType;
}
+5 -2
View File
@@ -1,8 +1,11 @@
import { UnprocessableEntityException } from '@nestjs/common';
import {
NotImplementedException,
UnprocessableEntityException,
} from '@nestjs/common';
export const DOCS_URL = 'https://waha.devlike.pro/';
export class NotImplementedByEngineError extends UnprocessableEntityException {
export class NotImplementedByEngineError extends NotImplementedException {
constructor(msg = '') {
let error = 'The method is not implemented by the engine.';
if (msg) {
+10 -7
View File
@@ -1,20 +1,23 @@
export function toVcard(data): string {
import { Contact, VCardContact } from '@waha/structures/chatting.dto';
export function toVcard(data: Contact | VCardContact): string {
if (data.vcard) {
return data.vcard;
}
const contact: Contact = data as any;
const parts = [];
parts.push('BEGIN:VCARD');
parts.push('VERSION:3.0');
parts.push(`FN:${data.fullName}`);
if (data.organization) {
parts.push(`ORG:${data.organization};`);
parts.push(`FN:${contact.fullName}`);
if (contact.organization) {
parts.push(`ORG:${contact.organization};`);
}
if (data.whatsappId) {
if (contact.whatsappId) {
parts.push(
`TEL;type=CELL;type=VOICE;waid=${data.whatsappId}:${data.phoneNumber}`,
`TEL;type=CELL;type=VOICE;waid=${contact.whatsappId}:${contact.phoneNumber}`,
);
} else {
parts.push(`TEL;type=CELL;type=VOICE:${data.phoneNumber}`);
parts.push(`TEL;type=CELL;type=VOICE:${contact.phoneNumber}`);
}
parts.push('END:VCARD');
return parts.join('\n');
+16
View File
@@ -0,0 +1,16 @@
import { AvailableInPlusVersion } from '../exceptions';
export interface IMediaConverter {
voice(content: Buffer): Promise<Buffer>;
video(content: Buffer): Promise<Buffer>;
}
export class CoreMediaConverter implements IMediaConverter {
video(content: Buffer): Promise<Buffer> {
throw new AvailableInPlusVersion();
}
voice(content: Buffer): Promise<Buffer> {
throw new AvailableInPlusVersion();
}
}
+4
View File
@@ -0,0 +1,4 @@
export enum WAMimeType {
VOICE = 'audio/ogg; codecs=opus',
VIDEO = 'video/mp4',
}
+2 -2
View File
@@ -4,8 +4,8 @@ import * as fsp from 'fs/promises';
import * as path from 'path';
import { Logger } from 'pino';
import fs = require('fs');
import del = require('del');
import { fileExists } from '@waha/utils/files';
import { deleteAsync } from 'del';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const writeFileAtomic = require('write-file-atomic');
@@ -59,7 +59,7 @@ export class MediaLocalStorage implements IMediaStorage {
}
if (fs.existsSync(this.filesFolder)) {
del([`${this.filesFolder}/*`], { force: true }).then((paths) => {
deleteAsync([`${this.filesFolder}/*`], { force: true }).then((paths) => {
if (paths.length === 0) {
return;
}
+6 -7
View File
@@ -1,6 +1,5 @@
import * as crypto from 'crypto';
import { safeJoin } from '@waha/utils/files';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { LocalStore } from './LocalStore';
@@ -33,14 +32,14 @@ export class LocalStoreCore extends LocalStore {
* Get the directory where all the engines and sessions are stored
*/
getBaseDirectory() {
return path.join(this.baseDirectory);
return path.resolve(this.baseDirectory);
}
/**
* Get the directory where the engine sessions are stored
*/
getEngineDirectory() {
return path.join(this.baseDirectory, this.engine);
return safeJoin(this.baseDirectory, this.engine);
}
getSessionDirectory(name: string): string {
@@ -48,17 +47,17 @@ export class LocalStoreCore extends LocalStore {
}
getFilePath(session: string, file: string): string {
return path.join(this.getSessionDirectory(session), file);
return safeJoin(this.getSessionDirectory(session), file);
}
protected getDirectoryPath(name: string): string {
return path.join(this.getEngineDirectory(), name);
return safeJoin(this.getEngineDirectory(), name);
}
getWAHADatabase(): any {
if (!this.db) {
const engineDir = this.getEngineDirectory();
const database = path.join(engineDir, 'waha.sqlite3');
const database = safeJoin(engineDir, 'waha.sqlite3');
this.db = new Database(database);
this.db.pragma('journal_mode = WAL;');
}
+1 -1
View File
@@ -1,4 +1,4 @@
export interface IJsonQuery {
filter(field: string, key: string): string;
filter(field: string, key: string, value: any): [string, string];
sortBy(field: string, sortBy: string, direction: string): string;
}
+54 -11
View File
@@ -4,6 +4,7 @@ import { ISQLEngine } from '@waha/core/storage/sql/ISQLEngine';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { KnexPaginator } from '@waha/utils/Paginator';
import Knex from 'knex';
import * as lodash from 'lodash';
export type Migration = string;
@@ -78,18 +79,25 @@ export class SqlKVRepository<Entity> {
}
}
private async upsertBatch(entities: Entity[]): Promise<void> {
const data = entities.map((entity) => this.dump(entity));
protected async upsertBatch(entities: Entity[]): Promise<void> {
const all = entities.map((entity) => this.dump(entity));
// make it unique by .id
const data = lodash.uniqBy(all, (d: any) => d.id);
if (data.length != all.length) {
console.warn(
`WARNING - Duplicated entities for upsert batch: all=${all.length}, data=${data.length}`,
);
}
const columns = this.columns.map((c) => `"${c.fieldName}"`);
const values = data.map((d) => Object.values(d)).flat();
const sql = `INSERT INTO "${this.table}" (${columns.join(', ')})
VALUES ${data
.map(() => `(${columns.map(() => '?').join(', ')})`)
.join(', ')}
ON CONFLICT(id) DO UPDATE
SET ${columns
.map((column) => `${column} = excluded.${column}`)
.join(', ')}`;
.join(', ')} ON CONFLICT(id) DO
UPDATE
SET ${columns
.map((column) => `${column} = excluded.${column}`)
.join(', ')}`;
try {
await this.raw(sql, values);
} catch (err) {
@@ -109,8 +117,43 @@ export class SqlKVRepository<Entity> {
return this.all(query);
}
getAllByIds(ids: string[]) {
return this.all(this.select().whereIn('id', ids));
async getCount(): Promise<number> {
const query = this.select().count({ count: 'id' });
const row = await this.engine.get(query);
if (!row) {
return 0;
}
return parseInt(row.count, 10);
}
async getAllByIds(ids: string[]) {
const entitiesMap = await this.getEntitiesByIds(ids);
return Array.from(entitiesMap.values()).filter(
(entity) => entity !== null,
) as Entity[];
}
async getEntitiesByIds(ids: string[]): Promise<Map<string, Entity | null>> {
if (ids.length === 0) {
return new Map();
}
const rows = await this.engine.all(this.select().whereIn('id', ids));
const entitiesMap = new Map<string, Entity | null>();
// Initialize a map with null values for all requested IDs
for (const id of ids) {
entitiesMap.set(id, null);
}
// Fill in the map with found entities
for (const row of rows) {
if (row && row.id) {
entitiesMap.set(row.id, this.parse(row));
}
}
return entitiesMap;
}
getById(id: string): Promise<Entity | null> {
@@ -187,8 +230,8 @@ export class SqlKVRepository<Entity> {
/**
* JSON helpers
*/
public filterJson(field: string, key: string): string {
return this.jsonQuery.filter(field, key);
public filterJson(field: string, key: string, value: any): [string, string] {
return this.jsonQuery.filter(field, key, value);
}
protected stringify(data: any): string {
+2 -2
View File
@@ -1,9 +1,9 @@
import { IJsonQuery } from '@waha/core/storage/sql/IJsonQuery';
export class Sqlite3JsonQuery implements IJsonQuery {
filter(field: string, key: string): string {
filter(field: string, key: string, value: any): [string, string] {
const jsonPath = `$.${key}`;
return `${field}->'${jsonPath}' = ?`;
return [`${field}->'${jsonPath}' = ?`, `${value}`];
}
sortBy(field: string, sortBy: string, direction: string): string {
@@ -1,6 +1,7 @@
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { Sqlite3Engine } from '@waha/core/storage/sqlite3/Sqlite3Engine';
import { Sqlite3JsonQuery } from '@waha/core/storage/sqlite3/Sqlite3JsonQuery';
import { sleep } from '@waha/utils/promiseTimeout';
import { Database } from 'better-sqlite3';
import Knex from 'knex';
@@ -18,4 +19,12 @@ export class Sqlite3KVRepository<Entity> extends SqlKVRepository<Entity> {
super(engine, knex);
this.db = db;
}
protected async upsertBatch(entities: Entity[]): Promise<void> {
await super.upsertBatch(entities);
// Give some time to the Node.js loop because we're using sync better-sqlite
if (entities.length >= this.UPSERT_BATCH_SIZE) {
await sleep(1);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { WAMessageAck } from '@waha/structures/enums.dto';
export function StatusToAck(status: number): WAMessageAck {
return status - 1;
}
export function AckToStatus(ack: number): number {
return ack + 1;
}
+72
View File
@@ -0,0 +1,72 @@
import { isJidGroup, WAMessageKey } from '@adiwajshing/baileys';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { toJID } from '@waha/core/utils/jids';
import {
GetChatMessagesFilter,
GetChatMessagesQuery,
ReadChatMessagesQuery,
} from '@waha/structures/chats.dto';
import { SendSeenRequest } from '@waha/structures/chatting.dto';
import { WAMessageAck } from '@waha/structures/enums.dto';
export function ExtractMessageKeysForRead(
request: SendSeenRequest,
): WAMessageKey[] {
const jid = toJID(request.chatId);
const defaults = {
remoteJid: jid,
participant: request.participant,
};
const ids = request.messageIds || [];
if (request.messageId) {
ids.push(request.messageId);
}
const keys: WAMessageKey[] = [];
for (const messageId of ids) {
const parsed = parseMessageIdSerialized(messageId, false);
if (parsed.fromMe) {
continue;
}
const key: WAMessageKey = {
id: parsed.id,
remoteJid: parsed.remoteJid
? toJID(parsed.remoteJid)
: defaults.remoteJid,
participant: parsed.participant
? toJID(parsed.participant)
: defaults.participant,
};
keys.push(key);
}
return keys;
}
function daysToMs(days: number) {
return days * 24 * 60 * 60 * 1000;
}
export function MessagesForRead(
chatId: string,
request: ReadChatMessagesQuery,
): {
query: GetChatMessagesQuery;
filter: GetChatMessagesFilter;
} {
const limit = request.messages || isJidGroup(chatId) ? 100 : 30;
const query: GetChatMessagesQuery = {
offset: 0,
limit: limit,
downloadMedia: false,
};
const afterMs = Date.now() - daysToMs(request.days);
const after = Math.floor(afterMs / 1000);
const filter: GetChatMessagesFilter = {
'filter.ack': WAMessageAck.DEVICE,
'filter.fromMe': false,
'filter.timestamp.gte': after,
};
return {
query: query,
filter: filter,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { EventResponseType } from '@waha/structures/events.dto';
export function ParseEventResponseType(response: number) {
switch (response) {
case 0:
return EventResponseType.UNKNOWN;
case 1:
return EventResponseType.GOING;
case 2:
return EventResponseType.NOT_GOING;
case 3:
return EventResponseType.MAYBE;
default:
return EventResponseType.UNKNOWN;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { WAMessageKey } from '@adiwajshing/baileys';
import { toJID } from '@waha/core/utils/jids';
/**
* Parse message id from WAHA to engine
* false_11111111111@c.us_AAA
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
*/
export function parseMessageIdSerialized(
messageId: string,
soft: boolean = false,
): WAMessageKey {
if (!messageId.includes('_') && soft) {
return { id: messageId };
}
const parts = messageId.split('_');
if (parts.length != 3 && parts.length != 4) {
throw new Error(
'Message id be in format false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA[_participant]',
);
}
const fromMe = parts[0] == 'true';
const chatId = parts[1];
const remoteJid = toJID(chatId);
const id = parts[2];
const participant = parts[3] ? toJID(parts[3]) : undefined;
return {
fromMe: fromMe,
id: id,
remoteJid: remoteJid,
participant: participant,
};
}
export function SerializeMessageKey(key: WAMessageKey) {
const { fromMe, id, remoteJid, participant } = key;
const participantStr = participant ? `_${participant}` : '';
return `${fromMe ? 'true' : 'false'}_${remoteJid}_${id}${participantStr}`;
}
+37
View File
@@ -0,0 +1,37 @@
import { isJidGroup, isJidMetaIa } from '@adiwajshing/baileys';
import {
isJidBroadcast,
isLidUser,
} from '@adiwajshing/baileys/lib/WABinary/jid-utils';
export function isJidNewsletter(jid: string) {
return jid.endsWith('@newsletter');
}
export function isJidCus(jid: string) {
return jid?.endsWith('@c.us');
}
/**
* Convert from 11111111111@c.us to 11111111111@s.whatsapp.net
* @param chatId
*/
export function toJID(chatId) {
if (isJidGroup(chatId)) {
return chatId;
}
if (isJidBroadcast(chatId)) {
return chatId;
}
if (isJidNewsletter(chatId)) {
return chatId;
}
if (isLidUser(chatId)) {
return chatId;
}
if (isJidMetaIa(chatId)) {
return chatId;
}
const number = chatId.split('@')[0];
return number + '@s.whatsapp.net';
}
+10
View File
@@ -0,0 +1,10 @@
import { WAMessageAckBody } from '@waha/structures/webhooks.dto';
import { distinct, interval } from 'rxjs';
export function DistinctAck(flushEvery: number = 60_000) {
// only if we haven’t seen this key since the last flush
return distinct(
(msg: WAMessageAckBody) => `${msg.id}-${msg.ack}-${msg.participant}`,
interval(flushEvery),
);
}
+8 -15
View File
@@ -47,31 +47,24 @@ process.on('SIGTERM', () => {
logger.info('SIGTERM received');
});
async function loadModules(): Promise<
[typeof AppModuleCore, typeof SwaggerConfiguratorCore]
> {
async function loadModules(): Promise<typeof AppModuleCore> {
const version = getWAHAVersion();
if (version === WAHAVersion.CORE) {
const { AppModuleCore } = await import('./core/app.module.core');
const { SwaggerConfiguratorCore } = await import(
'./core/SwaggerConfiguratorCore'
);
return [AppModuleCore, SwaggerConfiguratorCore];
return AppModuleCore;
}
// Ignore if it's core version - there's no plus module
// Ignore if it's a core version - there's no plus module
// @ts-ignore
const { AppModulePlus } = await import('./plus/app.module.plus');
// @ts-ignore
const { SwaggerConfiguratorPlus } = await import('./plus/SwaggerConfiguratorPlus'); // prettier-ignore
// @ts-ignore
return [AppModulePlus, SwaggerConfiguratorPlus];
return AppModulePlus;
}
async function bootstrap() {
const version = getWAHAVersion();
logger.info(`WAHA (WhatsApp HTTP API) - Running ${version} version...`);
const [AppModule, SwaggerModule] = await loadModules();
const AppModule = await loadModules();
const httpsOptions = AppModule.getHttpsOptions(logger);
const app = await NestFactory.create(AppModule, {
logger: getNestJSLogLevels(),
@@ -81,7 +74,7 @@ async function bootstrap() {
});
app.useLogger(app.get(NestJSPinoLogger));
// Print original stack, not pino one
// Print the original stack, not pino one
// https://github.com/iamolegga/nestjs-pino?tab=readme-ov-file#expose-stack-trace-and-error-class-in-err-property
app.useGlobalInterceptors(new LoggerErrorInterceptor());
@@ -91,13 +84,13 @@ async function bootstrap() {
// but for now we added it ValidationPipe on Controller or endpoint level
// app.useGlobalPipes(new ValidationPipe({ transform: true }));
// Allow to send big body - for images and attachments
// Allow sending big body - for images and attachments
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: false }));
app.useWebSocketAdapter(new WsAdapter(app));
// Configure swagger
const swaggerConfigurator = new SwaggerModule(app);
const swaggerConfigurator = new SwaggerConfiguratorCore(app);
swaggerConfigurator.configure(WAHA_WEBHOOKS);
AppModule.appReady(app, logger);
+2 -2
View File
@@ -11,7 +11,7 @@ function getRefSchemaPaths(models) {
/**
* Decorator to add a file accept header to the swagger documentation
*/
export function ApiFileAcceptHeader(...models) {
export function ApiFileAcceptHeader(mimetype: string, ...models) {
models = models.length ? models : [Base64File];
return applyDecorators(
// Add extra models, otherwise it'll give a error
@@ -20,7 +20,7 @@ export function ApiFileAcceptHeader(...models) {
ApiResponse({
status: 200,
content: {
'image/png': {
[mimetype]: {
schema: {
type: 'string',
format: 'binary',
+32 -19
View File
@@ -21,6 +21,11 @@ import { Base64File } from '../structures/files.dto';
export class BufferResponseInterceptor<T>
implements NestInterceptor<Buffer, Base64File | StreamableFile>
{
constructor(
private mimetype: string,
private filename: string = null,
) {}
intercept(
context: ExecutionContext,
next: CallHandler,
@@ -28,27 +33,35 @@ export class BufferResponseInterceptor<T>
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
return next.handle().pipe(map(processBuffer));
function processBuffer(buffer: Buffer) {
// Check buffer is Buffer
if (!Buffer.isBuffer(buffer)) {
return buffer;
}
return next
.handle()
.pipe(map((buffer) => this.processBuffer(buffer, request, response)));
}
const accept = request.headers['accept'];
if (accept == 'application/json') {
return {
mimetype: 'image/png',
data: buffer.toString('base64'),
};
}
const file = new StreamableFile(buffer);
response.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
});
return file;
processBuffer(buffer: Buffer, request: Request, response: Response) {
// Check buffer is Buffer
if (!Buffer.isBuffer(buffer)) {
return buffer;
}
const accept = request.headers['accept'];
if (accept == 'application/json') {
return {
mimetype: this.mimetype,
data: buffer.toString('base64'),
};
}
const file = new StreamableFile(buffer);
response.set({
'Content-Type': this.mimetype,
'Content-Length': buffer.length,
});
if (this.filename) {
response.set({
'Content-Disposition': `attachment; filename="${this.filename}"`,
});
}
return file;
}
}
+73
View File
@@ -0,0 +1,73 @@
import * as fs from 'node:fs';
import { LoggerBuilder } from '@waha/utils/logging';
import { Logger } from 'pino';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const chokidar = require('chokidar');
export class HttpsExpress {
private readonly keyPath: string;
private readonly certPath: string;
private readonly caPath: string;
constructor(private logger: Logger) {
//
// Let's encrypt certificates default paths
// cert.pem chain.pem fullchain.pem privkey.pem
//
this.keyPath = process.env.WAHA_HTTPS_PATH_KEY || './.secrets/privkey.pem';
this.certPath = process.env.WAHA_HTTPS_PATH_CERT || './.secrets/cert.pem';
this.caPath = process.env.WAHA_HTTPS_PATH_CA;
if (this.caPath == null) {
this.caPath = './.secrets/chain.pem';
}
}
readSync() {
this.logger.info('Reading HTTPS certificates...');
this.logger.info('HTTPS Key Path:', this.keyPath);
const key = fs.readFileSync(this.keyPath);
this.logger.info('HTTPS Cert Path:', this.certPath);
const cert = fs.readFileSync(this.certPath);
this.logger.info('HTTPS CA Path:', this.caPath);
const ca = this.caPath ? fs.readFileSync(this.caPath) : undefined;
this.logger.info('HTTPS certificates read successfully');
return { key: key, cert: cert, ca: ca };
}
/**
* https://stackoverflow.com/a/74076392
*/
watchCertChanges(httpd) {
let waitForCertAndFullChainToGetUpdatedTooTimeout: any;
const paths = [this.keyPath, this.certPath, this.caPath].filter(
(path) => !!path,
);
const watcher = chokidar.watch(paths, {
followSymlinks: false,
persistent: true,
ignoreInitial: true,
disableGlobbing: true,
});
// IDK why, but it has few bugs:
// 1. It issues 'add' event at the start, even tho ignoreInitial is set to true
// 2. It issues additional 'add' for the same file, but without full path
watcher.on('all', (eventName, path, stats) => {
this.logger.info(`HTTPS file '${path}' has been '${eventName}'...`);
clearTimeout(waitForCertAndFullChainToGetUpdatedTooTimeout);
waitForCertAndFullChainToGetUpdatedTooTimeout = setTimeout(() => {
this.logger.info('Updating HTTPS configuration...');
httpd.setSecureContext(this.readSync());
}, 1000);
});
process.on('SIGTERM', () => {
this.logger.info('SIGTERM received, closing HTTP file watchers');
clearTimeout(waitForCertAndFullChainToGetUpdatedTooTimeout);
watcher.close();
});
}
}
+1 -1
View File
@@ -195,5 +195,5 @@ export class PreviewChannelMessages {
@IsNumber()
@Type(() => Number)
limit: number = 100;
limit: number = 10;
}
+89 -2
View File
@@ -1,12 +1,23 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
import { WAMessageAck, WAMessageAckName } from '@waha/structures/enums.dto';
import {
LimitOffsetParams,
PaginationParams,
} from '@waha/structures/pagination.dto';
import { ChatIdProperty } from '@waha/structures/properties.dto';
import { SessionConfig } from '@waha/structures/sessions.dto';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsNumber, IsOptional } from 'class-validator';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
/**
* Queries
@@ -39,6 +50,30 @@ export class GetChatMessagesFilter {
@IsBoolean()
@IsOptional()
'filter.fromMe'?: boolean;
@ApiProperty({
required: false,
description: 'Filter messages by acknowledgment status',
enum: WAMessageAckName,
})
@IsEnum(WAMessageAckName)
@IsOptional()
'filter.ack'?: WAMessageAck;
}
export function transformAck(
filter: GetChatMessagesFilter,
): GetChatMessagesFilter {
if (!filter) return filter;
if (!filter['filter.ack']) return filter;
const ackName = filter['filter.ack'];
// @ts-ignore
const ack: WAMessageAck = WAMessageAck[ackName];
if (ack == null) {
throw new BadRequestException(`Invalid ack: '${ackName}'`);
}
filter['filter.ack'] = ack;
return filter;
}
export class ChatPictureQuery {
@@ -62,7 +97,7 @@ export class GetChatMessagesQuery {
@IsNumber()
@IsOptional()
@Type(() => Number)
limit: number = 100;
limit: number = 10;
@IsNumber()
@IsOptional()
@@ -80,6 +115,35 @@ export class GetChatMessagesQuery {
downloadMedia: boolean = true;
}
export class ReadChatMessagesQuery {
@ApiProperty({
example: 30,
required: false,
description: 'How much messages to read (latest first)',
})
@Type(() => Number)
@IsNumber()
@IsOptional()
messages: number;
@ApiProperty({
required: false,
description: 'How much days to read (latest first)',
})
@Type(() => Number)
@IsNumber()
@IsOptional()
days: number = 7;
}
export class ReadChatMessagesResponse {
@ApiProperty({
required: false,
description: 'Messages IDs that have been read',
})
ids?: string[];
}
export class GetChatMessageQuery {
@ApiProperty({
example: true,
@@ -132,6 +196,29 @@ export class OverviewPaginationParams extends LimitOffsetParams {
limit?: number = 20;
}
export class OverviewFilter {
@IsOptional()
@IsArray()
@IsString({ each: true })
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
@ApiProperty({
description: 'Filter by chat ids',
required: false,
example: ['111111111@c.us'],
})
ids?: string[];
}
export class OverviewBodyRequest {
@ValidateNested()
@Type(() => OverviewPaginationParams)
pagination: OverviewPaginationParams;
@ValidateNested()
@Type(() => OverviewFilter)
filter: OverviewFilter;
}
export class ChatSummary {
id: string;
name: string | null;
+24 -5
View File
@@ -32,7 +32,11 @@ import {
VoiceBinaryFile,
VoiceRemoteFile,
} from './files.dto';
import { ChatIdProperty, ReplyToProperty } from './properties.dto';
import {
ChatIdProperty,
ConvertApiProperty,
ReplyToProperty,
} from './properties.dto';
/**
* Queries
@@ -82,19 +86,24 @@ export class ChatRequest extends SessionBaseRequest {
export class SendSeenRequest extends ChatRequest {
@ApiProperty({
example: 'false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA',
example: null,
deprecated: true,
required: false,
description:
"NOWEB engine only - it's important to mark ALL messages as seen",
})
messageId?: string;
@ApiProperty({
example: ['false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA'],
required: false,
})
messageIds?: string[];
@ApiProperty({
example: '11111111111@c.us',
required: false,
default: null,
description:
'NOWEB engine only - the ID of the user that sent the message (undefined for individual chats)',
'NOWEB engine only - the ID of the user that sent the message (undefined for individual chats)',
})
participant?: string;
}
@@ -159,6 +168,9 @@ export class MessageContactVcardRequest extends ChatRequest {
],
})
contacts: (VCardContact | Contact)[];
@ReplyToProperty()
reply_to?: string;
}
export class MessageTextRequest extends ChatRequest {
@@ -312,6 +324,9 @@ export class MessageVoiceRequest extends ChatRequest {
@ReplyToProperty()
reply_to?: string;
@ConvertApiProperty()
convert: boolean;
}
@ApiExtraModels(VideoRemoteFile, VideoBinaryFile)
@@ -338,6 +353,9 @@ export class MessageVideoRequest extends ChatRequest {
example: false,
})
asNote?: boolean;
@ConvertApiProperty()
convert: boolean;
}
export class MessageLinkPreviewRequest extends ChatRequest {
@@ -412,6 +430,7 @@ export class MessageDestination {
to: string;
from: string;
fromMe: boolean;
participant?: string;
}
export class MessageButtonReply extends ChatRequest {
+12
View File
@@ -8,6 +8,7 @@ export enum WAHAEvents {
MESSAGE_ACK = 'message.ack',
MESSAGE_WAITING = 'message.waiting',
MESSAGE_REVOKED = 'message.revoked',
MESSAGE_EDITED = 'message.edited',
STATE_CHANGE = 'state.change',
GROUP_JOIN = 'group.join',
GROUP_LEAVE = 'group.leave',
@@ -26,6 +27,8 @@ export enum WAHAEvents {
LABEL_DELETED = 'label.deleted',
LABEL_CHAT_ADDED = 'label.chat.added',
LABEL_CHAT_DELETED = 'label.chat.deleted',
EVENT_RESPONSE = 'event.response',
EVENT_RESPONSE_FAILED = 'event.response.failed',
ENGINE_EVENT = 'engine.event',
}
@@ -68,4 +71,13 @@ export enum WAMessageAck {
PLAYED = 4,
}
export enum WAMessageAckName {
ERROR = 'ERROR',
PENDING = 'PENDING',
SERVER = 'SERVER',
DEVICE = 'DEVICE',
READ = 'READ',
PLAYED = 'PLAYED',
}
export const ACK_UNKNOWN = 'UNKNOWN';
+144
View File
@@ -0,0 +1,144 @@
import { ApiProperty } from '@nestjs/swagger';
import { MessageDestination } from '@waha/structures/chatting.dto';
import { WAMessageBase } from '@waha/structures/responses.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { ChatIdProperty, ReplyToProperty } from './properties.dto';
export class EventLocation {
@ApiProperty({
description: 'Name of the location',
example: 'Luxe Nail Studio 💅',
})
@IsString()
@IsNotEmpty()
name: string;
//
// Doesn't work right now
//
// @ApiProperty({
// description: 'Latitude of the location',
// example: 38.8937255,
// })
// @IsNumber()
// @IsOptional()
// degreesLatitude?: number;
//
// @ApiProperty({
// description: 'Longitude of the location',
// example: -77.0969763,
// })
// @IsNumber()
// @IsOptional()
// degreesLongitude?: number;
}
export class EventMessage {
@ApiProperty({
description: 'Name of the event',
example: "John's Nail Appointment 💅",
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
description: 'Description of the event',
example:
"It's time for your nail care session! 🌟\\n\\nYou'll be getting a *classic gel manicure* – clean, polished, and long-lasting. 💖\\n\\n📍 *Location:* Luxe Nail Studio\\nWe're on the *2nd floor of the Plaza Mall*, next to the flower shop. Look for the *pink neon sign*!\\n\\nFeel free to arrive *5–10 mins early* so we can get started on time 😊",
required: false,
})
@IsString()
@IsOptional()
description?: string;
@ApiProperty({
description: 'Start time of the event (Unix timestamp in seconds)',
example: 2063137000,
})
@IsNumber()
@IsNotEmpty()
startTime: number;
@ApiProperty({
description: 'End time of the event (Unix timestamp in seconds)',
example: null,
required: false,
})
@IsNumber()
@IsOptional()
endTime?: number;
@ApiProperty({
description: 'Location of the event',
required: false,
type: EventLocation,
})
@ValidateNested()
@Type(() => EventLocation)
@IsOptional()
location?: EventLocation;
@ApiProperty({
description: 'Whether extra guests are allowed',
example: false,
required: false,
})
@IsBoolean()
@IsOptional()
extraGuestsAllowed?: boolean;
}
export class EventMessageRequest {
@ChatIdProperty()
chatId: string;
event: EventMessage;
@ReplyToProperty()
reply_to?: string;
}
export class EventCancelRequest {
@ApiProperty({
description: 'ID of the event message to cancel',
example: 'true_12345678901@c.us_ABCDEFGHIJKLMNOPQRST',
})
@IsString()
@IsNotEmpty()
id: string;
}
export enum EventResponseType {
UNKNOWN = 'UNKNOWN',
GOING = 'GOING',
NOT_GOING = 'NOT_GOING',
MAYBE = 'MAYBE',
}
export class EventResponse {
response: EventResponseType;
timestampMs: number;
extraGuestCount: number;
}
export class EventResponsePayload extends WAMessageBase {
eventCreationKey: MessageDestination;
eventResponse?: EventResponse;
/** Returns a message in a raw format */
@ApiProperty({
description:
'Message in a raw format that we get from WhatsApp. May be changed anytime, use it with caution! It depends a lot on the underlying backend.',
})
_data?: any;
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more