Compare commits

..
150 Commits
Author SHA1 Message Date
devlikepro 96da96281d [core] 2025.12.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-12-05 00:15:53 -08:00
devlikepro 50e09c3680 [core] Up dashboard 2025-12-05 00:15:53 -08:00
devlikepro 1da6a16640 [core] for-swagger 2025-12-05 00:15:52 -08:00
devlikepro 10f80132f8 [core] NOWEB - poll.vote iterate over creators/votes
fix #1688
2025-12-05 00:15:52 -08:00
devlikepro 750b2b86b5 [core] WEBJS - WORKING as AUTHENTICATED
fix #1690
close #1695
2025-12-05 00:15:51 -08:00
devlikepro 2fc2caed85 [core] ChatWoot - mark as read flag
fix #1650
2025-12-05 00:15:50 -08:00
devlikepro 1431e8c0f1 [core] Apps allow to remove app if not enabled 2025-12-05 00:15:50 -08:00
devlikepro 01c9c2ca56 [core] session FAILED if apps not started 2025-12-05 00:15:49 -08:00
devlikepro f5d216df6f [core] apps in sessions 2025-12-05 00:15:49 -08:00
devlikepro 7614822d2b [core] Apps - upsert to service 2025-12-05 00:15:49 -08:00
devlikepro d2eb463d32 [core] Apps on and off 2025-12-05 00:15:49 -08:00
devlikepro 07b1e50534 [core] Remove apps by session 2025-12-05 00:15:49 -08:00
devlikepro 41b6b3459e [core] Calls App
fix #1694
2025-12-05 00:13:15 -08:00
devlikepro 0f5fcf60f6 [core] ChatWoot - markdown fix for symbols in the middle of text
fix #1643
2025-12-05 00:13:14 -08:00
devlikepro 18d8330ab3 [core] Up GOWS 2025-12-05 00:13:14 -08:00
devlikepro 7de5f461aa [core] ChatWoot - call messages 2025-12-05 00:13:13 -08:00
devlikepro caa055abed [core] ChatWoot - weaken the type for handler 2025-12-05 00:13:13 -08:00
devlikepro 49af88dea3 [core] ChatWoot - type based on private flag or fromMe 2025-12-05 00:13:13 -08:00
devlikepro 05d856d8ae [core] WEBJS - call.rejected for API rejection 2025-12-05 00:13:12 -08:00
devlikepro c5c99136fa [core] Reject Calls - WEBJS, NOWEB, GOWS
fix #608
fix #1258
2025-12-05 00:13:12 -08:00
devlikepro a8b520012f [core] GOWS - add call events
fix #1673
2025-12-05 00:13:12 -08:00
devlikepro 449df53c19 [core] Add _data to calls event - WEBJS, NOWEB 2025-12-05 00:13:12 -08:00
devlikepro 69ac19b5c0 [core] AGENTS.md 2025-12-05 00:13:12 -08:00
devlikepro 163b11aa36 [core] 2025.11.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-12-01 11:46:58 +07:00
devlikepro f4171a9086 [core] Up WEBJS
fix #1678
2025-12-01 11:46:57 +07:00
devlikepro dfbae9c71e [core] GOWS - 128Mb max back in grpc (shouldn't happen tho) 2025-12-01 11:46:57 +07:00
devlikepro 91caa7ba25 [core] nodejs 24.11 2025-12-01 11:46:56 +07:00
devlikepro 2ac57bb6c3 [core] GOWS - send media via file 2025-12-01 11:46:56 +07:00
devlikepro e01647eb48 [core] GOWS - fetch media via file 2025-12-01 11:46:55 +07:00
devlikepro 90bc05ecf3 [core] Use uniqid in webhook for request id 2025-12-01 11:46:54 +07:00
devlikepro 21998cbfae [core] log event 2025-12-01 11:46:53 +07:00
devlikepro 87c49a8766 [core] downgrade google-protobug 2025-12-01 11:46:53 +07:00
devlikepro 4e78d559a8 [core] Disable cpu-prof when WAHA_DEBUG_MODE=true
fix #1681
2025-12-01 11:46:53 +07:00
devlikepro 18ce902c60 [core] Up GOWS to 1.0.24 2025-12-01 11:46:53 +07:00
devlikepro 5784db8055 [core] 2025.11.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-11-28 14:02:15 +07:00
devlikepro 6862063654 [core] Up GOWS 2025-11-28 14:02:14 +07:00
devlikepro 4aea282356 [core] ChatWoot - do not log binary 2025-11-28 14:02:13 +07:00
devlikepro 82ff15ae56 [core] GOWS - 10 mins deadline for media fetch 2025-11-28 14:02:12 +07:00
devlikepro 6cbf8cded2 [core] Up Dashboard - app id is uuid now 2025-11-28 14:02:11 +07:00
devlikepro 35e9418988 [core] add cpu profiling for troubleshooting 2025-11-28 14:02:10 +07:00
devlikepro a58be84e56 [core] GOWS - Use one grpc client to handle all sessions (but per session stream) 2025-11-28 14:02:09 +07:00
devlikepro 2994e0a54d [core] ignore tmp 2025-11-28 14:02:09 +07:00
devlikepro f16100e1d9 [core] GOWS - logs for grpc client closing 2025-11-28 14:02:07 +07:00
devlikepro e57be58d48 [core] GOWS - up grpc 2025-11-28 14:02:06 +07:00
devlikepro 7909910dc6 [core] ChatWoot - limit names to 255
fix #1556
2025-11-28 14:02:05 +07:00
devlikepro 7f1778ceb0 [core] ChatWoot - do not create new conversation when closed on message.ack
close devlikeapro/waha-plus#28
fix #1646
2025-11-28 14:02:05 +07:00
devlikepro aba90574d8 [core] ChatWoot - remove unused imports 2025-11-28 14:02:05 +07:00
devlikepro 33c945bf95 [core] NOWEB - Up engine 2025-11-28 14:02:04 +07:00
devlikepro d40e82eb02 [core] WEBJS - up engine 2025-11-28 14:02:04 +07:00
devlikepro 6e82d6f40b [core] GOWS - force to use cgo dns
fix #1649
2025-11-28 14:02:03 +07:00
devlikepro e193eb5696 [core] Up go 1.24
Tho we don't built anything here
2025-11-28 14:02:03 +07:00
devlikepro 8c63c4b097 [core] WEBJS - fix code pairing
fix #1651
2025-11-28 14:02:03 +07:00
devlikepro 4127d609cf [core] for-swagger 2025-11-28 14:02:03 +07:00
devlikepro 57f6db4252 [core] 2025.11.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-11-14 10:23:33 +07:00
devlikepro 68a91cb55c [core] up GOWS v1.0.21 2025-11-14 10:23:32 +07:00
devlikepro 869a343e43 [core] API - mentions for send image, file, video 2025-11-14 10:23:32 +07:00
devlikepro fd2b22b339 [core] Up better-sqlite3 to 12.4.1 2025-11-14 10:23:31 +07:00
devlikepro 90d3a5b1ad [core] NOWEB - add pool for sqlite3 2025-11-14 10:23:31 +07:00
devlikepro b4a2ad6b96 [core] WAHA_SQLITE_ENGINE - Add a way to use better-sqlite3 (sync sqlite3 engine) 2025-11-14 10:23:31 +07:00
devlikepro 27ff7e7a21 [core] Reset presence on engine restart (end) 2025-11-14 10:23:30 +07:00
devlikepro 94ae624662 [core] Set to OFFLINE always 2025-11-14 10:23:30 +07:00
devlikepro 52e018aa93 [core] Use timestamps.activity 2025-11-14 10:23:29 +07:00
Berg Pinheiro 8bd13a233e [core] Add automatic presence management and activity tracking
fix #1584
closes #1586

This prevents WhatsApp from disconnecting sessions due to inactivity by
signaling activity through presence updates while avoiding constant online
status that could generate unnecessary notifications.
2025-11-14 10:23:28 +07:00
Emir Muhammadzadeh 87f79c1dc5 [core] NOWEB, GOWS - add location and vcards
closes #1609
2025-11-14 10:23:28 +07:00
devlikepro 31533b5d19 [core] GOWS - move waproto helper to engine 2025-11-14 10:23:27 +07:00
devlikepro 27cc9f4764 [core] Add opustags 2025-11-14 10:23:27 +07:00
devlikepro 4fdfce3944 [core] 2025.11.1
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
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
2025-11-07 13:55:03 +07:00
devlikepro 1e12a83cdf [core] Up GOWS
Fixes for poll.vote using @lid
fix #1561
fix #1570

Ignore status@broadcast for ack updates
fix #1557

Increase gRPC limit to 512MB
fix #1365
2025-11-07 13:55:03 +07:00
devlikepro 1c41b8be65 [core] Add mentions: ["all"] to /api/sendText in groups - WEBJS, GOWS, NOWEB
fix #1377
2025-11-07 13:55:03 +07:00
devlikepro f72ee7c1f8 [core] Add /api/:session/groups/:id/participants/v2 - WEBJS, GOWS, NOWEB 2025-11-07 13:55:03 +07:00
devlikepro e75912435b [core] GOWS - support mentions
fix #1372
2025-11-07 13:55:02 +07:00
devlikepro da294a58a6 [core] GOWS - increase gRPC limits to 512MB
fix #1365
2025-11-07 13:55:01 +07:00
devlikepro 7689199e11 [core] ChatWoot - no chatwoot/waha mention in links for jobs 2025-11-07 13:55:01 +07:00
devlikepro 324ab11f09 [core] Fix Chat UI on dashboard (sortBy: messageTimestamp in /messages)
fix #1602
2025-11-07 13:55:00 +07:00
devlikepro b1f2e17e3d [core] Up NOWEB
fix #1590
fix #1605
2025-11-07 13:55:00 +07:00
devlikepro 90822cd582 [core] Up WEBJS
fix #1583
fix #1546
2025-11-07 13:54:59 +07:00
devlikepro 9eec72b641 [core] dotenv 2025-11-07 13:54:59 +07:00
devlikepro 050c652480 [core] Exclude /ping and /health using WAHA_API_KEY_EXCLUDE_PATH from all auth
fix #1562
2025-11-07 13:54:59 +07:00
devlikepro c44680c987 [core] 2025.10.5
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 / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (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-11-04 14:00:34 +07:00
devlikepro 9394509a07 [core] ChatWoot - timestamp for old messages 2025-11-04 14:00:33 +07:00
devlikepro 63890dc499 [core] ChatWoot - hour12 2025-11-04 14:00:33 +07:00
devlikepro e2330901e4 [core] ChatWoot - resolve conversations option 2025-11-04 14:00:32 +07:00
devlikepro 58672fa1e1 [core] ChatWoot - sent from at the top 2025-11-04 14:00:32 +07:00
devlikepro 3890071f77 [core] ChatWoot - Album Message
fix #1405

closes devlikeapro/waha-plus#24

(cherry picked from bergpinheiro/waha-plus@947fc000bb)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-11-04 14:00:31 +07:00
devlikepro d60dfba90a [core] ChatWoot - no from me tag for history (from whatsapp or API) 2025-11-04 14:00:31 +07:00
devlikepro 4e58713e7e [core] Up GOWS - sort messages API, ctx 2025-11-04 14:00:31 +07:00
devlikepro 6f0af3c7cc [core] Up dashboard - chatwoot queue commands 2025-11-04 14:00:30 +07:00
devlikepro 23bac2e7b1 [core] ChatWoot - no additional content if we know it sent from CW 2025-11-04 14:00:30 +07:00
devlikepro e839203dbd [core] i18n 2025-11-04 14:00:30 +07:00
devlikepro 304b0037ec [core] ChatWoot - queue management 2025-11-04 14:00:28 +07:00
devlikepro 482dbbeb0e [core] ChatWoot - messages pull
[core] ChatWoot - HasMediaNoMedia message

fix #1231
2025-11-04 14:00:26 +07:00
devlikepro 8f4c79baf4 [core] Up ChatWoot to 4.7.0 2025-11-04 14:00:26 +07:00
devlikepro 31038a9318 [core] redis lock - trace log 2025-11-04 14:00:26 +07:00
devlikepro 86fde1f670 [core] WEBJS - fix get messages offset 2025-11-04 14:00:26 +07:00
devlikepro 984ef0c6a1 [core] NOWEB - Ignore history sync notification 2025-11-04 14:00:25 +07:00
devlikepro 744ee1abb1 [core] ChatWoot - Increase CONVERSATION_MESSAGE_PER_MINUTE_LIMIT to 3000 by default for "messages pull" 2025-11-04 14:00:25 +07:00
devlikepro 47f4f2670d [core] NOWEB - sqlite3 increase timeouts 2025-11-04 14:00:24 +07:00
devlikepro f16eaed935 [core] ChatWoot - keep mapping for 365 days 2025-11-04 14:00:24 +07:00
devlikepro dfe9454a72 [core] ChatWoot - debug logging for API requests 2025-11-04 14:00:24 +07:00
devlikepro 4aef38ff04 [core] Add sortOrder, sortBy to /messages 2025-11-04 14:00:22 +07:00
devlikepro ab8de51022 [core] ChatWoot - no chatwoot in user notifications 2025-11-04 14:00:22 +07:00
devlikepro c001d7fed6 [core] ChatWoot - contacts pull
fix #1231
2025-11-04 14:00:21 +07:00
devlikepro cc056dca9c [core] ChatWoot - timeZoneName short 2025-11-04 14:00:19 +07:00
devlikepro 47f60a1626 [core] cli 2025-11-04 14:00:18 +07:00
devlikepro 98721051da [core] parse jid - normalize it before
fix #1545
2025-11-04 14:00:18 +07:00
devlikepro 5d7a6f4ff7 [core] WEBJS - normalize "from" in message.reaction
fix #1545
2025-11-04 14:00:18 +07:00
devlikepro dcb7bf033f [core] ChatWoot WEBJS - fix message.reaction
fix #1545
2025-11-04 14:00:17 +07:00
devlikepro 7d29be2d69 [core] ChatWoot - mark read only if chatwoot message found 2025-11-04 14:00:17 +07:00
devlikepro 41b669a35c [core] 2025.10.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-10-19 17:08:06 +07:00
devlikepro 7b5ca3bc57 [core] Up Dashboard 2025-10-19 17:08:06 +07:00
devlikepro bb0c7fd22b [core] NOWEB, GOWS - better handle message.edited
fix #1394
2025-10-19 17:08:05 +07:00
devlikepro c79958541e [core] GOWS - use proto type 2025-10-19 17:08:05 +07:00
devlikepro 94ee8863d6 [core] Up Dashboard - headers, agent name 2025-10-19 17:08:04 +07:00
devlikepro 011477262d [core] Up NOWEB
fix #1538
2025-10-19 17:08:04 +07:00
devlikeproandbergpinheiro 2e8e4cefb0 [core] Fixes inverted logic of messages read in ChatWoot
closes devlikeapro/waha-plus#20

Adapted from: bergpinheiro/read-chatwoot-invertida@ef2ff1b9de

(cherry picked from commit ef2ff1b9deceeea3c26747531e26d66e96f931fd)
(cherry picked from commit 3dc3440d2d5077279934577007f89cd640f60d9e)

Co-authored-by: bergpinheiro <bergpinheiro@users.noreply.github.com>"
2025-10-19 17:08:04 +07:00
devlikepro 5e2802c098 [core] metadata is Record 2025-10-19 17:08:04 +07:00
devlikepro 9a6250a055 [core] Add jid to MeInfo type 2025-10-19 17:08:03 +07:00
devlikepro f8cb56525a [core] has recent 2025-10-19 17:08:03 +07:00
devlikepro b5b491cb7b [core] en in waha 2025-10-19 17:08:02 +07:00
devlikepro 7eb6be70f9 [core] dev image 2025-10-19 17:08:02 +07:00
devlikepro 0de8a5fabc [core] NOWEB - Fix sending media to channels
fix #1523
2025-10-19 17:08:02 +07:00
Berg Pinheiro 9fbc18fb70 [core] Fix URL breaking in ChatWoot integration
- Fix WhatsappToMarkdown function to preserve URLs with underscores
- URLs are now protected before markdown transformations
- Prevents Sarah_Jessica_Parker from becoming Sarah*Jessica*Parker
- Add comprehensive tests for URL preservation scenarios
- Maintains existing markdown formatting for non-URL text
- All comments in English for code consistency

Fixes: Links with underscores being broken in ChatWoot messages

fix devlikeapro/waha#1522
closes devlikeapro/waha-plus#19
2025-10-19 17:08:02 +07:00
devlikepro e7ce3368b0 [core] Fix tests 2025-10-19 17:08:02 +07:00
devlikepro 729600e9c6 [core] voice 2025-10-17 11:44:55 +07:00
devlikepro 54886f385b [core] Disable pt 2025-10-17 10:41:35 +07:00
devlikepro 81854ad7ec [core] Fix smoke test
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-10-16 17:07:47 +07:00
devlikepro 5261d4f281 [core] 2025.10.3 2025-10-16 16:57:01 +07:00
devlikepro 72d9d94342 [core] NOWEB - use upstream libsignal 2025-10-16 16:57:01 +07:00
devlikepro b782ad27a2 [core] ChatWoot - mark read fix one more time 2025-10-16 16:56:36 +07:00
devlikepro d7dad5e6c2 [core] rm unused imports 2025-10-16 16:56:35 +07:00
devlikepro 8765bb4412 [core] init-waha 2025-10-16 16:56:35 +07:00
devlikepro 984be25d51 [core] Replace 111 as well 2025-10-16 16:56:30 +07:00
devlikepro 70620207dd [core] Adjust .env 2025-10-16 16:56:29 +07:00
devlikepro a96200f981 [core] Adjust message 2025-10-16 16:56:29 +07:00
devlikepro 4978abf3d8 [core] Up GOWS
fix #1503
fix #1511
2025-10-16 16:56:27 +07:00
devlikepro a67d84f21e [core] ChatWoot - List Message 2025-10-16 16:56:27 +07:00
devlikepro 91f59b38fd [core] Dashboard - Chats
fix #1454
2025-10-16 16:56:27 +07:00
devlikepro d0ec351910 [core] Fix /api/sendImage when using proxy
fix #1449
fix #1510
2025-10-16 16:56:26 +07:00
devlikepro a7b12874fb [core] Require auth by default 2025-10-16 16:56:24 +07:00
devlikepro cd2c7bdd86 [core] Up creds in .env 2025-10-16 16:56:24 +07:00
devlikepro 045077143f [core] no warning about plain key 2025-10-16 16:56:24 +07:00
devlikepro bcd2199687 [core] ChatWoot - remove (WAHA) from contact name 2025-10-16 16:56:20 +07:00
devlikepro af108eede4 [core] NOWEB - normalize @lid
fix #1493
2025-10-16 16:56:19 +07:00
devlikepro ee1cf838b8 [core] NOWEB - use IPv4 by default 2025-10-16 16:56:19 +07:00
devlikepro 0fc74895da [core] Up NOWEB - add more logs for media 2025-10-16 16:56:19 +07:00
devlikepro 394937c154 [core] 2025.10.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-10-14 15:21:40 +07:00
devlikepro 71f9adeff4 [core] ChatWoot - ignore fromMe in ack
mention #1492
2025-10-14 15:21:40 +07:00
devlikepro e13ccdab9c [core] ChatWoot - disable error/succeeded messages
mention #1492
mention #1395
2025-10-14 15:21:40 +07:00
devlikepro 2074748a7a [core] ChatWoot - no message.ack error
mention #1492
2025-10-14 15:21:39 +07:00
devlikepro 283d63394e [core] Up NOWEB
fix #1495
fix #1499
2025-10-14 15:21:39 +07:00
220 changed files with 15408 additions and 2097 deletions

No files matched your search

+3
View File
@@ -1,3 +1,4 @@
tmp/*
Dockerfile
docker-compose
src/core/engines/gows/proto
@@ -10,6 +11,8 @@ docs
examples
.github
.git
.envs/*
.env
# compiled output
/dist
+22 -13
View File
@@ -3,9 +3,25 @@
# https://waha.devlike.pro/docs/how-to/config/
#
# ====================
# ===== SECURITY =====
# ====================
WAHA_API_KEY=00000000000000000000000000000000
WAHA_DASHBOARD_USERNAME=admin
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
WHATSAPP_SWAGGER_USERNAME=admin
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
# Disable Dashboard or Swagger
WAHA_DASHBOARD_ENABLED=True
WHATSAPP_SWAGGER_ENABLED=True
# ==================
# ===== COMMON =====
# ==================
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=WEBJS
# Base URL for the API (used for webhooks, file URLs, etc.)
WAHA_BASE_URL=http://localhost:3000
# WAHA_PUBLIC_URL=http://localhost:3000
@@ -15,20 +31,13 @@ WAHA_BASE_URL=http://localhost:3000
# WHATSAPP_API_PORT=3000
# WHATSAPP_API_HOSTNAME=localhost
# ====================
# ===== 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
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=WEBJS
# ===================
# ====== GOWS =======
# ===================
# By default, GOWS uses cgo (libc) DNS resolver
# You can ask to use golang one, but it's less reliable
# GODEBUG=netdns=go
# ===================
# ===== LOGGING =====
+1
View File
@@ -129,6 +129,7 @@ jobs:
docker run -d \
--name smoke \
--rm -p3000:3000 \
-e WAHA_API_KEY=test \
${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
sleep 3
+126
View File
@@ -0,0 +1,126 @@
name: Dev
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
${{ matrix.tag }}
strategy:
matrix:
include:
- runner: 'buildjet-4vcpu-ubuntu-2204'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-arm'
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check recent changes
id: recent
run: |
count=$(git rev-list --after="24 hours ago" --count HEAD || true)
if [ "$count" -gt 0 ]; then
echo "has_recent=true" >> "$GITHUB_OUTPUT"
else
echo "has_recent=false" >> "$GITHUB_OUTPUT"
fi
- name: No recent changes summary
if: ${{ steps.recent.outputs.has_recent != 'true' }}
run:
echo "No commits in the last 24 hours; skipping build." >>
"$GITHUB_STEP_SUMMARY"
- name: Set up Docker Buildx
if: ${{ steps.recent.outputs.has_recent == 'true' }}
uses: docker/setup-buildx-action@v2
- name: Docker meta
if: ${{ steps.recent.outputs.has_recent == 'true' }}
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ vars.DOCKER_IMAGE }}
flavor: latest=false
tags: |
type=raw,value=${{ matrix.tag }}
- name: Login to image repository
if: ${{ steps.recent.outputs.has_recent == 'true' }}
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
if: ${{ steps.recent.outputs.has_recent == 'true' }}
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Install goss
if: ${{ steps.recent.outputs.has_recent == 'true' }}
env:
GOSS_DOWNLOAD: 'https://github.com/goss-org/goss/releases/download'
GOSS_VERSION: 'v0.4.8'
run: |
curl -L https://github.com/goss-org/goss/releases/download/${{ env.GOSS_VERSION }}/${{ matrix.goss }} -o /usr/local/bin/goss
chmod +rx /usr/local/bin/goss
- name: Run smoke tests
if: ${{ steps.recent.outputs.has_recent == 'true' }}
working-directory: tests/smoke
timeout-minutes: 2
run: |
docker run -d \
--name smoke \
--rm -p3000:3000 \
-e WAHA_API_KEY=test \
${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
sleep 3
docker logs smoke
goss validate --retry-timeout 30s --sleep 1s
docker rm --force smoke
- name: Push
if: ${{ steps.recent.outputs.has_recent == 'true' }}
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+18 -18
View File
@@ -15,6 +15,7 @@ on:
jobs:
en:
if: github.repository == 'devlikeapro/waha'
permissions:
issues: write
discussions: write
@@ -31,21 +32,20 @@ jobs:
CUSTOM_BOT_NOTE: 'English'
TARGET_LANGUAGE: 'eng'
TRANSLATED_LANGUAGE: 'en'
pt:
permissions:
issues: write
discussions: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: devlikeapro/github-translate-action@main
env:
# GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
IS_MODIFY_TITLE: false
CUSTOM_BOT_NOTE: 'Portuguese'
TARGET_LANGUAGE: 'por'
TRANSLATED_LANGUAGE: 'pt'
# pt:
# permissions:
# issues: write
# discussions: write
# pull-requests: write
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: devlikeapro/github-translate-action@main
# env:
# # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# IS_MODIFY_TITLE: false
# CUSTOM_BOT_NOTE: 'Portuguese'
# TARGET_LANGUAGE: 'por'
# TRANSLATED_LANGUAGE: 'pt'
+2
View File
@@ -1,5 +1,7 @@
tmp/*
src/core/engines/gows/proto
.env
.envs/*
*.heapsnapshot
.yarn
example.ts
+1 -1
View File
@@ -1 +1 @@
v22.16
v24.11
+1
View File
@@ -73,4 +73,5 @@ repos:
^src/core/engines/webjs/_lodash.js|
^tests/perf/.*|
^scripts/.*|
^scripts/init-waha.js|
)$
+6
View File
@@ -120,5 +120,11 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## Local Engine Sources
- WebJS source: `../whatsapp-web.js`
- Baileys (NoWeb) source: `../WhiskeySockets-Baileys`
- GoWhatsApp (GoWS) source: `../gows` + `../whatsmeow`
Following this playbook keeps contributions aligned with WAHA’s structure,
automation hooks, and release process.
+27 -2
View File
@@ -1,5 +1,5 @@
ARG NODE_IMAGE_TAG=22.16-bookworm-slim
ARG GOLANG_IMAGE_TAG=1.23-bookworm
ARG NODE_IMAGE_TAG=24.11-bookworm-slim
ARG GOLANG_IMAGE_TAG=1.24-bookworm
#
# Build
@@ -163,6 +163,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="140.0.7339.80-1"
ARG OPUSTAGS_VERSION="1.10.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 \
@@ -176,6 +177,22 @@ RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
# Build and install opustags so audio metadata can be cleaned up inside the container
RUN set -eux; \
buildDeps='build-essential cmake pkg-config libogg-dev'; \
apt-get update; \
apt-get install -y --no-install-recommends ${buildDeps}; \
mkdir -p /tmp/opustags; \
curl -L https://github.com/fmang/opustags/archive/refs/tags/${OPUSTAGS_VERSION}.tar.gz \
| tar -xz -C /tmp/opustags; \
cd /tmp/opustags/opustags-${OPUSTAGS_VERSION}; \
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/usr/local -DCMAKE_BUILD_TYPE=Release; \
cmake --build build --config Release; \
cmake --install build; \
rm -rf /tmp/opustags; \
apt-get purge -y --auto-remove ${buildDeps}; \
rm -rf /var/lib/apt/lists/*
# GOWS requirements
# libc6
RUN apt-get update \
@@ -195,6 +212,11 @@ COPY --from=build /git/node_modules ./node_modules
COPY --from=build /git/dist ./dist
COPY --from=dashboard /dashboard ./dist/dashboard
COPY --from=gows /go/gows/bin/gows /app/gows
COPY .env.example ./.env.example
COPY scripts/init-waha.js ./scripts/init-waha.js
RUN chmod +x ./scripts/init-waha.js \
&& printf '%s\n' '#!/bin/sh' 'exec node /app/scripts/init-waha.js "$@"' > /usr/local/bin/init-waha \
&& chmod +x /usr/local/bin/init-waha
ENV WAHA_GOWS_PATH=/app/gows
ENV WAHA_GOWS_SOCKET=/tmp/gows.sock
@@ -207,6 +229,9 @@ ENV CHOKIDAR_INTERVAL=5000
# WAHA variables
ENV WAHA_ZIPPER=ZIPUNZIP
# GOWS - use libc DNS resolver
ENV GODEBUG netdns=cgo
# Run command, etc
EXPOSE 3000
# Use tini as init system to handle zombie processes properly
+3 -3
View File
@@ -32,16 +32,16 @@ push:
docker push devlikeapro/waha
for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-10-10
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-11-21
up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-09-10
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-11-05
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
+1
View File
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true
CONVERSATION_MESSAGE_PER_MINUTE_LIMIT=3000
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
+14 -14
View File
@@ -3,6 +3,20 @@
# https://waha.devlike.pro/docs/how-to/config/
#
# ====================
# ===== SECURITY =====
# ====================
WAHA_API_KEY_PLAIN=00000000000000000000000000000000
WAHA_API_KEY=00000000000000000000000000000000
WAHA_DASHBOARD_USERNAME=admin
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
WHATSAPP_SWAGGER_USERNAME=admin
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
# Disable Dashboard or Swagger
WAHA_DASHBOARD_ENABLED=True
WHATSAPP_SWAGGER_ENABLED=True
# ==================
# ===== COMMON =====
# ==================
@@ -15,20 +29,6 @@ WAHA_PUBLIC_URL=http://localhost:3000
# WHATSAPP_API_PORT=3000
# WHATSAPP_API_HOSTNAME=localhost
# ====================
# ===== SECURITY =====
# ====================
# WAHA Apps requires both sha and plain version
WAHA_API_KEY_PLAIN=00000000000000000000000000000000
WAHA_API_KEY=sha512:98b6d128682e280b74b324ca82a6bae6e8a3f7174e0605bfd52eb9948fad8984854ec08f7652f32055c4a9f12b69add4850481d9503a7f2225501671d6124648
WAHA_DASHBOARD_USERNAME=admin
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
WHATSAPP_SWAGGER_USERNAME=admin
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
WAHA_DASHBOARD_ENABLED=True
WHATSAPP_SWAGGER_ENABLED=True
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=GOWS
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- 8.8.8.8
base: &base
image: chatwoot/chatwoot:v4.6.0
image: chatwoot/chatwoot:v4.7.0
env_file: .chatwoot.env ## Change this file for customized env variables
volumes:
- chatwoot_storage:/app/storage
-5
View File
@@ -41,11 +41,6 @@ if [ -n "$key" ]; 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"
Binary file not shown.
Binary file not shown.
+13 -9
View File
@@ -26,7 +26,7 @@
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-10-10",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-11-21",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
@@ -59,12 +59,13 @@
"audio-decode": "^2.2.2",
"axios": "^1.9.0",
"axios-retry": "^4.5.0",
"better-sqlite3": "11.3.0",
"better-sqlite3": "^12.4.1",
"bullmq": "^5.48.1",
"check-disk-space": "^3.4.0",
"chokidar": "^3.6.0",
"class-transformer": "^0.5.1",
"class-validator": "0.14.0",
"commander": "^14.0.1",
"del": "^8.0.0",
"express-basic-auth": "^1.2.1",
"file-type": "16.5.4",
@@ -76,12 +77,13 @@
"joi": "^17.13.3",
"knex": "^3.1.0",
"libphonenumber-js": "^1.10.36",
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
"libsignal": "github:WhiskeySockets/libsignal-node#master",
"link-preview-js": "^3.0.4",
"lodash": "^4.17.21",
"long": "^5.3.2",
"mime-types": "^2.1.27",
"mongodb": "6.9.0",
"ms": "^2.1.3",
"mustache": "^4.2.0",
"nestjs-pino": "^4.1.0",
"node-cache": "5.1.2",
@@ -99,10 +101,13 @@
"rimraf": "^4.3.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.4",
"shell-quote": "^1.8.3",
"sqlite3": "^5.1.7",
"swagger-ui-express": "^4.1.4",
"ulid": "^2.3.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-09-10",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-11-05",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
},
@@ -114,12 +119,11 @@
"ws": "^8.18.0",
"puppeteer": "^24.19.0",
"whatwg-url": "13.0.0",
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
"axios": "^1.9.0"
},
"devDependencies": {
"@grpc/grpc-js": "^1.13.4",
"@grpc/proto-loader": "^0.7.13",
"@grpc/grpc-js": "^1.14.1",
"@grpc/proto-loader": "^0.8.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
@@ -129,6 +133,7 @@
"@types/node-fetch": "^2.6.13",
"@types/semver": "^7.7.0",
"@types/supertest": "^2.0.8",
"dotenv": "^17.2.3",
"grpc-tools": "^1.13.0",
"jest": "^29.7.0",
"oxlint": "^1.13.0",
@@ -139,8 +144,7 @@
"ts-loader": "^6.2.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.1.0",
"typescript": "5.9.2",
"yargs": "^17.7.2"
"typescript": "5.9.2"
},
"jest": {
"moduleFileExtensions": [
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
const { readFileSync } = require('fs');
const { spawnSync } = require('child_process');
const path = require('path');
const YAML = require('yaml');
const repoRoot = path.resolve(__dirname, '..');
const enPath = path.join(repoRoot, 'src/apps/chatwoot/i18n/locales/en-US.yaml');
const contents = readFileSync(enPath, 'utf8');
const document = YAML.parse(contents);
const flatten = (node, prefix = '') => {
if (node && typeof node === 'object' && !Array.isArray(node)) {
return Object.entries(node).reduce((acc, [key, value]) => {
const next = prefix ? `${prefix}.${key}` : key;
return acc.concat(flatten(value, next));
}, []);
}
return [prefix];
};
const allKeys = flatten(document).filter(Boolean);
const ignoredPrefixes = ['datetime.', 'locale.'];
const ignored = new Set();
const unused = [];
const searchRoots = ['src', 'tests'];
for (const key of allKeys) {
if (
ignoredPrefixes.some((prefix) => key.startsWith(prefix)) ||
!key.includes('.')
) {
ignored.add(key);
continue;
}
const result = spawnSync(
'rg',
[
'--files-with-matches',
'--fixed-strings',
key,
...searchRoots,
'--glob',
'!apps/chatwoot/i18n/locales/*.yaml',
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0 || !result.stdout.trim()) {
unused.push(key);
}
}
const message = [
`Scanned ${allKeys.length} keys.`,
`Ignored ${ignored.size} keys (datetime.*, locale.*, or without dot).`,
unused.length
? `Found ${unused.length} potentially unused keys:\n - ${unused.join(
'\n - ',
)}`
: 'No unused keys detected.',
];
console.log(message.join('\n'));
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const projectRoot = path.resolve(__dirname, '..');
const args = process.argv.slice(2);
const forceIndex = args.indexOf('--force');
const forceOverwrite = forceIndex !== -1;
if (forceOverwrite) {
args.splice(forceIndex, 1);
}
const folderArg = args[0] || '.';
const sourceArgRaw = args[1];
const targetArg = args[2] || '.env';
const envDir = path.resolve(process.cwd(), folderArg);
const sourceArg = sourceArgRaw || '.env.example';
const templateBaseDir = sourceArgRaw ? envDir : projectRoot;
const templatePath = path.isAbsolute(sourceArg)
? sourceArg
: path.resolve(templateBaseDir, sourceArg);
const targetPath = path.isAbsolute(targetArg)
? targetArg
: path.resolve(envDir, targetArg);
function exitWithMessage(message, code = 1) {
console.error(message);
process.exit(code);
}
if (!fs.existsSync(envDir)) {
exitWithMessage(`Target directory does not exist: ${envDir}`);
}
if (!fs.existsSync(templatePath)) {
exitWithMessage(`Missing template file: ${templatePath}`);
}
if (!forceOverwrite && fs.existsSync(targetPath)) {
const stats = fs.statSync(targetPath);
if (stats.size > 0) {
exitWithMessage(
`${targetPath} already exists and is not empty. Remove it manually or rerun with --force to overwrite.`,
);
}
}
const exampleContent = fs.readFileSync(templatePath, 'utf8');
function generateSecret() {
return crypto.randomUUID({ disableEntropyCache: true }).replace(/-/g, '');
}
const apiKey = generateSecret();
const adminPassword = generateSecret();
const updatedContent = exampleContent
.replace(/^WAHA_API_KEY=.*/m, `WAHA_API_KEY=${apiKey}`)
.replace(/^WAHA_API_KEY_PLAIN=.*/m, `WAHA_API_KEY_PLAIN=${apiKey}`)
.replace(/^WAHA_DASHBOARD_USERNAME=.*/m, 'WAHA_DASHBOARD_USERNAME=admin')
.replace(
/^WAHA_DASHBOARD_PASSWORD=.*/m,
`WAHA_DASHBOARD_PASSWORD=${adminPassword}`,
)
.replace(/^WHATSAPP_SWAGGER_USERNAME=.*/m, 'WHATSAPP_SWAGGER_USERNAME=admin')
.replace(
/^WHATSAPP_SWAGGER_PASSWORD=.*/m,
`WHATSAPP_SWAGGER_PASSWORD=${adminPassword}`,
);
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
const lines = [
'Credentials generated.',
'',
'Dashboard and Swagger:',
' - Username: admin',
` - Password: ${adminPassword}`,
'',
'API key: ',
` - ${apiKey}`,
'',
'Use it in the Dashboard connection flow: https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
];
console.log(lines.join('\n'));
+34
View File
@@ -0,0 +1,34 @@
import {
Body,
Controller,
Post,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
export class CallsController {
constructor(private manager: SessionManager) {}
@Post('reject')
@SessionApiParam
@ApiOperation({ summary: 'Reject incoming call' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
rejectCall(
@WorkingSessionParam session: WhatsappSession,
@Body() request: RejectCallRequest,
) {
return session.rejectCall(request.from, request.id);
}
}
+7
View File
@@ -29,6 +29,7 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
MessageSortField,
OverviewBodyRequest,
OverviewFilter,
OverviewPaginationParams,
@@ -38,6 +39,7 @@ import {
transformAck,
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
@ApiSecurity('api_key')
@Controller('api/:session/chats')
@@ -120,6 +122,11 @@ class ChatsController {
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
) {
if (query.sortBy == MessageSortField.MESSAGE_TIMESTAMP) {
query.sortBy = MessageSortField.TIMESTAMP;
}
query.sortBy = query.sortBy || MessageSortField.TIMESTAMP;
query.sortOrder = query.sortOrder || SortOrder.DESC;
filter = transformAck(filter);
return session.getChatMessages(chatId, query, filter);
}
+20
View File
@@ -44,6 +44,10 @@ import {
WANumberExistResult,
} from '../structures/chatting.dto';
import { WAMessage } from '../structures/responses.dto';
import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
@ApiSecurity('api_key')
@Controller('api')
@@ -55,6 +59,10 @@ export class ChattingController {
@ApiOperation({ summary: 'Send a text message' })
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
validateRequestMentions(request);
request.mentions = await whatsapp.resolveMentionsAll(request.chatId);
}
return whatsapp.sendText(request);
}
@@ -66,6 +74,10 @@ export class ChattingController {
})
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
validateRequestMentions(request);
request.mentions = await whatsapp.resolveMentionsAll(request.chatId);
}
return whatsapp.sendImage(request);
}
@@ -77,6 +89,10 @@ export class ChattingController {
})
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
validateRequestMentions(request);
request.mentions = await whatsapp.resolveMentionsAll(request.chatId);
}
return whatsapp.sendFile(request);
}
@@ -99,6 +115,10 @@ export class ChattingController {
})
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
validateRequestMentions(request);
request.mentions = await whatsapp.resolveMentionsAll(request.chatId);
}
return whatsapp.sendVideo(request);
}
+12
View File
@@ -31,6 +31,7 @@ import {
CreateGroupRequest,
DescriptionRequest,
GroupField,
GroupParticipant,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
@@ -320,6 +321,17 @@ export class GroupsController {
return session.getParticipants(id);
}
@Get(':id/participants/v2')
@GroupIdApiParam
@SessionApiParam
@ApiOperation({ summary: 'Get group participants.' })
getGroupParticipants(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<GroupParticipant[]> {
return session.getGroupParticipants(id);
}
@Post(':id/participants/add')
@HttpCode(HttpStatus.OK)
@SessionApiParam
-1
View File
@@ -2,7 +2,6 @@ import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { PingResponse } from '@waha/structures/ping.dto';
@ApiSecurity('api_key')
@Controller('ping')
@ApiTags('🔍 Observability')
export class PingController {
+56 -1
View File
@@ -1,4 +1,6 @@
import * as inspector from 'node:inspector';
import * as v8 from 'node:v8';
import { Readable } from 'node:stream';
import {
Controller,
@@ -18,7 +20,10 @@ import {
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import {
BrowserTraceQuery,
CpuProfileQuery,
} from '@waha/structures/server.debug.dto';
import { createReadStream } from 'fs';
@ApiSecurity('api_key')
@@ -36,6 +41,56 @@ export class ServerDebugController {
this.enabled = this.config.debugModeEnabled;
}
@Get('cpu')
@ApiOperation({
summary: 'Collect and return a CPU profile for the current nodejs process',
description: 'Uses the Node.js inspector profiler to capture a .cpuprofile',
})
@UsePipes(new WAHAValidationPipe())
async cpuProfile(@Query() query: CpuProfileQuery) {
if (!this.enabled) {
throw new NotFoundException('WAHA_DEBUG_MODE is disabled');
}
const { seconds } = query;
this.logger.log(`Collecting CPU profile for ${seconds}s...`);
const session = new inspector.Session();
session.connect();
const profile = await new Promise<inspector.Profiler.Profile>(
(resolve, reject) => {
session.post('Profiler.enable', (enableError) => {
if (enableError) {
session.disconnect();
return reject(enableError);
}
session.post('Profiler.start', (startError) => {
if (startError) {
session.disconnect();
return reject(startError);
}
setTimeout(() => {
session.post('Profiler.stop', (stopError, params) => {
session.disconnect();
if (stopError) {
return reject(stopError);
}
resolve(params.profile);
});
}, seconds * 1000);
});
});
},
);
const filename = `CPU.${Date.now()}.${process.pid}.cpuprofile`;
const stream = Readable.from([JSON.stringify(profile)]);
return new StreamableFile(stream, {
type: 'application/json',
disposition: `attachment; filename=${filename}`,
});
}
@Get('heapsnapshot')
@ApiOperation({
summary: 'Return a heapsnapshot for the current nodejs process',
+55 -9
View File
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
Inject,
NotFoundException,
Param,
Post,
@@ -11,12 +12,16 @@ import {
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
AppsService,
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import {
SessionLogoutDeprecatedRequest,
SessionStartDeprecatedRequest,
@@ -29,17 +34,23 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionInfo,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
class SessionsController {
constructor(private manager: SessionManager) {}
constructor(
private manager: SessionManager,
@Inject(AppsService) private appsService: IAppsService,
) {}
private withLock(name: string, fn: () => any) {
return this.manager.withLock(name, fn);
@@ -47,21 +58,33 @@ class SessionsController {
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
list(
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
): Promise<SessionInfo[]> {
return this.manager.getSessions(query.all);
const sessions = await this.manager.getSessions(query.all);
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
}
}
return sessions;
}
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
async get(@Param('session') name: string): Promise<SessionInfo> {
async get(
@Param('session') name: string,
@Query() query: SessionInfoQuery,
): Promise<SessionInfo> {
const session = await this.manager.getSessionInfo(name);
if (session === null) {
throw new NotFoundException('Session not found');
}
if (query.expand?.includes(SessionExpand.apps)) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@@ -78,6 +101,7 @@ class SessionsController {
description:
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -86,16 +110,26 @@ class SessionsController {
const msg = `Session '${name}' already exists. Use PUT to update it.`;
throw new UnprocessableEntityException(msg);
}
const config = request.config;
const start = request.start || false;
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (start) {
await this.manager.assign(name);
await this.manager.start(name);
}
});
return await this.manager.getSessionInfo(name);
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@Put(':session')
@@ -104,7 +138,8 @@ class SessionsController {
description: '',
})
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@Body() request: SessionUpdateRequest,
@@ -117,11 +152,22 @@ class SessionsController {
const isRunning = this.manager.isRunning(name);
await this.manager.stop(name, true);
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (isRunning) {
await this.manager.start(name);
}
});
return await this.manager.getSessionInfo(name);
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@Delete(':session')
+102
View File
@@ -0,0 +1,102 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
export const SessionExamples = {
basic: {
summary: 'Basic Session',
value: {
name: 'default',
},
},
withWebhooks: {
summary: 'Session Webhooks',
value: {
name: 'default',
config: {
webhooks: [
{
url: 'https://example.com/webhooks/waha',
events: ['message', 'session.status'],
hmac: {
key: 'super-secret',
},
retries: {
delaySeconds: 2,
attempts: 5,
policy: 'linear',
},
customHeaders: [
{
name: 'X-Request-ID',
value: '123',
},
],
},
],
},
},
},
withApps: {
summary: 'Session Apps',
value: {
name: 'default',
apps: [
{
app: AppName.calls,
id: 'app_unique_id',
session: '',
config: {
dm: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
group: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
},
},
],
start: true,
},
},
full: {
summary: 'Full Session Example',
value: {
name: 'default',
start: true,
config: {
metadata: {
'user.id': '123',
},
debug: true,
webhooks: [
{
url: 'https://example.com/webhooks/waha',
events: ['message', 'session.status'],
hmac: {
key: 'super-secret',
},
},
],
},
apps: [
{
app: AppName.calls,
enabled: true,
config: {
dm: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
group: {
reject: true,
},
},
},
],
},
},
};
+14
View File
@@ -7,6 +7,12 @@ import { DelayedError } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { Logger } from 'pino';
export interface JobDataTimeout {
timeout?: {
job?: number;
};
}
/**
* Base class for app consumers that provides common functionality
* like mutex locking, logging setup, and error handling.
@@ -27,6 +33,14 @@ export abstract class AppConsumer extends BaseWorkerHost {
});
}
protected signal(job) {
if (job.data?.timeout?.job != null) {
return AbortSignal.timeout(job.data?.timeout?.job);
}
// Aka never abort
return new AbortController().signal;
}
/**
* Executes a job with mutex locking to ensure only one job processes a specific resource at a time.
* @param job The job to process
+15 -6
View File
@@ -21,7 +21,7 @@ export class AxiosLogging {
: '';
const body = config.data ? JSON.stringify(config.data) : '';
this.logger.info(`${method} ${url}${query} ${body}`);
this.logger.debug(`${method} ${url}${query} ${body}`);
return config;
}
@@ -29,13 +29,22 @@ export class AxiosLogging {
const { method, url } = response.config;
const status = response.status;
const methodStr = method?.toUpperCase() ?? 'GET';
const responseData = JSON.stringify(response.data);
if (status >= 200 && status < 400) {
this.logger.info(`${methodStr} ${status}:OK ${url}`);
this.logger.debug(`${methodStr} ${status}:OK ${url} ${responseData}`);
const type: any = response.headers['content-type'];
// Log warnings for non-2xx/3xx responses
if (status < 200 || status >= 400) {
const data = JSON.stringify(response.data);
this.logger.warn(`${methodStr} ${status} ${url} ${data}`);
return response;
}
// Log debug for 2xx/3xx responses
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
if (type.startsWith('application/json')) {
const data = JSON.stringify(response.data);
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
} else {
this.logger.warn(`${methodStr} ${status} ${url} ${responseData}`);
this.logger.trace(`${methodStr} ${status}:OK ${url} [${type}]`);
}
return response;
+47 -6
View File
@@ -1,5 +1,5 @@
import { Job } from 'bullmq';
import { Backoffs } from 'bullmq';
import { Backoffs, Job } from 'bullmq';
import { FlowJob } from 'bullmq/dist/esm/interfaces';
/**
* Calculates the delay until the next attempt for a job.
@@ -51,6 +51,17 @@ export function HasBeenRetried(job: Job): boolean {
return attemptsMade > 1;
}
/**
* Hide chatwoot and waha from queue name for user faced messages
*/
export function QueueNameRepr(name: string): string {
// No "chatwoot" in the name always (it start with chatwoot)
name = name.replace('chatwoot.', '');
// Replace "waha" with "whatsapp"
name = name.replace('waha |', 'whatsapp |');
return name;
}
let base =
process.env.WAHA_PUBLIC_URL ||
process.env.WAHA_BASE_URL ||
@@ -59,9 +70,39 @@ let base =
base = base.replace(/\/+$/, '');
export function JobLink(job: Job): { text: string; url: string } {
const text = `${job.queueName} => ${job.id}`;
const url = `${base}/jobs/queue/${encodeURIComponent(job.queueName)}/${
job.id
}`;
// Use repr name for text
const name = QueueNameRepr(job.queueName);
const text = `${name} => ${job.id}`;
// Use original queue name in the URL
const queue = encodeURIComponent(job.queueName);
const id = encodeURIComponent(job.id);
const url = `${base}/jobs/queue/${queue}/${id}`;
return { text: text, url: url };
}
/**
* Chain jobs so that they run one at a time
* A | B | C
* to
* A -> B -> C
*/
export function ChainJobsOneAtATime(jobs: FlowJob[]): FlowJob {
if (jobs.length == 0) {
throw new Error('No jobs provided');
}
for (const job of jobs) {
if (job.children.length != 0) {
throw new Error('Jobs with children are not supported');
}
}
const left = [...jobs];
const root = left.pop();
let parent = root;
while (left.length != 0) {
const job = left.pop();
parent.children = [job];
parent = job;
}
return root;
}
+17 -3
View File
@@ -45,7 +45,12 @@ export class AppsController {
@ApiOperation({ summary: 'Create a new app' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
return await this.appsService.create(this.manager, app);
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled) {
await this.manager.restart(app.session);
}
return result;
}
@Get('/:id')
@@ -67,13 +72,22 @@ export class AppsController {
);
}
return await this.appsService.update(this.manager, app);
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
}
return result;
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
await this.appsService.delete(this.manager, id);
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { AppEnv } from '@waha/apps/app_sdk/env';
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/name';
class AppRuntimeConfigC {
private constructor(private apps: AppDefinition[] | null) {}
static FromEnv(env: typeof AppEnv) {
if (!env.enabled) {
return new AppRuntimeConfigC(null);
}
let apps = Object.values(APPS);
// Include
if (env.on && env.on.length > 0) {
apps = apps.filter((app) => env.on!.includes(app.name));
}
// Exclude
if (env.off && env.off.length > 0) {
apps = apps.filter((app) => !env.off!.includes(app.name));
}
return new AppRuntimeConfigC(apps);
}
Enabled() {
return this.apps !== null;
}
GetApps() {
return this.apps || [];
}
GetAppsWithMigration() {
return this.GetApps().filter((app) => app.migrations);
}
GetAppsRequiringPlainKey() {
return this.GetApps().filter((app) => app.plainkey);
}
GetAppsRequiringQueue() {
return this.GetApps().filter((app) => app.queue);
}
HasApp(name: AppName) {
return this.GetApps().some((app) => app.name === name);
}
HasAppsRequiringPlainKey() {
return this.GetAppsRequiringPlainKey().length > 0;
}
HasAppsRequiringQueue() {
return this.GetAppsRequiringQueue().length > 0;
}
}
export const AppRuntimeConfig = AppRuntimeConfigC.FromEnv(AppEnv);
+28
View File
@@ -0,0 +1,28 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
export interface AppDefinition {
// App name
name: AppName;
// If app requires WAHA_API_KEY_PLAIN to work
plainkey: boolean;
// If app requires queue to work
queue: boolean;
// If app has any migrations
migrations: boolean;
}
// All Apps
export const APPS: Record<AppName, AppDefinition> = {
[AppName.calls]: {
name: AppName.calls,
plainkey: false,
queue: false,
migrations: false,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
},
};
+4
View File
@@ -0,0 +1,4 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
}
+3 -2
View File
@@ -2,10 +2,11 @@ import * as process from 'node:process';
import { parseBool } from '@waha/helpers';
import * as basicAuth from 'express-basic-auth';
import { Auth } from '@waha/core/auth/config';
export function BullAuthMiddleware() {
let username = process.env.WAHA_DASHBOARD_USERNAME || '';
let password = process.env.WAHA_DASHBOARD_PASSWORD || '';
let username = Auth.dashboard.username.value || '';
let password = Auth.dashboard.password.value || '';
if (process.env.WAHA_DASHBOARD_ENABLED) {
const enabled = parseBool(process.env.WAHA_DASHBOARD_ENABLED);
if (!enabled) {
+12 -7
View File
@@ -1,4 +1,5 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -8,14 +9,11 @@ import {
ValidateNested,
} from 'class-validator';
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
export type AllowedAppConfig = ChatWootAppConfig;
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
export enum AppName {
chatwoot = 'chatwoot',
}
@ApiExtraModels(ChatWootAppConfig)
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
export class App<T extends AllowedAppConfig = any> {
@IsString()
id: string;
@@ -43,6 +41,8 @@ export class App<T extends AllowedAppConfig = any> {
switch (options.object.app) {
case AppName.chatwoot:
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
default:
return Object;
}
@@ -57,4 +57,9 @@ export class ChatWootAppDto extends App<ChatWootAppConfig> {
config: ChatWootAppConfig;
}
export type AppDto = ChatWootAppDto;
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto;
+14
View File
@@ -0,0 +1,14 @@
import { parseBool } from '@waha/helpers';
function parseCommaSeparatedList(value: string | undefined): string[] {
if (!value) {
return null;
}
return value.split(',').map((item) => item.trim());
}
export const AppEnv = {
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
};
+4 -1
View File
@@ -1,6 +1,7 @@
import * as path from 'node:path';
import { Knex } from 'knex';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
function migrateSDK(knex: Knex): Promise<any> {
const config = {
@@ -25,5 +26,7 @@ function migrateApp(knex: Knex, app: string): Promise<any> {
export async function migrate(knex: Knex) {
await migrateSDK(knex);
await migrateApp(knex, 'chatwoot');
for (const app of AppRuntimeConfig.GetAppsWithMigration()) {
await migrateApp(knex, app.name);
}
}
@@ -29,14 +29,22 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async upsert(manager: SessionManager, app: App): Promise<App> {
throw new AppsIsDisabledError();
}
async update(manager: SessionManager, app: App): Promise<App> {
throw new AppsIsDisabledError();
}
async delete(manager: SessionManager, appId: string) {
async delete(manager: SessionManager, appId: string): Promise<App> {
throw new AppsIsDisabledError();
}
async removeBySession(manager: SessionManager, session: string) {
return;
}
migrate(knex: Knex<any, any[]>): Promise<void> {
return;
}
@@ -48,4 +56,12 @@ export class AppsDisabledService implements IAppsService {
async afterSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
async syncSessionApps(
manager: SessionManager,
sessionName: string,
apps?: App[] | null,
): Promise<void> {
throw new AppsIsDisabledError();
}
}
+106 -24
View File
@@ -1,8 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { migrate } from '@waha/apps/app_sdk/migrations';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -10,15 +16,26 @@ import { generatePrefixedId } from '@waha/utils/ids';
import { Knex } from 'knex';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { App, AppName } from '../dto/app.dto';
import { App } from '../dto/app.dto';
import { AppRepository } from '../storage/AppRepository';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
export class AppDisableError extends UnprocessableEntityException {
constructor(app: string) {
super(
`App '${app}' is disabled in runtime configuration - adjust WAHA_APPS_ON / WAHA_APPS_OFF environment variables to enable it.`,
);
}
}
@Injectable()
export class AppsEnabledService implements IAppsService {
constructor(
protected readonly chatwootService: ChatWootAppService,
@InjectPinoLogger('AppsService')
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
@@ -43,9 +60,12 @@ export class AppsEnabledService implements IAppsService {
throw new Error(`App with ID '${app.id}' already exists.`);
}
let existingApps: App[] = [];
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
existingApps = await repo.getAllBySession(app.session);
}
// Validate only one Chatwoot app per session
if (app.app === AppName.chatwoot) {
const existingApps = await repo.getAllBySession(app.session);
const existingChatwootApp = existingApps.find(
(existingApp) => existingApp.app === AppName.chatwoot,
);
@@ -56,8 +76,23 @@ export class AppsEnabledService implements IAppsService {
);
}
}
// Validate only one Calls app per session
if (app.app === AppName.calls) {
const existingCallsApp = existingApps.find(
(existingApp) => existingApp.app === AppName.calls,
);
if (existingCallsApp) {
throw new Error(
`Only one Calls app is allowed per session. Session '${app.session}' already has a Calls app with ID '${existingCallsApp.id}'.`,
);
}
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
// Only run beforeCreated when app is enabled (default true if omitted)
if (app.enabled !== false) {
@@ -65,9 +100,6 @@ export class AppsEnabledService implements IAppsService {
}
const result = await repo.save(app);
if (app.enabled) {
await this.restartIfRunning(manager, app.session);
}
delete result.pk;
return result;
}
@@ -83,11 +115,23 @@ export class AppsEnabledService implements IAppsService {
return app;
}
async update(manager: SessionManager, app: App): Promise<App> {
async upsert(manager: SessionManager, app: App) {
return await this.update(manager, app, true);
}
async update(
manager: SessionManager,
app: App,
upsert: boolean = false,
): Promise<App> {
await this.checkSessionExists(manager, app.session);
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const savedApp = await repo.getById(app.id);
if (!savedApp && upsert) {
return this.create(manager, app);
}
if (!savedApp) {
throw new NotFoundException(`App '${app.id}' not found`);
}
@@ -103,6 +147,9 @@ export class AppsEnabledService implements IAppsService {
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
const hasEnabledChange = savedApp.enabled !== app.enabled;
@@ -116,16 +163,13 @@ export class AppsEnabledService implements IAppsService {
} else {
await service.beforeUpdated(savedApp, app);
}
await repo.update(app.id, app);
await this.restartIfRunning(manager, app.session);
const updated = await repo.getById(app.id);
delete (updated as any)?.pk;
return updated!;
}
async delete(manager: SessionManager, appId: string) {
async delete(manager: SessionManager, appId: string): Promise<App> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
@@ -133,10 +177,16 @@ export class AppsEnabledService implements IAppsService {
throw new NotFoundException(`App '${appId}' not found`);
}
const service = this.getAppService(app);
await service.beforeDeleted(app);
await service?.beforeDeleted(app);
await repo.delete(app.id);
await this.restartIfRunning(manager, app.session);
return;
delete app.pk;
return app;
}
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
await repo.deleteBySession(session);
}
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
@@ -145,6 +195,9 @@ export class AppsEnabledService implements IAppsService {
const apps = await repo.getEnabledBySession(session.name);
for (const app of apps) {
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.beforeSessionStart(app, session);
}
}
@@ -155,26 +208,55 @@ export class AppsEnabledService implements IAppsService {
const apps = await repo.getEnabledBySession(session.name);
for (const app of apps) {
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.afterSessionStart(app, session);
}
}
async syncSessionApps(
manager: SessionManager,
session: string,
apps: App[],
): Promise<void> {
const existing = await this.list(manager, session);
const ids = new Set<string>();
// Upsert provided apps
for (const app of apps) {
if (!app.id) {
// Try to find the app by type
const found = existing.find((a) => a.app === app.app);
if (found) {
app.id = found.id;
}
}
// Force session
app.session = session;
await this.upsert(manager, app);
ids.add(app.id);
}
// Remove apps that are not in the provided list
for (const app of existing) {
if (ids.has(app.id)) {
continue;
}
await this.delete(manager, app.id);
}
}
async migrate(knex: Knex): Promise<void> {
await migrate(knex);
}
private restartIfRunning(manager: SessionManager, session: string) {
const isRunning = manager.isRunning(session);
if (!isRunning) {
return;
}
return manager.restart(session);
}
private getAppService(app: App): IAppService {
private getAppService(app: App): IAppService | null {
switch (app.app) {
case AppName.chatwoot:
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
default:
throw new Error(`App '${app.app}' not supported`);
}
+11 -1
View File
@@ -11,14 +11,24 @@ export interface IAppsService {
create(manager: SessionManager, app: App): Promise<App>;
upsert(manager: SessionManager, app: App): Promise<App>;
update(manager: SessionManager, app: App): Promise<App>;
delete(manager: SessionManager, appId: string): Promise<void>;
delete(manager: SessionManager, appId: string): Promise<App>;
removeBySession(manager: SessionManager, session: string): Promise<void>;
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
afterSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
syncSessionApps(
manager: SessionManager,
sessionName: string,
apps?: App[] | null,
): Promise<void>;
migrate(knex: Knex): Promise<void>;
}
@@ -123,4 +123,11 @@ export class AppRepository {
async delete(id: string): Promise<void> {
await this.knex(this.tableName).where('id', id).delete();
}
/**
* Deletes all apps for a session
*/
async deleteBySession(session: string): Promise<void> {
await this.knex(this.tableName).where('session', session).delete();
}
}
+43
View File
@@ -0,0 +1,43 @@
import * as lodash from 'lodash';
type Call<Record> = (processed: number) => Promise<Record[]>;
export interface PaginatorParams {
processed?: number;
max?: number;
}
const DefaultParams: PaginatorParams = {
processed: 0,
max: Infinity,
};
/**
* Generic paginator for APIs that return arrays of records with limits and offsets.
*/
export class ArrayPaginator<Record> {
private readonly params: PaginatorParams;
constructor(params: PaginatorParams = DefaultParams) {
this.params = lodash.merge({}, DefaultParams, params) as PaginatorParams;
}
async *iterate(call: Call<Record>): AsyncGenerator<Record> {
let processed = this.params.processed;
let records: Record[] = [];
while (true) {
records = await call(processed);
if (records.length === 0) {
return;
}
for (const record of records) {
yield record;
processed += 1;
if (processed >= this.params.max) {
return;
}
}
}
}
}
+462
View File
@@ -0,0 +1,462 @@
import { Channel } from '@waha/structures/channels.dto';
import {
GetChatMessagesFilter,
GetChatMessagesQuery,
} from '@waha/structures/chats.dto';
import {
ChatRequest,
MessageFileRequest,
MessageImageRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
WANumberExistResult,
} from '@waha/structures/chatting.dto';
import { SessionInfo } from '@waha/structures/sessions.dto';
import axios, { AxiosInstance } from 'axios';
import { Auth } from '@waha/core/auth/config';
import { ContactSortField } from '@waha/structures/contacts.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
export interface RequestOptions {
signal?: AbortSignal;
}
export class WAHASelf {
public client: AxiosInstance;
constructor() {
// Set 'X-Api-Key'
const key = Auth.keyplain.value;
const port =
parseInt(process.env.PORT) ||
parseInt(process.env.WHATSAPP_API_PORT) ||
3000;
const url = `http://localhost:${port}`;
this.client = axios.create({
baseURL: url,
headers: {
'X-Api-Key': key,
'Content-Type': 'application/json',
},
});
}
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
const response = await this.client.get(url, {
responseType: 'arraybuffer',
signal: opts?.signal,
});
return Buffer.from(response.data);
}
async qr(session: string, opts?: RequestOptions): Promise<Buffer> {
const url = `/api/${session}/auth/qr`;
return await this.fetch(url, opts);
}
async screenshot(session: string, opts?: RequestOptions): Promise<Buffer> {
const url = `/api/screenshot?session=${session}`;
return await this.fetch(url, opts);
}
async restart(session: string, opts?: RequestOptions): Promise<any> {
const url = `/api/sessions/${session}/restart`;
return await this.client.post(url, undefined, { signal: opts?.signal });
}
async logout(session: string, opts?: RequestOptions): Promise<any> {
const url = `/api/sessions/${session}/logout`;
return await this.client.post(url, undefined, { signal: opts?.signal });
}
async stop(session: string, opts?: RequestOptions): Promise<any> {
const url = `/api/sessions/${session}/stop`;
return await this.client.post(url, undefined, { signal: opts?.signal });
}
async get(session: string, opts?: RequestOptions): Promise<SessionInfo> {
const url = `/api/sessions/${session}/`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
async getChats(
session: string,
page: PaginationParams,
opts?: RequestOptions,
) {
const url = `/api/${session}/chats`;
const params = {
...page,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async getContacts(
session: string,
page: PaginationParams,
opts?: RequestOptions,
) {
const url = `/api/contacts/all`;
const params = {
session: session,
...page,
sortBy: ContactSortField.ID,
sortOrder: ContactSortField.ID,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async getContact(session: string, contactId: string, opts?: RequestOptions) {
const url = `/api/contacts`;
const params = {
session: session,
contactId: contactId,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async contactCheckExists(
session: string,
phone: string,
opts?: RequestOptions,
): Promise<WANumberExistResult> {
const url = `/api/contacts/check-exists`;
const params = {
phone: phone,
session: session,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async getGroup(session: string, groupId: string, opts?: RequestOptions) {
const url = `/api/${session}/groups/${groupId}`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
async getChannel(
session: string,
channelId: string,
opts?: RequestOptions,
): Promise<Channel> {
const url = `/api/${session}/channels/${channelId}`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
async getChatPicture(
session: string,
chatId: string,
opts?: RequestOptions,
): Promise<string | null> {
const url = `/api/${session}/chats/${chatId}/picture`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data?.url);
}
async sendText(
body: MessageTextRequest,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/sendText`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async sendImage(
body: MessageImageRequest,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/sendImage`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async sendVideo(
body: MessageVideoRequest,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/sendVideo`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async sendVoice(
body: MessageVoiceRequest,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/sendVoice`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async sendFile(
body: MessageFileRequest,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/sendFile`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async deleteMessage(
session: string,
chatId: string,
messageId: string,
opts?: RequestOptions,
) {
const url = `/api/${session}/chats/${chatId}/messages/${messageId}`;
return await this.client
.delete(url, { signal: opts?.signal })
.then((response) => response.data);
}
async startTyping(body: ChatRequest, opts?: RequestOptions) {
const url = `/api/startTyping`;
return await this.client
.post(url, body, { signal: opts?.signal })
.then((response) => response.data);
}
async stopTyping(body: ChatRequest, opts?: RequestOptions) {
const url = `/api/stopTyping`;
return await this.client.post(url, body, { signal: opts?.signal });
}
async readMessages(session: string, chatId: string, opts?: RequestOptions) {
const url = `/api/${session}/chats/${chatId}/messages/read`;
return await this.client
.post(url, undefined, { signal: opts?.signal })
.then((response) => response.data);
}
async getMessages(
session: string,
chatId: string,
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
opts?: RequestOptions,
) {
const url = `/api/${session}/chats/${chatId}/messages`;
const params = {
...query,
...filter,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async getMessageById(
session: string,
chatId: string,
messageId: string,
media: boolean,
opts?: RequestOptions,
) {
const url = `/api/${session}/chats/${chatId}/messages/${messageId}`;
const params = {
downloadMedia: media,
};
return await this.client
.get(url, { params: params, signal: opts?.signal })
.then((response) => response.data);
}
async findPNByLid(
session: string,
lid: string,
opts?: RequestOptions,
): Promise<string | null> {
const url = `/api/${session}/lids/${lid}`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data.pn);
}
async findLIDByPN(
session: string,
pn: string,
opts?: RequestOptions,
): Promise<string | null> {
const url = `/api/${session}/lids/pn/${pn}`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data.lid);
}
/**
* Get the server version
* @returns Server version information
*/
async serverVersion(opts?: RequestOptions): Promise<any> {
const url = `/api/server/version`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
/**
* Get the server status
* @returns Server status information
*/
async serverStatus(opts?: RequestOptions): Promise<any> {
const url = `/api/server/status`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
/**
* Reboot the server
* @param force Whether to force reboot (true) or gracefully reboot (false)
* @returns Server stop response
*/
async serverReboot(
force: boolean = false,
opts?: RequestOptions,
): Promise<any> {
const url = `/api/server/stop`;
return await this.client
.post(url, { force }, { signal: opts?.signal })
.then((response) => response.data);
}
}
export class WAHASessionAPI {
constructor(
private session: string,
private api: WAHASelf,
) {}
getChats(page: PaginationParams, opts?: RequestOptions) {
return this.api.getChats(this.session, page, opts);
}
getContacts(page: PaginationParams, opts?: RequestOptions): Promise<any> {
return this.api.getContacts(this.session, page, opts);
}
getContact(contactId: string, opts?: RequestOptions): Promise<any> {
return this.api.getContact(this.session, contactId, opts);
}
contactCheckExists(
phone: string,
opts?: RequestOptions,
): Promise<WANumberExistResult> {
return this.api.contactCheckExists(this.session, phone, opts);
}
getGroup(groupId: string, opts?: RequestOptions): Promise<any> {
return this.api.getGroup(this.session, groupId, opts);
}
getChannel(channelId: string, opts?: RequestOptions): Promise<Channel> {
return this.api.getChannel(this.session, channelId, opts);
}
getChatPicture(
chatId: string,
opts?: RequestOptions,
): Promise<string | null> {
return this.api.getChatPicture(this.session, chatId, opts);
}
sendText(body: MessageTextRequest, opts?: RequestOptions): Promise<any> {
body.session = this.session;
return this.api.sendText(body, opts);
}
sendImage(body: MessageImageRequest, opts?: RequestOptions): Promise<any> {
body.session = this.session;
return this.api.sendImage(body, opts);
}
sendVideo(body: MessageVideoRequest, opts?: RequestOptions): Promise<any> {
body.session = this.session;
return this.api.sendVideo(body, opts);
}
sendVoice(body: MessageVoiceRequest, opts?: RequestOptions): Promise<any> {
body.session = this.session;
return this.api.sendVoice(body, opts);
}
sendFile(body: MessageFileRequest, opts?: RequestOptions): Promise<any> {
body.session = this.session;
return this.api.sendFile(body, opts);
}
deleteMessage(chatId: string, messageId: string, opts?: RequestOptions) {
return this.api.deleteMessage(this.session, chatId, messageId, opts);
}
startTyping(body: ChatRequest, opts?: RequestOptions) {
body.session = this.session;
return this.api.startTyping(body, opts);
}
stopTyping(body: ChatRequest, opts?: RequestOptions) {
body.session = this.session;
return this.api.stopTyping(body, opts);
}
readMessages(chatId: string, opts?: RequestOptions) {
return this.api.readMessages(this.session, chatId, opts);
}
async getMessages(
chatId: string,
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
opts?: RequestOptions,
) {
return this.api.getMessages(this.session, chatId, query, filter, opts);
}
async getMessageById(
chatId: string,
messageId: string,
media: boolean,
opts?: RequestOptions,
) {
return this.api.getMessageById(
this.session,
chatId,
messageId,
media,
opts,
);
}
//
// Lids
//
findPNByLid(lid: string, opts?: RequestOptions) {
return this.api.findPNByLid(this.session, lid, opts);
}
findLIDByPN(pn: string, opts?: RequestOptions) {
return this.api.findLIDByPN(this.session, pn, opts);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
export const AppsDisabled = {
providers: [
{
provide: AppsService,
useClass: AppsDisabledService,
},
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
};
+127
View File
@@ -0,0 +1,127 @@
import { BullModule } from '@nestjs/bullmq';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import { RMutexModule } from '@waha/modules/rmutex';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
import { Auth } from '@waha/core/auth/config';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppName } from '@waha/apps/app_sdk/apps/name';
const QUEUES_IMPORTS_REQUIRED = [
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
maxRetriesPerRequest: null,
},
prefix: `waha-${process.env.WAHA_WORKER_ID}`,
}),
RedisModule.forRoot({
closeClient: true,
config: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
onClientCreated: async (client) => {
try {
await client.ping();
} catch (err) {
console.error('[Redis] Connection failed:', err);
process.exit(1); // Stop the app if Redis is unavailable
}
},
},
}),
RMutexModule,
BullBoardModule.forRoot({
route: '/jobs',
adapter: ExpressAdapter,
middleware: BullAuthMiddleware(),
boardOptions: {
uiConfig: {
boardTitle: 'Jobs | WAHA',
boardLogo: {
path: '/dashboard/layout/images/logo-white.svg',
width: 35,
height: 35,
},
favIcon: {
default: '/dashboard/favicon.ico',
alternative: '/dashboard/favicon.ico',
},
miscLinks: [
{
text: '📊 Dashboard',
url: '/dashboard',
},
{
text: '📚 Swagger (OpenAPI)',
url: '/',
},
],
},
},
}),
];
const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
? QUEUES_IMPORTS_REQUIRED
: [];
function getAppModule(name: AppName) {
if (!AppRuntimeConfig.HasApp(name)) {
return {
imports: [],
controllers: [],
providers: [],
};
}
switch (name) {
case AppName.calls:
return CallsAppExports;
case AppName.chatwoot:
return ChatWootExports;
default:
throw Error(`App module not found for ${name}`);
}
}
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
],
controllers: [
AppsController,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
],
};
function checkApiKey() {
const key = Auth.key.value;
if (!key) {
return;
}
const plain = Auth.keyplain.value;
if (!plain) {
throw Error(
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
);
}
}
if (AppRuntimeConfig.HasAppsRequiringPlainKey()) {
checkApiKey();
}
+6 -111
View File
@@ -1,112 +1,7 @@
import * as process from 'node:process';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppsDisabled } from '@waha/apps/apps.module.disabled';
import { AppsEnabled } from '@waha/apps/apps.module.enabled';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import { BullModule } from '@nestjs/bullmq';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { parseBool } from '@waha/helpers';
import { RMutexModule } from '@waha/modules/rmutex';
const IMPORTS = [
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
maxRetriesPerRequest: null,
},
prefix: `waha-${process.env.WAHA_WORKER_ID}`,
}),
RedisModule.forRoot({
closeClient: true,
config: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
onClientCreated: async (client) => {
try {
await client.ping();
} catch (err) {
console.error('[Redis] Connection failed:', err);
process.exit(1); // Stop the app if Redis is unavailable
}
},
},
}),
RMutexModule,
BullBoardModule.forRoot({
route: '/jobs',
adapter: ExpressAdapter,
middleware: BullAuthMiddleware(),
boardOptions: {
uiConfig: {
boardTitle: 'Jobs | WAHA',
boardLogo: {
path: '/dashboard/layout/images/logo-white.svg',
width: 35,
height: 35,
},
favIcon: {
default: '/dashboard/favicon.ico',
alternative: '/dashboard/favicon.ico',
},
miscLinks: [
{
text: '📊 Dashboard',
url: '/dashboard',
},
{
text: '📚 Swagger (OpenAPI)',
url: '/',
},
],
},
},
}),
...ChatWootExports.imports,
];
const AppsEnabled = {
imports: IMPORTS,
controllers: [AppsController, ...ChatWootExports.controllers],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...ChatWootExports.providers,
],
};
const AppsDisabled = {
providers: [
{
provide: AppsService,
useClass: AppsDisabledService,
},
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
};
function checkApiKey() {
const key = process.env.WHATSAPP_API_KEY || process.env.WAHA_API_KEY;
if (!key) {
return;
}
const plain = process.env.WAHA_API_KEY_PLAIN;
if (!plain) {
throw Error(
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
);
}
}
const enabled = parseBool(process.env.WAHA_APPS_ENABLED);
if (enabled) {
checkApiKey();
}
export const AppsModuleExports = enabled ? AppsEnabled : AppsDisabled;
export const AppsModuleExports = AppRuntimeConfig.Enabled()
? AppsEnabled
: AppsDisabled;
+7
View File
@@ -0,0 +1,7 @@
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
export const CallsAppExports = {
providers: [CallsAppService],
imports: [],
controllers: [],
};
+44
View File
@@ -0,0 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class CallsAppChannelConfig {
@ApiProperty({
description: 'Reject incoming calls for this chat type',
default: true,
})
@IsBoolean()
reject: boolean = true;
@ApiProperty({
description:
'Optional auto-reply message sent after the call is rejected. If empty, no message is sent.',
required: false,
})
@IsOptional()
@IsString()
message?: string;
}
export class CallsAppConfig {
@ApiProperty({
description: 'Rules applied to direct messages (non-group calls)',
type: CallsAppChannelConfig,
})
@ValidateNested()
@Type(() => CallsAppChannelConfig)
dm: CallsAppChannelConfig = new CallsAppChannelConfig();
@ApiProperty({
description: 'Rules applied to group calls',
type: CallsAppChannelConfig,
})
@ValidateNested()
@Type(() => CallsAppChannelConfig)
group: CallsAppChannelConfig = new CallsAppChannelConfig();
}
@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@Injectable()
export class CallsAppService implements IAppService {
constructor(
@InjectPinoLogger('CallsAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<CallsAppConfig>): void {
// The DTO validation covers structure; no extra validation rules.
void app;
return;
}
async beforeCreated(app: App<CallsAppConfig>): Promise<void> {
void app;
return;
}
async beforeEnabled(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
// Enabling behaves the same as creating for this lightweight app.
void savedApp;
void newApp;
return;
}
async beforeDisabled(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void newApp;
void savedApp;
}
async beforeUpdated(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void savedApp;
void newApp;
}
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
void app;
}
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
const listener = new CallsListener(app, session, this.logger);
listener.attach();
}
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
void app;
void session;
return;
}
}
+151
View File
@@ -0,0 +1,151 @@
import { Subscription } from 'rxjs';
import {
CallsAppChannelConfig,
CallsAppConfig,
} from '@waha/apps/calls/dto/config.dto';
import { Logger } from 'pino';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { PinoLogger } from 'nestjs-pino';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { CallData } from '@waha/structures/calls.dto';
import { MessageTextRequest } from '@waha/structures/chatting.dto';
import { sleep } from '@waha/utils/promiseTimeout';
export class CallsListener {
private subscription?: Subscription;
private config: CallsAppConfig;
private readonly log: Logger;
private readonly session: WhatsappSession;
constructor(
app: App<CallsAppConfig>,
session: WhatsappSession,
logger: PinoLogger,
) {
this.session = session;
this.config = app.config;
this.log = logger.logger.child({
app: 'calls',
session: app.session,
});
}
attach(): void {
this.detach();
const observable = this.session.getEventObservable(
WAHAEvents.CALL_RECEIVED,
);
if (!observable) {
this.log.warn('CALL_RECEIVED event stream is not available, skipping');
return;
}
this.subscription = observable.subscribe((payload) => {
this.handleCall(payload as CallData).catch((error) => {
this.log.error(
{ err: error, callId: (payload as any)?.id },
'Failed to handle incoming call',
);
});
});
this.log.info('Calls app listener is attached');
}
detach(): void {
this.subscription?.unsubscribe();
this.subscription = undefined;
}
private configFor(call: CallData): CallsAppChannelConfig {
return call?.isGroup ? this.config?.group : this.config?.dm;
}
private async handleCall(call: CallData): Promise<void> {
if (!call.from) {
this.log.warn({ call: call?.id }, 'Incoming call has no chat id');
return;
}
if (!call.id) {
this.log.warn({ from: call.from }, 'Incoming call has no from');
return;
}
const config = this.configFor(call);
if (!config) {
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
return;
}
const message = (config.message || '').trim();
const shouldReject = !!config.reject;
const shouldMessage = message.length > 0;
if (!shouldReject && !shouldMessage) {
this.log.debug(
{ callId: call.id, chatId: call.from },
'No actions configured for this call',
);
return;
}
if (shouldReject) {
await this.rejectCall(call);
}
if (shouldMessage) {
await this.replyWithTyping(call.from, message);
}
}
private async rejectCall(call: CallData): Promise<void> {
this.log.debug({ from: call.from, id: call.id }, 'Rejecting incoming call');
await this.session.rejectCall(call.from, call.id);
this.log.info({ from: call.from, id: call.id }, 'Call rejected');
}
private async replyWithTyping(
chatId: string,
message: string,
): Promise<void> {
this.log.info(
{ chatId: chatId },
'Sending auto-response for rejected call',
);
await this.setTyping(chatId);
await this.setPaused(chatId);
await this.session.sendText({
session: this.session.name,
chatId: chatId,
text: message,
} as MessageTextRequest);
}
private async setTyping(chatId: string): Promise<void> {
try {
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
} catch (error) {
this.log.warn(
{ err: error, chatId: chatId },
'Failed to set typing presence before reply',
);
return;
}
await sleep(2000);
}
private async setPaused(chatId: string): Promise<void> {
try {
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
} catch (error) {
this.log.warn(
{ err: error, chatId: chatId },
'Failed to clear typing presence after reply',
);
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { WAHASessionStatus } from '@waha/structures/enums.dto';
export function SessionStatusEmoji(status: WAHASessionStatus): string {
switch (status) {
case WAHASessionStatus.STOPPED:
return '⚠️';
case WAHASessionStatus.STARTING:
return '⏳';
case WAHASessionStatus.SCAN_QR_CODE:
return '⚠️';
case WAHASessionStatus.WORKING:
return '🟢';
case WAHASessionStatus.FAILED:
return '🛑';
default:
return '❓';
}
}
@@ -6,13 +6,13 @@ import {
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FindChatID } from '@waha/apps/chatwoot/client/ids';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
import { INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueueService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
@Controller('webhooks/chatwoot/')
@ApiTags('🧩 Apps')
@@ -36,16 +36,26 @@ export class ChatwootWebhookController {
return { success: true };
}
// Ignore all private notes
if (body.private) {
return { success: true };
}
// Ignore all incoming messages
if (body.message_type == MessageType.INCOMING) {
return { success: true };
}
const isCommandsChat = IsCommandsChat(body);
// Ignore private notes (most of them)
const deleted = body?.content_attributes?.deleted;
if (body.private) {
// Ignore any private note in commands chats
if (isCommandsChat) {
return { success: true };
}
// keep "deleted" notes
// So Agent can delete "Sent From API/WhatsApp" messages in ChatWoot
if (!deleted) {
return { success: true };
}
}
const data: InboxData = {
session: session,
app: id,
@@ -61,12 +71,8 @@ export class ChatwootWebhookController {
}
// Check if it's a command message (sent to the special inbox contact)
const sender = body?.conversation?.meta?.sender;
const chatId = FindChatID(sender);
const isCommandsChat = chatId === INBOX_CONTACT_CHAT_ID;
// Check if it's a deleted message
if (body.content_attributes?.deleted && !isCommandsChat) {
if (deleted && !isCommandsChat) {
await this.chatWootQueueService.addMessageDeletedJob(data);
return { success: true };
}
@@ -74,10 +80,10 @@ export class ChatwootWebhookController {
// Route to specific queues based on an event type
switch (body.event) {
case EventName.MESSAGE_CREATED:
if (!isCommandsChat) {
await this.chatWootQueueService.addMessageCreatedJob(data);
} else {
if (isCommandsChat || body.content?.startsWith(CommandPrefix)) {
await this.chatWootQueueService.addCommandsJob(body.event, data);
} else {
await this.chatWootQueueService.addMessageCreatedJob(data);
}
return { success: true };
case EventName.MESSAGE_UPDATED:
@@ -94,10 +100,10 @@ export class ChatwootWebhookController {
return { success: true };
}
if (!isCommandsChat) {
await this.chatWootQueueService.addMessageUpdatedJob(data);
} else {
if (isCommandsChat || body.content?.startsWith(CommandPrefix)) {
await this.chatWootQueueService.addCommandsJob(body.event, data);
} else {
await this.chatWootQueueService.addMessageUpdatedJob(data);
}
return { success: true };
default:
+43 -1
View File
@@ -15,7 +15,7 @@ import { ChatWootInboxCommandsConsumer } from './consumers/inbox/commands';
import { ChatWootInboxMessageCreatedConsumer } from './consumers/inbox/message_created';
import { ChatWootInboxMessageDeletedConsumer } from './consumers/inbox/message_deleted';
import { ChatWootInboxMessageUpdatedConsumer } from './consumers/inbox/message_updated';
import { QueueName } from './consumers/QueueName';
import { FlowProducerName, QueueName } from './consumers/QueueName';
import { CheckVersionConsumer } from './consumers/scheduled/check.version';
import { WAHAMessageAnyConsumer } from './consumers/waha/message.any';
import { WAHAMessageEditedConsumer } from './consumers/waha/message.edited';
@@ -23,15 +23,26 @@ import { WAHAMessageReactionConsumer } from './consumers/waha/message.reaction';
import { WAHAMessageRevokedConsumer } from './consumers/waha/message.revoked';
import { WAHAMessageAckConsumer } from './consumers/waha/message.ack';
import { WAHASessionStatusConsumer } from './consumers/waha/session.status';
import { WAHACallAcceptedConsumer } from './consumers/waha/call.accepted';
import { WAHACallReceivedConsumer } from './consumers/waha/call.received';
import { WAHACallRejectedConsumer } from './consumers/waha/call.rejected';
import { ChatWootQueueService } from './services/ChatWootQueueService';
import { ChatWootScheduleService } from './services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from './services/ChatWootWAHAQueueService';
import { QueueRegistry } from './services/QueueRegistry';
import { ChatWootConversationCreatedConsumer } from './consumers/inbox/conversation_created';
import { ChatWootConversationStatusChangedConsumer } from '@waha/apps/chatwoot/consumers/inbox/conversation_status_changed';
import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
import { BullModule } from '@nestjs/bullmq';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
const IMPORTS = lodash.flatten([
BullModule.registerFlowProducer({
name: FlowProducerName.MESSAGES_PULL_FLOW,
}),
RegisterAppQueue({
name: QueueName.SCHEDULED_MESSAGE_CLEANUP,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -40,6 +51,14 @@ const IMPORTS = lodash.flatten([
name: QueueName.SCHEDULED_CHECK_VERSION,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.TASK_CONTACTS_PULL,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.TASK_MESSAGES_PULL,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_MESSAGE_ANY,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -60,6 +79,18 @@ const IMPORTS = lodash.flatten([
name: QueueName.WAHA_MESSAGE_ACK,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_RECEIVED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_ACCEPTED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_REJECTED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_SESSION_STATUS,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -98,18 +129,29 @@ const PROVIDERS = [
ChatWootConversationCreatedConsumer,
ChatWootConversationStatusChangedConsumer,
ChatWootInboxCommandsConsumer,
// Tasks
TaskContactsPullConsumer,
TaskMessagesPullConsumer,
// WAHA
WAHASessionStatusConsumer,
WAHAMessageAnyConsumer,
WAHAMessageReactionConsumer,
WAHAMessageEditedConsumer,
WAHAMessageRevokedConsumer,
WAHAMessageAckConsumer,
WAHACallReceivedConsumer,
WAHACallAcceptedConsumer,
WAHACallRejectedConsumer,
// Scheduled
MessageCleanupConsumer,
CheckVersionConsumer,
// Services
ChatWootWAHAQueueService,
ChatWootQueueService,
ChatWootScheduleService,
ChatWootAppService,
QueueRegistry,
QueueManager,
];
export const ChatWootExports = {
+80
View File
@@ -0,0 +1,80 @@
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import {
ContactsPullOptions,
ContactsPullStatusMessage,
} from '@waha/apps/chatwoot/consumers/task/contacts.pull';
import { Job, JobsOptions, Queue } from 'bullmq';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
import {
ExponentialRetriesJobOptions,
JobRemoveOptions,
merge,
} from '@waha/apps/app_sdk/constants';
export async function ContactsPullStart(
ctx: CommandContext,
options: ContactsPullOptions,
jobOptions: JobsOptions & JobDataTimeout,
) {
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
const job: Job = await ctx.queues.contactsPull.getJob(jobId);
if (job) {
const state = await job.getState();
const done = state === 'completed' || state === 'failed';
if (!done) {
const msg = ctx.l.r('cli.cmd.contacts.pull.already-running');
await ctx.conversation.incoming(msg);
return;
}
// Remove already done job
await ContactsPullRemove(ctx.queues.contactsPull, ctx.data.app, ctx.logger);
}
const opts: JobsOptions = merge(
ExponentialRetriesJobOptions,
JobRemoveOptions,
jobOptions,
);
const data = {
...ctx.data,
timeout: {
job: jobOptions.timeout.job,
},
options: options,
};
opts.jobId = jobId;
await ctx.queues.contactsPull.add(QueueName.TASK_CONTACTS_PULL, data, opts);
}
export async function ContactsPullStatus(ctx: CommandContext) {
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
const job: Job | null = await ctx.queues.contactsPull.getJob(jobId);
if (!job) {
const msg = ctx.l.r('cli.cmd.contacts.status.not-found');
await ctx.conversation.incoming(msg);
return;
}
const state = await job.getState();
const msg = ContactsPullStatusMessage(ctx.l, job, state);
await ctx.conversation.incoming(msg);
}
export async function ContactsPullRemove(
queue: Queue,
app: string,
logger: ILogger,
): Promise<boolean> {
const jobId = ChatWootScheduleService.SingleJobId(app);
const job: Job = await queue.getJob(jobId);
if (!job) {
logger.info('Pull Contacts job has already been removed');
return false;
}
await job.remove();
return true;
}
+7
View File
@@ -0,0 +1,7 @@
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
export async function CommandDisabled(ctx: CommandContext, command: string) {
const text = ctx.l.r('cli.cmd.disabled', { command: command });
await ctx.conversation.incoming(text);
}
+182
View File
@@ -0,0 +1,182 @@
import * as lodash from 'lodash';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import {
ChatID,
MessagesPullOptions,
MessagesPullStatusMessage,
TaskActivity,
} from '@waha/apps/chatwoot/consumers/task/messages.pull';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
import { FlowProducer, Job, JobsOptions, Queue } from 'bullmq';
import { FlowJob } from 'bullmq/dist/esm/interfaces';
import { GetAllChatIDs, IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { ChainJobsOneAtATime } from '@waha/apps/app_sdk/JobUtils';
import {
ExponentialRetriesJobOptions,
JobRemoveOptions,
merge,
} from '@waha/apps/app_sdk/constants';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
IgnoreJidConfig,
isNullJid,
isPnUser,
JidFilter,
} from '@waha/core/utils/jids';
function oneJob(chat: string, data, opts: JobsOptions): FlowJob {
data = lodash.cloneDeep(data);
data.options.chat = chat;
opts = lodash.cloneDeep(opts);
return {
name: chat,
queueName: QueueName.TASK_MESSAGES_PULL,
data: data,
opts: merge(ExponentialRetriesJobOptions, JobRemoveOptions, opts),
children: [],
};
}
export async function MessagesPullStart(
ctx: CommandContext,
options: MessagesPullOptions,
jobOptions: JobsOptions & JobDataTimeout,
) {
const queue = ctx.queues.messagesPull;
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
const job: Job | null = await queue.getJob(jobId);
if (job) {
const state = await job.getState();
const done = state === 'completed' || state === 'failed';
if (!done) {
const msg = ctx.l.r('cli.cmd.messages.pull.already-running', {
chat: job.data.options.chat,
});
await ctx.conversation.incoming(msg);
return;
}
await MessagesPullRemove(queue, ctx.data.app, ctx.logger);
}
const opts: JobsOptions = {
...jobOptions,
ignoreDependencyOnFailure: true,
delay: 1_000,
};
const data = {
...ctx.data,
timeout: {
job: jobOptions.timeout.job,
},
options: options,
};
const producer: FlowProducer = ctx.flows.messagesPull;
let chats: string[] = [options.chat];
if (options.chat === ChatID.ALL) {
if (IsCommandsChat(data.body)) {
if (EngineHelper.SupportsAllChatForMessage()) {
// keep "all" chats - that's fine
} else {
// "all" chats for engine that doesn't support it
chats = await resolveAllToChats(ctx.waha, ctx.data.session, options);
if (chats.length == 0) {
throw new Error(ctx.l.r('cli.cmd.messages.pull.no-chats-found'));
}
}
} else {
// It's per-chat command, get the chat ids and run the pulling process
chats = GetAllChatIDs(data.body?.conversation?.meta?.sender);
chats = EngineHelper.FilterChatIdsForMessages(chats);
}
}
const children = chats.map((chat) => oneJob(chat, data, opts));
let root: FlowJob;
if (children.length == 1) {
// Schedule one job, no "summary" job required
root = children[0];
} else {
// Add "summary" parent job so we get the overall progress at the end
root = oneJob(ChatID.SUMMARY, data, opts);
root.children = [ChainJobsOneAtATime(children)];
}
root.opts.jobId = jobId;
await producer.add(root);
const activity = new TaskActivity(ctx.l, ctx.conversation);
await activity.details(data);
}
export async function MessagesPullStatus(ctx: CommandContext) {
const queue = ctx.queues.messagesPull;
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
const job: Job | null = await queue.getJob(jobId);
if (!job) {
const msg = ctx.l.r('cli.cmd.messages.status.not-found');
await ctx.conversation.incoming(msg);
return;
}
const state = await job.getState();
const msg = MessagesPullStatusMessage(ctx.l, job, state);
await ctx.conversation.incoming(msg);
}
export async function MessagesPullRemove(
queue: Queue,
app: string,
logger: ILogger,
): Promise<boolean> {
const jobId = ChatWootScheduleService.SingleJobId(app);
const job: Job | null = await queue.getJob(jobId);
if (!job) {
logger.info('Pull Messages job has already been removed');
return false;
}
await job.remove();
return true;
}
/**
* WORKING ONLY FOR WEBJS
*/
async function resolveAllToChats(
waha: WAHASelf,
sessionName: string,
options: MessagesPullOptions,
): Promise<string[]> {
const session = new WAHASessionAPI(sessionName, waha);
let chats = await session.getChats({
limit: undefined,
offset: undefined,
});
// new chat last
chats = lodash.sortBy(chats, (c) => c.timestamp || 0);
const gte = Date.now() - options.period.start;
const result = [];
const jids = new JidFilter(options.ignore);
for (const chat of chats) {
const id = chat.id._serialized;
if (!jids.include(id)) {
continue;
}
if (isNullJid(id)) {
continue;
}
const timestamp = (chat.timestamp || Infinity) * 1000;
if (timestamp < gte) {
// Too old chat
continue;
}
result.push(id);
}
return result;
}
+45
View File
@@ -0,0 +1,45 @@
import * as lodash from 'lodash';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
import { QueueNameRepr } from '@waha/apps/app_sdk/JobUtils';
export interface QueueCommandContext {
queues: {
registry: QueueRegistry;
};
l: Locale;
conversation: Conversation;
}
export async function QueueStatus(ctx: QueueCommandContext, name: string) {
const manager = new QueueManager(ctx.queues.registry);
const names = manager.resolve(name);
let result = await manager.status(names);
for (const status of result) {
status.name = QueueNameRepr(status.name);
}
// locked: true - last
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
const msg = ctx.l.r('cli.cmd.queue.status.result', {
queues: result,
});
await ctx.conversation.incoming(msg);
}
export async function QueueStart(ctx: QueueCommandContext, name: string) {
const manager = new QueueManager(ctx.queues.registry);
const names = manager.resolve(name);
await manager.resume(names);
const msg = ctx.l.r('cli.cmd.queue.resumed');
await ctx.conversation.activity(msg);
}
export async function QueueStop(ctx: QueueCommandContext, name?: string) {
const manager = new QueueManager(ctx.queues.registry);
const names = manager.resolve(name);
await manager.pause(names);
const msg = ctx.l.r('cli.cmd.queue.paused');
await ctx.conversation.activity(msg);
}
+22
View File
@@ -0,0 +1,22 @@
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
export async function ServerStatus(ctx: CommandContext) {
ctx.logger.info('Getting server version and status');
const version = await ctx.waha.serverVersion();
const status = await ctx.waha.serverStatus();
const text = ctx.l.key(TKey.APP_SERVER_VERSION_AND_STATUS).render({
version: JSON.stringify(version, null, 2),
status: JSON.stringify(status, null, 2),
});
await ctx.conversation.incoming(text);
}
export async function ServerReboot(ctx: CommandContext, force: boolean) {
const type = force ? 'force' : 'graceful';
const tkey = force ? TKey.APP_SERVER_REBOOT_FORCE : TKey.APP_SERVER_REBOOT;
ctx.logger.info(`Rebooting server (${type})`);
const text = ctx.l.key(tkey).render();
await ctx.conversation.incoming(text);
await ctx.waha.serverReboot(false);
}
+54
View File
@@ -0,0 +1,54 @@
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { SessionStatusEmoji } from '@waha/apps/chatwoot/emoji';
import { AttachmentFromBuffer } from '@waha/apps/chatwoot/client/messages';
import { MessageType } from '@waha/apps/chatwoot/client/types';
export async function SessionRestart(ctx: CommandContext) {
await ctx.waha.restart(ctx.data.session);
}
export async function SessionStart(ctx: CommandContext) {
return SessionRestart(ctx);
}
export async function SessionLogout(ctx: CommandContext) {
ctx.logger.info(`Logging out session ${ctx.data.session}`);
await ctx.waha.logout(ctx.data.session);
const text = ctx.l.key(TKey.APP_LOGOUT_SUCCESS).render();
await ctx.conversation.incoming(text);
}
export async function SessionStop(ctx: CommandContext) {
ctx.logger.info(`Stopping session ${ctx.data.session}`);
await ctx.waha.stop(ctx.data.session);
}
export async function SessionStatus(ctx: CommandContext) {
ctx.logger.info(`Getting status for session ${ctx.data.session}`);
const session = await ctx.waha.get(ctx.data.session);
const emoji = SessionStatusEmoji(session.status);
const text = ctx.l.key(TKey.APP_SESSION_CURRENT_STATUS).render({
emoji: emoji,
session: session.name,
status: session.status,
name: session.me?.pushName || 'Unknown',
id: session.me?.id || 'No phone number',
});
await ctx.conversation.incoming(text);
}
export async function SessionQR(ctx: CommandContext) {
const content = await ctx.waha.qr(ctx.data.session);
const message = AttachmentFromBuffer(content, 'qr.jpg');
message.message_type = MessageType.INCOMING;
await ctx.conversation.send(message);
}
export async function SessionScreenshot(ctx: CommandContext) {
ctx.logger.info(`Getting screenshot for session ${ctx.data.session}`);
const content = await ctx.waha.screenshot(ctx.data.session);
const message = AttachmentFromBuffer(content, 'screenshot.jpg');
message.message_type = MessageType.INCOMING;
await ctx.conversation.send(message);
}
+35
View File
@@ -0,0 +1,35 @@
import { CommanderError } from 'commander';
import { parse } from 'shell-quote';
import { BufferedOutput } from '@waha/apps/chatwoot/cli/utils/BufferedOutput';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { BuildProgram } from '@waha/apps/chatwoot/cli/program.a';
import { CHATWOOT_COMMAND_PREFIX } from '@waha/apps/chatwoot/env';
export async function runText(
commands: ChatWootCommandsConfig,
ctx: CommandContext,
text: string,
) {
// Prepare text for cli like command
text = text
.split('\n') // split into lines
.map((line) => line.trimStart()) // remove indentation
.join(' ') // join with space
.trim(); // final cleanup
const output = new BufferedOutput();
const program = BuildProgram(commands, ctx, output);
const argv = parse(text);
try {
await program.parseAsync(argv, { from: 'user' });
} catch (err) {
if (err instanceof CommanderError) {
return output;
} else {
throw err; // unexpected bug in your action code
}
}
return output;
}
export const CommandPrefix = CHATWOOT_COMMAND_PREFIX;
+70
View File
@@ -0,0 +1,70 @@
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { Argument, Command, Option, OutputConfiguration } from 'commander';
import {
buildFormatHelp,
fullCommandPath,
} from '@waha/apps/chatwoot/cli/utils/help';
import { AddSessionCommand } from '@waha/apps/chatwoot/cli/program.session';
import { AddContactsCommand } from '@waha/apps/chatwoot/cli/program.contacts';
import { AddMessagesCommand } from '@waha/apps/chatwoot/cli/program.messages';
import { AddServerCommand } from '@waha/apps/chatwoot/cli/program.server';
import { AddQueueCommand } from '@waha/apps/chatwoot/cli/program.queue';
function Program(ctx: CommandContext, output: OutputConfiguration) {
const l = ctx.l;
const program = new Command();
program
.name('')
.description(l.r('cli.cmd.root.description'))
.exitOverride()
.configureOutput(output)
.showSuggestionAfterError(true)
.configureHelp({
helpWidth: 200,
styleOptionTerm(str: string) {
return `- \`${str}\``;
},
styleArgumentTerm(str: string) {
return `- **${str}**`;
},
styleUsage(str) {
return `**${str}**`;
},
styleSubcommandTerm(str: string) {
return `- **${str}**`;
},
styleSubcommandDescription(str: string) {
return str ? `- ${str}` : str;
},
subcommandTerm: fullCommandPath,
commandUsage(cmd) {
// For the root program - no usage
if (!cmd.parent)
return `${l.r('cli.usage.command')} ${l.r('cli.usage.options')}`;
// For subcommands, keep default behavior
if (cmd.commands.length > 0) {
return `${fullCommandPath(cmd)} ${l.r('cli.usage.command')} ${l.r(
'cli.usage.options',
)}`;
}
return `${fullCommandPath(cmd)} ${l.r('cli.usage.options')}`;
},
formatHelp: buildFormatHelp(l),
});
return program;
}
export function BuildProgram(
commands: ChatWootCommandsConfig,
ctx: CommandContext,
output: OutputConfiguration,
) {
const program = Program(ctx, output);
AddSessionCommand(program, ctx);
AddContactsCommand(program, ctx);
AddMessagesCommand(program, ctx, commands.queue);
AddQueueCommand(program, ctx, commands.queue);
AddServerCommand(program, ctx, commands.server);
return program;
}
+113
View File
@@ -0,0 +1,113 @@
import { Argument, Command, Option } from 'commander';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import {
JobAttemptsOption,
JobTimeoutOption,
ParseMS,
NotNegativeNumber,
ProgressOption,
} from '@waha/apps/chatwoot/cli/utils/options';
import {
ContactsPullStart,
ContactsPullStatus,
} from '@waha/apps/chatwoot/cli/cmd.contacts';
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
import { JobsOptions } from 'bullmq';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
export function AddContactsCommand(program: Command, ctx: CommandContext) {
const l = ctx.l;
const SyncGroup = l.r('cli.cmd.root.sub.sync');
program.commandsGroup(SyncGroup);
program
.command('contacts')
.summary(l.r('cli.cmd.contacts.summary'))
.description(l.r('cli.cmd.contacts.description'))
.helpGroup(SyncGroup)
.addArgument(
new Argument(
'[action]',
l.r('cli.cmd.contacts.action.description'),
).choices(['pull', 'status', 'help']),
)
.addOption(
new Option(
'-a, --avatar <mode>',
l.r('cli.cmd.contacts.pull.option.avatar'),
)
.choices(['if-missing', 'update'])
.preset('if-missing'),
)
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
.option(
'--na, --no-attributes',
l.r('cli.cmd.contacts.pull.option.no-attributes'),
)
.option(
'-b, --batch <number>',
l.r('cli.cmd.contacts.pull.option.batch'),
NotNegativeNumber,
100,
)
.addOption(
ProgressOption(l.r('cli.cmd.contacts.pull.option.progress'), 100),
)
.addOption(new JobAttemptsOption(l, 6))
.addOption(new JobTimeoutOption(l, '10m'))
.addOption(
new Option(
'--dc, --delay-contact <duration>',
l.r('cli.cmd.contacts.pull.option.delay-contact'),
)
.argParser(ParseMS)
.default(ParseMS('0.1s'), '0.1s'),
)
.addOption(
new Option(
'--db, --delay-batch <duration>',
l.r('cli.cmd.contacts.pull.option.delay-batch'),
)
.argParser(ParseMS)
.default(ParseMS('1s'), '1s'),
)
.action(async (action, opts, cmd: Command) => {
if (!action) {
cmd.outputHelp();
return;
}
if (action === 'help') {
cmd.outputHelp();
return;
}
if (action === 'status') {
await ContactsPullStatus(ctx);
return;
}
const options: ContactsPullOptions = {
batch: opts.batch,
progress: opts.progress,
avatar: opts.avatar,
attributes: opts.attributes,
contacts: {
lids: opts.lids,
groups: opts.groups,
},
delay: {
contact: opts.delayContact,
batch: opts.delayBatch,
},
};
const jobOptions: JobsOptions & JobDataTimeout = {
attempts: opts.attempts,
timeout: {
job: opts.timeout,
},
};
await ContactsPullStart(ctx, options, jobOptions);
});
}
+142
View File
@@ -0,0 +1,142 @@
import { Argument, Command, Option } from 'commander';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import {
JobAttemptsOption,
JobTimeoutOption,
ParseMS,
NotNegativeNumber,
ProgressOption,
} from '@waha/apps/chatwoot/cli/utils/options';
import {
MessagesPullStart,
MessagesPullStatus,
} from '@waha/apps/chatwoot/cli/cmd.messages';
import { MessagesPullOptions } from '@waha/apps/chatwoot/consumers/task/messages.pull';
import { JobsOptions } from 'bullmq';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
export function AddMessagesCommand(
program: Command,
ctx: CommandContext,
queue: boolean,
) {
const l = ctx.l;
const SyncGroup = l.r('cli.cmd.root.sub.sync');
program.commandsGroup(SyncGroup);
program
.command('messages')
.summary(l.r('cli.cmd.messages.summary'))
.description(l.r('cli.cmd.messages.description'))
.helpGroup(SyncGroup)
.addArgument(
new Argument(
'[action]',
l.r('cli.cmd.messages.action.description'),
).choices(['pull', 'status', 'help']),
)
.addArgument(
new Argument('[end]', l.r('cli.cmd.messages.pull.argument.end'))
.argParser(ParseMS)
.default(ParseMS('1d'), '1d'),
)
.addArgument(
new Argument('[start]', l.r('cli.cmd.messages.pull.argument.start'))
.argParser(ParseMS)
.default(ParseMS('0d'), '0d'),
)
.option(
'-c, --chat <chat>',
l.r('cli.cmd.messages.pull.option.chat'),
'all',
)
.option('-f, --force', l.r('cli.cmd.messages.pull.option.force'))
.option('--nd, --no-dm', l.r('cli.cmd.messages.pull.option.no-dm'))
.option('-g, --groups', l.r('cli.cmd.messages.pull.option.groups'))
.option('--ch, --channels', l.r('cli.cmd.messages.pull.option.channels'))
.option('-s, --status', l.r('cli.cmd.messages.pull.option.status'))
.option('--bc, --broadcast', l.r('cli.cmd.messages.pull.option.broadcast'))
.option('-m, --media', l.r('cli.cmd.messages.pull.option.media'))
.option('--pause', l.r('cli.cmd.messages.pull.option.pause'))
.option(
'--rc, --resolve-conversations',
l.r('cli.cmd.messages.pull.option.resolve-conversations'),
)
.option(
'-b, --batch <number>',
l.r('cli.cmd.messages.pull.option.batch'),
NotNegativeNumber,
100 as any,
)
.addOption(ProgressOption(l.r('cli.cmd.messages.pull.option.progress')))
.addOption(new JobAttemptsOption(l, 6))
.addOption(new JobTimeoutOption(l, '10m'))
.addOption(
new Option(
'--tm, --timeout-media <duration>',
l.r('cli.cmd.messages.pull.option.timeout-media'),
)
.argParser(ParseMS)
.default(ParseMS('30s'), '30s'),
)
.action(async (action, end, start, opts, cmd: Command) => {
if (!action) {
cmd.outputHelp();
return;
}
if (action === 'help') {
cmd.outputHelp();
return;
}
if (action === 'status') {
await MessagesPullStatus(ctx);
return;
}
if (end > start) {
// Swap
const tmp = end;
end = start;
start = tmp;
}
if (opts.pause && !queue) {
await ctx.conversation.incoming(
l.r('cli.cmd.messages.pull.error.pause-no-queue'),
);
return;
}
const options: MessagesPullOptions = {
chat: opts.chat,
progress: opts.progress,
period: {
end: end,
start: start,
},
media: opts.media,
force: opts.force,
pause: opts.pause,
timeout: {
media: opts.timeoutMedia,
},
batch: opts.batch,
ignore: {
dm: !opts.dm,
status: !opts.status,
groups: !opts.groups,
channels: !opts.channels,
broadcast: !opts.broadcast,
},
resolveConversations: opts.resolveConversations,
};
const jobOptions: JobsOptions & JobDataTimeout = {
attempts: opts.attempts,
timeout: {
job: opts.timeout,
},
};
await MessagesPullStart(ctx, options, jobOptions);
});
}
+66
View File
@@ -0,0 +1,66 @@
import { Argument, Command } from 'commander';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { CommandDisabled } from '@waha/apps/chatwoot/cli/cmd.disabled';
import {
QueueStart,
QueueStatus,
QueueStop,
} from '@waha/apps/chatwoot/cli/cmd.queue';
export function AddQueueCommand(
program: Command,
ctx: CommandContext,
enabled: boolean,
) {
const l = ctx.l;
const QueueGroup = l.r('cli.cmd.root.sub.queue');
program.commandsGroup(QueueGroup);
program
.command('queue', { hidden: !enabled })
.alias('q')
.summary(l.r('cli.cmd.queue.summary'))
.description(l.r('cli.cmd.queue.description'))
.helpGroup(QueueGroup)
.addArgument(
new Argument('[action]', l.r('cli.cmd.queue.action.description'))
.choices(['status', 'start', 'stop', 'help'])
.default('help'),
)
.addArgument(
new Argument('[name]', l.r('cli.cmd.queue.argument.name')).default(''),
)
.action(async function (this: Command, action: string, name: string) {
if (!action) {
this.outputHelp();
return;
}
if (action === 'help') {
this.outputHelp();
return;
}
if (!enabled) {
await CommandDisabled(ctx, 'queue');
return;
}
if (action === 'status') {
await QueueStatus(ctx, name);
return;
}
if (action === 'start') {
await QueueStart(ctx, name);
return;
}
if (action === 'stop') {
await QueueStop(ctx, name);
return;
}
this.outputHelp();
});
}
+38
View File
@@ -0,0 +1,38 @@
import { Command } from 'commander';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { CommandDisabled } from '@waha/apps/chatwoot/cli/cmd.disabled';
import { ServerReboot, ServerStatus } from '@waha/apps/chatwoot/cli/cmd.server';
export function AddServerCommand(
program: Command,
ctx: CommandContext,
enabled: boolean,
) {
const l = ctx.l;
const ServerGroup = l.r('cli.cmd.root.sub.server');
program.commandsGroup(ServerGroup);
const server = program
.command('server', { hidden: !enabled })
.description(l.r('cli.cmd.server.description'))
.helpGroup(ServerGroup);
if (!enabled) {
// Do not show help, show disabled ASAP
server.action(() => CommandDisabled(ctx, 'server'));
}
server
.command('status')
.description(l.r('cli.cmd.server.status.description'))
.action(() =>
enabled ? ServerStatus(ctx) : CommandDisabled(ctx, 'server status'),
);
server
.command('reboot')
.description(l.r('cli.cmd.server.reboot.description'))
.option('-f, --force', l.r('cli.cmd.server.reboot.option.force'), false)
.action((opts) =>
enabled
? ServerReboot(ctx, opts.force)
: CommandDisabled(ctx, 'server reboot'),
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Command } from 'commander';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import {
SessionLogout,
SessionQR,
SessionRestart,
SessionScreenshot,
SessionStart,
SessionStatus,
SessionStop,
} from '@waha/apps/chatwoot/cli/cms.session';
export function AddSessionCommand(program: Command, ctx: CommandContext) {
const l = ctx.l;
const SessionGroup = l.r('cli.cmd.root.sub.session');
program.commandsGroup(SessionGroup);
program
.command('status')
.alias('1')
.description(l.r('cli.cmd.session.status.description'))
.helpGroup(SessionGroup)
.action(() => SessionStatus(ctx));
program
.command('restart')
.alias('2')
.description(l.r('cli.cmd.session.restart.description'))
.helpGroup(SessionGroup)
.action(() => SessionRestart(ctx));
program
.command('start')
.alias('3')
.description(l.r('cli.cmd.session.start.description'))
.helpGroup(SessionGroup)
.action(() => SessionStart(ctx));
program
.command('stop')
.alias('4')
.description(l.r('cli.cmd.session.stop.description'))
.helpGroup(SessionGroup)
.action(() => SessionStop(ctx));
program
.command('logout')
.alias('5')
.description(l.r('cli.cmd.session.logout.description'))
.helpGroup(SessionGroup)
.action(() => SessionLogout(ctx));
program
.command('qr')
.alias('6')
.description(l.r('cli.cmd.session.qr.description'))
.helpGroup(SessionGroup)
.action(() => SessionQR(ctx));
program
.command('screenshot')
.alias('7')
.description(l.r('cli.cmd.session.screenshot.description'))
.helpGroup(SessionGroup)
.action(() => SessionScreenshot(ctx));
}
+23
View File
@@ -0,0 +1,23 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { Conversation } from '../client/Conversation';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { FlowProducer, Queue } from 'bullmq';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
export interface CommandContext {
data: InboxData;
logger: ILogger;
l: Locale;
waha: WAHASelf;
conversation: Conversation;
queues: {
registry: QueueRegistry;
contactsPull: Queue;
messagesPull: Queue;
};
flows: {
messagesPull: FlowProducer;
};
}
@@ -0,0 +1,26 @@
import { OutputConfiguration } from 'commander';
export class BufferedOutput implements OutputConfiguration {
private outBuffer: string[] = [];
private errBuffer: string[] = [];
writeOut = (str: string): void => {
this.outBuffer.push(str);
};
writeErr = (str: string): void => {
this.errBuffer.push(str);
};
outputError = (str: string, write: (s: string) => void) => {
this.errBuffer.push(str);
};
get out() {
return this.outBuffer.join('');
}
get err() {
return this.errBuffer.join('');
}
}
+132
View File
@@ -0,0 +1,132 @@
import { Command, Help, Option } from 'commander';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
export function fullCommandPath(sub: Command): string {
const parts: string[] = [];
let cmd: Command | null = sub;
// collect names up to (but not including) the root program
const name = cmd.name();
if (name) parts.push(name);
while (cmd && cmd.parent) {
cmd = cmd.parent!;
const name = cmd.name();
if (name && name != 'program') parts.push(name);
}
return parts.reverse().join(' ');
}
export function buildFormatHelp(l: Locale) {
/**
* Commands first, then Arguments, then Options
* @param cmd
* @param helper
*/
return function formatHelp(this: Help, cmd: Command, helper: Help) {
const termWidth = helper.padWidth(cmd, helper);
const helpWidth = helper.helpWidth ?? 80;
const callFormatItem = (term: string, description: string) =>
helper.formatItem(term, termWidth, description, helper);
let output: string[] = [];
const commandDescription = helper.commandDescription(cmd);
if (commandDescription.length > 0) {
output = output.concat([
helper.boxWrap(
helper.styleCommandDescription(commandDescription),
helpWidth,
),
'',
]);
}
const usage = helper.commandUsage(cmd);
if (usage) {
output.push(
`${
helper.styleTitle(l.r('cli.help.usage.title')) + '\n'
} ${helper.styleUsage(helper.commandUsage(cmd))}`,
);
output.push('');
}
const commandGroups = this.groupItems(
cmd.commands as Command[],
helper.visibleCommands(cmd),
(sub) => sub.helpGroup() || l.r('cli.help.commands.defaultGroup'),
);
commandGroups.forEach((commands, group) => {
if (!commands.length) {
return;
}
const commandLines = commands.map((sub) => {
const commandTerm = helper.styleSubcommandTerm(
helper.subcommandTerm(sub),
);
const description = helper.subcommandDescription(sub);
const styledDescription = description
? helper.styleSubcommandDescription(description)
: '';
return `${commandTerm} ${styledDescription}`;
});
output.push(helper.styleTitle(group));
output.push(...commandLines);
output.push('');
});
const argumentList = helper
.visibleArguments(cmd)
.map((argument) =>
callFormatItem(
helper.styleArgumentTerm(helper.argumentTerm(argument)),
helper.styleArgumentDescription(helper.argumentDescription(argument)),
),
);
output = output.concat(
this.formatItemList(
l.r('cli.help.arguments.title'),
argumentList,
helper,
),
);
const optionGroups = this.groupItems(
cmd.options as Option[],
helper.visibleOptions(cmd),
(option) =>
option.helpGroupHeading ?? l.r('cli.help.options.defaultGroup'),
);
optionGroups.forEach((options, group) => {
const optionList = options.map((option) =>
callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option)),
),
);
output = output.concat(this.formatItemList(group, optionList, helper));
});
if (helper.showGlobalOptions) {
const globalOptionList = helper
.visibleGlobalOptions(cmd)
.map((option) =>
callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option)),
),
);
output = output.concat(
this.formatItemList(
l.r('cli.help.globalOptions.title'),
globalOptionList,
helper,
),
);
}
return output.join('\n');
};
}
+65
View File
@@ -0,0 +1,65 @@
import * as ms from 'ms';
import { Option } from 'commander';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
/**
* Parse human string into milliseconds
*/
export function ParseMS(value: string) {
const duration = ms(value);
if (duration == null) throw new Error(`Invalid duration: "${value}"`);
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
return duration;
}
export function ParseSeconds(value: string): number {
const duration = ParseMS(value);
return Math.floor(duration / 1000);
}
export class JobAttemptsOption extends Option {
constructor(l: Locale, def: number) {
super('--at, --attempts <number>', l.r('cli.cmd.options.job.attempts'));
this.argParser(NotNegativeNumber);
this.default(def);
}
}
export class JobTimeoutOption extends Option {
constructor(l: Locale, def: string) {
super('-t, --timeout <duration>', l.r('cli.cmd.options.job.timeout'));
this.argParser(ParseMS);
this.default(ParseMS(def));
}
}
export function ProgressOption(
description: string,
def: number = 1000,
): Option {
return new Option('-p, --progress [number]', description)
.argParser(NotNegativeNumber)
.default(def);
}
export function NotNegativeNumber(value: string): number {
const n = parseInt(value, 10);
if (isNaN(n)) {
throw new Error(`Invalid number: "${value}"`);
}
if (n < 0) {
throw new Error(`Number must be positive or 0: "${value}"`);
}
return n;
}
export function PositiveNumber(value: string): number {
const n = parseInt(value, 10);
if (isNaN(n)) {
throw new Error(`Invalid number: "${value}"`);
}
if (n <= 0) {
throw new Error(`Number must be positive: "${value}"`);
}
return n;
}
@@ -3,10 +3,14 @@ import ChatwootClient, {
public_contact_create_update_payload,
} from '@figuro/chatwoot-sdk';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { ContactService } from '@waha/apps/chatwoot/client/ContactService';
import {
AvatarUpdateMode,
ContactService,
} from '@waha/apps/chatwoot/client/ContactService';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import {
ContactIds,
ConversationResult,
ConversationService,
} from '@waha/apps/chatwoot/client/ConversationService';
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
@@ -40,9 +44,10 @@ export class ContactConversationService {
this.cache = CacheForConfig(config);
}
private async upsertByContactInfo(
private async getByContactInfo(
contactInfo: ContactInfo,
): Promise<ContactIds> {
upsert: boolean,
): Promise<ContactIds | null> {
const chatId = contactInfo.ChatId();
// Check cache for chat id
@@ -50,65 +55,67 @@ export class ContactConversationService {
return this.cache.get(chatId);
}
//
// Find or create contact
//
let contact = await this.contactService.searchByAnyID(chatId);
if (!contact) {
const request = await contactInfo.PublicContactCreate();
contact = await this.contactService.create(chatId, request);
}
let [cwContact, created] =
await this.contactService.findOrCreateContact(contactInfo);
// Update custom attributes - always
const attributes = await contactInfo.Attributes();
this.logger.info(
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${contact.data.id}`,
this.logger.debug(
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
);
await this.contactService.upsertCustomAttributes(contact.data, attributes);
// Update Avatar if nothing, but keep the original one if any
if (!contact.data.thumbnail) {
const avatarUrl = await contactInfo.AvatarUrl().catch((err) => {
this.logger.warn(
`Error getting avatar for chat.id from WhatsApp: ${chatId}`,
);
this.logger.warn(err);
return null;
});
if (avatarUrl) {
this.contactService.updateAvatarUrlSafe(contact.data.id, avatarUrl);
}
}
this.logger.info(
`Using contact for chat.id: ${chatId}, contact.id: ${contact.data.id}, contact.sourceId: ${contact.sourceId}`,
const attributes = await contactInfo.Attributes();
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
);
await this.contactService.updateAvatar(
cwContact,
contactInfo,
AvatarUpdateMode.IF_MISSING,
);
this.logger.debug(
`Using contact for chat.id: ${chatId}, contact.id: ${cwContact.data.id}, contact.sourceId: ${cwContact.sourceId}`,
);
//
// Get or create a conversation for this inbox
//
const conversation = await this.conversationService.upsert({
id: contact.data.id,
sourceId: contact.sourceId,
});
this.logger.info(
`Using conversation for chat.id: ${chatId}, conversation.id: ${conversation.id}, contact.id: ${contact.sourceId}`,
const contactIds = {
id: cwContact.data.id,
sourceId: cwContact.sourceId,
};
let conversation: ConversationResult;
if (upsert) {
conversation = await this.conversationService.upsert(contactIds);
} else {
conversation = await this.conversationService.find(contactIds);
if (!conversation) {
// Nothing found, do not save it in cache
return null;
}
}
this.logger.debug(
`Using conversation for chat.id: ${chatId}, conversation.id: ${conversation.id}, contact.id: ${cwContact.sourceId}`,
);
// Save to cache
const ids = {
id: conversation.id,
sourceId: contact.sourceId,
sourceId: cwContact.sourceId,
};
this.cache.set(chatId, ids);
return ids;
}
public async ConversationByContact(
private async conversationByContact(
contactInfo: ContactInfo,
): Promise<Conversation> {
upsert: boolean,
): Promise<Conversation | null> {
const chatId = contactInfo.ChatId();
const ids = await this.upsertByContactInfo(contactInfo);
const ids = await this.getByContactInfo(contactInfo, upsert);
if (!ids) {
return null;
}
const conversation = new Conversation(
this.accountAPI,
this.config.accountId,
@@ -128,6 +135,26 @@ export class ContactConversationService {
return conversation;
}
/**
* Find or create if not exists conversation for the contact
*/
public async ConversationByContact(contactInfo: ContactInfo) {
const conversation = await this.conversationByContact(contactInfo, true);
if (!conversation) {
// Shouldn't happen, but for type safety
throw new Error('Conversation could not be created or found');
}
return conversation;
}
/**
* Find suitable conversation for the contact (based on filters)
* If nothing found - it doesn't create a new one
*/
public async FindConversationByContact(contactInfo: ContactInfo) {
return this.conversationByContact(contactInfo, false);
}
public ConversationById(conversationId: number): Conversation {
return new Conversation(
this.accountAPI,
@@ -145,7 +172,7 @@ export class ContactConversationService {
}
public ResetCache(chatIds: Array<string>) {
this.logger.info(`Resetting cache chat ids: ${chatIds.join(', ')}`);
this.logger.debug(`Resetting cache chat ids: ${chatIds.join(', ')}`);
for (const chatId of chatIds) {
this.cache.delete(chatId);
}
@@ -158,7 +185,7 @@ export class ContactConversationService {
}
const current = this.cache.get(chatId);
if (current.id !== contactId) {
this.logger.info(
this.logger.debug(
`Resetting cache for chat id: ${chatId}, value changed from ${current} to ${contactId}`,
);
this.cache.delete(chatId);
@@ -0,0 +1,23 @@
import { sanitizeName } from '@waha/apps/chatwoot/client/ContactService';
describe('sanitizeName', () => {
it('should return the same name', () => {
const name = 'John Doe';
expect(sanitizeName(name)).toBe(name);
});
it('should return 254 symbols max', () => {
const name = 'a'.repeat(300);
expect(sanitizeName(name)).toBe('a'.repeat(255));
});
it('should remove bidi characters and trim the name', () => {
const name =
'‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪‏‪+123123‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏‬‏';
expect(sanitizeName(name)).toBe('+123123');
});
it('should not remove bidi characters if within limit', () => {
const name = 'محمد +123';
expect(sanitizeName(name)).toBe(name);
});
});
+65 -2
View File
@@ -14,12 +14,32 @@ import * as lodash from 'lodash';
import { AttributeKey } from '../const';
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
export interface ContactResponse {
data: generic_id & contact;
sourceId: string;
}
export enum AvatarUpdateMode {
IF_MISSING,
ALWAYS,
}
export function sanitizeName(name: string) {
// 255 chars max
const limit = 255;
if (!name) {
return name;
}
if (name.length < limit) {
return name;
}
// remove only bidi controls
const clean = name.replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
return clean.slice(0, 255).trim();
}
export class ContactService {
constructor(
private config: ChatWootAPIConfig,
@@ -28,6 +48,20 @@ export class ContactService {
private logger: ILogger,
) {}
async findOrCreateContact(
contactInfo: ContactInfo,
): Promise<[ContactResponse, boolean]> {
const chatId = contactInfo.ChatId();
let contact = await this.searchByAnyID(chatId);
if (contact) {
return [contact, false];
}
const request = await contactInfo.PublicContactCreate();
contact = await this.create(chatId, request);
return [contact, true];
}
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
const payload: any[] = [
{
@@ -119,6 +153,7 @@ export class ContactService {
chatId: string,
payload: public_contact_create_update_payload,
): Promise<ContactResponse> {
payload.name = sanitizeName(payload.name);
const contact = await this.inboxAPI.contacts.create({
inboxIdentifier: this.config.inboxIdentifier,
data: payload,
@@ -136,8 +171,32 @@ export class ContactService {
};
}
public updateAvatarUrlSafe(contactId, avatarUrl: string) {
this.accountAPI.contacts
public async updateAvatar(
contact: ContactResponse,
contactInfo: ContactInfo,
mode: AvatarUpdateMode,
): Promise<boolean> {
// Update Avatar if nothing, but keep the original one if any
if (contact.data.thumbnail && mode == AvatarUpdateMode.IF_MISSING) {
return false;
}
const chatId = contactInfo.ChatId();
const avatarUrl = await contactInfo.AvatarUrl().catch((err) => {
this.logger.warn(
`Error getting avatar for chat.id from WhatsApp: ${chatId}`,
);
this.logger.warn(err);
return null;
});
if (!avatarUrl) {
return false;
}
const success = await this.updateAvatarUrlSafe(contact.data.id, avatarUrl);
return success;
}
public updateAvatarUrlSafe(contactId, avatarUrl: string): Promise<boolean> {
return this.accountAPI.contacts
.update({
accountId: this.config.accountId,
id: contactId,
@@ -145,11 +204,15 @@ export class ContactService {
avatar_url: avatarUrl,
},
})
.then(() => {
return true;
})
.catch((e) => {
this.logger.warn(
`Error updating avatar_url for contact.id: ${contactId}`,
);
this.logger.warn(e);
return true;
});
}
}
+20 -2
View File
@@ -4,6 +4,7 @@ import { MessageType } from '@waha/apps/chatwoot/client/types';
export class Conversation {
public onError: (e: any) => void;
public overrideIncoming: any = null;
constructor(
private accountAPI: ChatwootClient,
@@ -13,6 +14,9 @@ export class Conversation {
) {}
public async send(data: conversation_message_create) {
if (data.message_type === MessageType.INCOMING && this.overrideIncoming) {
data = { ...data, ...this.overrideIncoming };
}
try {
const message = await this.accountAPI.messages.create({
accountId: this.accountId,
@@ -29,10 +33,13 @@ export class Conversation {
}
public async incoming(text: string) {
const data: conversation_message_create = {
let data: conversation_message_create = {
content: text,
message_type: MessageType.INCOMING as any,
};
if (this.overrideIncoming) {
data = { ...data, ...this.overrideIncoming };
}
return this.send(data);
}
@@ -44,11 +51,22 @@ export class Conversation {
return this.send(data);
}
public async outgoing(text: string) {
public async note(text: string) {
const data: conversation_message_create = {
content: text,
private: true,
message_type: MessageType.OUTGOING as any,
};
return this.send(data);
}
/**
* Force a note message (private outgoing) for all communications
*/
public forceNote() {
this.overrideIncoming = {
private: true,
message_type: MessageType.OUTGOING as any,
};
}
}
@@ -6,7 +6,6 @@ import {
} from '@waha/apps/chatwoot/client/interfaces';
import type { conversation } from '@figuro/chatwoot-sdk/dist/models/conversation';
import { ConversationSelector } from '@waha/apps/chatwoot/services/ConversationSelector';
import axios from 'axios';
import { ChatWootInboxNewAPI } from '@waha/apps/chatwoot/client/ChatWootInboxNewAPI';
export type ConversationResult = Pick<conversation, 'id' | 'account_id'>;
@@ -25,14 +24,20 @@ export class ConversationService {
private logger: ILogger,
) {}
private async find(contact: ContactIds): Promise<ConversationResult | null> {
public async find(contact: ContactIds): Promise<ConversationResult | null> {
const result: { payload: contact_conversations } =
(await this.accountAPI.contacts.listConversations({
accountId: this.config.accountId,
id: contact.id,
})) as any;
const conversations = result.payload;
return this.selector.select(conversations);
const conversation = this.selector.select(conversations);
if (conversation) {
this.logger.debug(
`Found existing conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
);
}
return conversation;
}
private async create(contact: ContactIds): Promise<ConversationResult> {
@@ -40,7 +45,7 @@ export class ConversationService {
inboxIdentifier: this.config.inboxIdentifier,
contactIdentifier: contact.sourceId,
});
this.logger.info(
this.logger.debug(
`Created conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
);
return conversation;
@@ -51,9 +56,6 @@ export class ConversationService {
if (!conversation) {
conversation = await this.create(contact);
}
this.logger.info(
`Using conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
);
return conversation;
}
@@ -75,4 +77,14 @@ export class ConversationService {
throw error;
}
}
async resolve(conversationId: number): Promise<any> {
return this.accountAPI.conversations.toggleStatus({
accountId: this.config.accountId,
conversationId: conversationId,
data: {
status: 'resolved',
},
});
}
}
+9 -2
View File
@@ -1,4 +1,5 @@
import { AttributeKey } from '@waha/apps/chatwoot/const';
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
import * as lodash from 'lodash';
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
import { isLidUser } from '@waha/core/utils/jids';
@@ -22,7 +23,7 @@ export function GetAllChatIDs(contact: any): Array<string> {
attrs[AttributeKey.WA_JID],
attrs[AttributeKey.WA_LID],
];
return ids.filter(Boolean);
return lodash.uniq(ids.filter(Boolean));
}
export function FindChatID(contact: any): string | null {
@@ -55,3 +56,9 @@ export function ContactAttr(chatId: string): AttributeKey {
return AttributeKey.WA_JID;
}
}
export function IsCommandsChat(body): boolean {
const sender = body?.conversation?.meta?.sender;
const chatId = FindChatID(sender);
return chatId === INBOX_CONTACT_CHAT_ID;
}
+13
View File
@@ -5,6 +5,12 @@ export enum QueueName {
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
//
// Task
//
TASK_CONTACTS_PULL = 'chatwoot.task | contacts.pull',
TASK_MESSAGES_PULL = 'chatwoot.task | messages.pull',
//
// WAHA Events
//
@@ -14,6 +20,9 @@ export enum QueueName {
WAHA_MESSAGE_EDITED = 'chatwoot.waha | message.edited',
WAHA_MESSAGE_REVOKED = 'chatwoot.waha | message.revoked',
WAHA_MESSAGE_ACK = 'chatwoot.waha | message.ack',
WAHA_CALL_RECEIVED = 'chatwoot.waha | call.received',
WAHA_CALL_ACCEPTED = 'chatwoot.waha | call.accepted',
WAHA_CALL_REJECTED = 'chatwoot.waha | call.rejected',
//
// ChatWoot Events - Real
//
@@ -27,3 +36,7 @@ export enum QueueName {
INBOX_MESSAGE_DELETED = 'chatwoot.inbox | message_deleted',
INBOX_COMMANDS = 'chatwoot.inbox | commands',
}
export enum FlowProducerName {
MESSAGES_PULL_FLOW = 'messages.pull.flow',
}
+10 -7
View File
@@ -10,7 +10,7 @@ import {
ChatIDNotFoundForContactError,
PhoneNumberNotFoundInWhatsAppError,
} from '@waha/apps/chatwoot/errors';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job } from 'bullmq';
@@ -18,6 +18,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
/**
* Base class for ChatWoot inbox consumers
@@ -90,6 +91,12 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
}
}
protected conversationForReport(container, body): Conversation {
return container
.ContactConversationService()
.ConversationById(body.conversation.id);
}
/**
* Report an error for a message
*/
@@ -98,9 +105,7 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
const header: string = this.ErrorHeaderKey()
? container.Locale().key(this.ErrorHeaderKey()).render()
: err.message || `${err}`;
const conversation = container
.ContactConversationService()
.ConversationById(body.conversation.id);
const conversation = this.conversationForReport(container, body);
const reporter = container.ChatWootErrorReporter(job);
await reporter.ReportError(
conversation,
@@ -118,9 +123,7 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
}
const container = await this.DIContainer(job, job.data.app);
const conversation = container
.ContactConversationService()
.ConversationById(body.conversation.id);
const conversation = this.conversationForReport(container, body);
const reporter = container.ChatWootErrorReporter(job);
await reporter.ReportSucceeded(conversation, body.message_type, body.id);
}
+49 -196
View File
@@ -1,21 +1,20 @@
import { Processor } from '@nestjs/bullmq';
import { InjectFlowProducer, InjectQueue, Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { AttachmentFromBuffer } from '@waha/apps/chatwoot/client/messages';
import { MessageType } from '@waha/apps/chatwoot/client/types';
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import {
FlowProducerName,
QueueName,
} from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf } from '@waha/apps/chatwoot/session/WAHASelf';
import { SessionStatusEmoji } from '@waha/apps/chatwoot/SessionStatusEmoji';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job } from 'bullmq';
import { FlowProducer, Job, Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CommandPrefix, runText } from '@waha/apps/chatwoot/cli';
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
@Processor(QueueName.INBOX_COMMANDS, { concurrency: JOB_CONCURRENCY })
export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer {
@@ -23,6 +22,9 @@ export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
private readonly queueRegistry: QueueRegistry,
@InjectFlowProducer(FlowProducerName.MESSAGES_PULL_FLOW)
private readonly messagesPullFlow: FlowProducer,
) {
super(manager, log, rmutex, 'ChatWootInboxCommandsConsumer');
}
@@ -31,196 +33,47 @@ export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer
return null;
}
protected conversationForReport(container, body) {
const conversation = super.conversationForReport(container, body);
if (!IsCommandsChat(body)) {
conversation.forceNote();
}
return conversation;
}
protected async Process(container: DIContainer, body, job: Job) {
const handler = new SessionCommandHandler(
container.Logger(),
container.ContactConversationService(),
job.data.session,
container.Locale(),
container.WAHASelf(),
container.ChatWootConfig().commands,
);
return await handler.handle(body);
}
}
const CommandServerPrefix = 'server ';
enum Command {
STATUS = 'status',
RESTART = 'restart',
START = 'start',
STOP = 'stop',
LOGOUT = 'logout',
QR = 'qr',
SCREENSHOT = 'screenshot',
HELP = 'help',
SERVER_STATUS = 'server status',
SERVER_REBOOT = 'server reboot',
SERVER_REBOOT_FORCE = 'server reboot force',
}
const CommandMap: Record<string, Command> = {
// Status
[Command.STATUS]: Command.STATUS,
['1']: Command.STATUS,
// Restart
[Command.RESTART]: Command.RESTART,
['2']: Command.RESTART,
// Start
[Command.START]: Command.START,
['3']: Command.START,
// Stop
[Command.STOP]: Command.STOP,
['4']: Command.STOP,
// Logout
[Command.LOGOUT]: Command.LOGOUT,
['5']: Command.LOGOUT,
// QR
[Command.QR]: Command.QR,
['6']: Command.QR,
// Screenshot
[Command.SCREENSHOT]: Command.SCREENSHOT,
['7']: Command.SCREENSHOT,
// Help
[Command.HELP]: Command.HELP,
['8']: Command.HELP,
// Server commands
[Command.SERVER_STATUS]: Command.SERVER_STATUS,
[Command.SERVER_REBOOT]: Command.SERVER_REBOOT,
[Command.SERVER_REBOOT_FORCE]: Command.SERVER_REBOOT_FORCE,
};
class CommandIsNotImplementedError extends Error {
constructor(private command: string) {
super(`Command is not implemented`);
}
}
class SessionCommandHandler {
constructor(
private logger: ILogger,
private repo: ContactConversationService,
private session: string,
private l: Locale,
private waha: WAHASelf,
private commands: ChatWootCommandsConfig,
) {}
async handle(message: any) {
const cmd = message.content?.toLowerCase();
const command = this.findCommand(cmd) || Command.HELP;
let cmd = body.content;
cmd = cmd.startsWith(CommandPrefix) ? cmd.slice(CommandPrefix.length) : cmd;
this.logger.info(
`Executing command ${command} for session ${this.session}`,
`Executing command '${cmd}' for session ${job.data.session}...`,
);
const warning = this.commandDisabledWarning(command);
const conversation = await this.repo.InboxNotifications();
if (warning) {
const text = this.l.key(warning).render();
await conversation.incoming(text);
return;
const repo = container.ContactConversationService();
let conversation = repo.ConversationById(body.conversation.id);
if (!IsCommandsChat(body)) {
conversation.forceNote();
}
switch (command) {
case Command.RESTART:
await this.waha.restart(this.session);
break;
case Command.START:
await this.waha.restart(this.session);
break;
case Command.LOGOUT:
this.logger.info(`Logging out session ${this.session}`);
await this.waha.logout(this.session);
const text = this.l.key(TKey.APP_LOGOUT_SUCCESS).render();
await conversation.incoming(text);
break;
case Command.STOP:
this.logger.info(`Stopping session ${this.session}`);
await this.waha.stop(this.session);
break;
case Command.STATUS: {
this.logger.info(`Getting status for session ${this.session}`);
const session = await this.waha.get(this.session);
const emoji = SessionStatusEmoji(session.status);
const text = this.l.key(TKey.APP_SESSION_CURRENT_STATUS).render({
emoji: emoji,
session: session.name,
status: session.status,
name: session.me?.pushName || 'Unknown',
id: session.me?.id || 'No phone number',
});
await conversation.incoming(text);
break;
}
case Command.SERVER_STATUS: {
this.logger.info('Getting server version and status');
const version = await this.waha.serverVersion();
const status = await this.waha.serverStatus();
const text = this.l.key(TKey.APP_SERVER_VERSION_AND_STATUS).render({
version: JSON.stringify(version, null, 2),
status: JSON.stringify(status, null, 2),
});
await conversation.incoming(text);
break;
}
case Command.SERVER_REBOOT: {
this.logger.info('Rebooting server (graceful)');
const text = this.l.key(TKey.APP_SERVER_REBOOT).render();
await conversation.incoming(text);
await this.waha.serverReboot(false);
break;
}
case Command.SERVER_REBOOT_FORCE: {
this.logger.info('Rebooting server (force)');
const text = this.l.key(TKey.APP_SERVER_REBOOT_FORCE).render();
await conversation.incoming(text);
await this.waha.serverReboot(true);
break;
}
case Command.QR: {
const content = await this.waha.qr(this.session);
const message = AttachmentFromBuffer(content, 'qr.jpg');
message.message_type = MessageType.INCOMING;
await conversation.send(message);
break;
}
case Command.SCREENSHOT: {
this.logger.info(`Getting screenshot for session ${this.session}`);
const content = await this.waha.screenshot(this.session);
const message = AttachmentFromBuffer(content, 'screenshot.jpg');
message.message_type = MessageType.INCOMING;
await conversation.send(message);
break;
}
case Command.HELP: {
const text = this.l
.key(TKey.APP_COMMANDS_LIST)
.render({ commands: this.commands });
await conversation.incoming(text);
break;
}
default:
throw new CommandIsNotImplementedError(cmd);
const ctx: CommandContext = {
data: job.data,
logger: this.logger,
l: container.Locale(),
waha: container.WAHASelf(),
conversation: conversation,
queues: {
registry: this.queueRegistry,
contactsPull: this.queueRegistry.queue(QueueName.TASK_CONTACTS_PULL),
messagesPull: this.queueRegistry.queue(QueueName.TASK_MESSAGES_PULL),
},
flows: {
messagesPull: this.messagesPullFlow,
},
};
const commands = container.ChatWootConfig().commands;
const output = await runText(commands, ctx, cmd);
if (output.out) {
await conversation.incoming(output.out);
}
}
/**
* Check if the command allowed for this session
* Returns `null` if allowed or the message key to show as an error
*/
commandDisabledWarning(command: Command): TKey | null {
if (!this.commands.server && command.startsWith(CommandServerPrefix)) {
return TKey.APP_COMMANDS_SERVER_DISABLED;
if (output.err) {
await conversation.incoming(output.err);
}
return null;
}
findCommand(text: string): Command {
// Remove empty from end
text = text.trim();
// Remove empty from start
text = text.trimStart();
// Remove empty from end
text = text.trimEnd();
return CommandMap[text] || null;
}
}
@@ -8,8 +8,8 @@ import {
} from '@waha/apps/chatwoot/consumers/inbox/base';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { EngineHelper } from '@waha/apps/chatwoot/session';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
ChatwootMessage,
MessageMapping,
@@ -8,7 +8,7 @@ import {
} from '@waha/apps/chatwoot/consumers/inbox/base';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
@@ -56,6 +56,13 @@ class MessageDeletedHandler {
message_id: body.id,
});
if (!messages || messages.length == 0) {
if (body.private) {
// Private notes can have no whatsapp messages
this.logger.info(
`No WhatsApp message to delete for private note '${body.id}'`,
);
return;
}
throw Error(
`No WhatsApp message found for Chatwoot message '${body.id}'`,
);
@@ -9,7 +9,7 @@ import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { WAHASessionAPI } from '../../session/WAHASelf';
import { WAHASessionAPI } from '../../../app_sdk/waha/WAHASelf';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
@Processor(QueueName.INBOX_MESSAGE_UPDATED, { concurrency: JOB_CONCURRENCY })
@@ -4,7 +4,6 @@ import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
import { MessageType } from '@waha/apps/chatwoot/client/types';
import { ScheduledData } from '@waha/apps/chatwoot/consumers/types';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job } from 'bullmq';
@@ -19,7 +19,7 @@ import { TKey } from '@waha/apps/chatwoot/i18n/templates';
concurrency: JOB_CONCURRENCY,
})
export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
REMOVE_AFTER_DAYS = 14;
REMOVE_AFTER_DAYS = 365;
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
super(manager, log, rmutex, MessageCleanupConsumer.name);
+116
View File
@@ -0,0 +1,116 @@
import { AppConsumer } from '@waha/apps/app_sdk/AppConsumer';
import { JobLoggerWrapper } from '@waha/apps/app_sdk/JobLoggerWrapper';
import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
import { MessageType } from '@waha/apps/chatwoot/client/types';
import { ScheduledData } from '@waha/apps/chatwoot/consumers/types';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { SignalRace } from '@waha/utils/abortable';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
/**
* Base class for ChatWoot background task consumers
*/
export abstract class ChatWootTaskConsumer extends AppConsumer {
protected appRepository: AppRepository;
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
protected readonly consumerName: string,
) {
super('ChatWoot', consumerName, log, rmutex);
}
/**
* Gets the DIContainer for the specified app.
*/
protected async DIContainer(job: Job, appId: string): Promise<DIContainer> {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
return new DIContainer(app.pk, app.config, logger, knex);
}
protected abstract ErrorHeaderKey(): TKey | null;
/**
* Get the function that processes the scheduled job
* This method must be implemented by subclasses
*/
protected abstract Process(
container: DIContainer,
job: Job,
signal: AbortSignal,
): Promise<any>;
/**
* Process the job
* This method is called by the queue processor
*/
async processJob(job: Job<ScheduledData, any, string>): Promise<any> {
const signal = this.signal(job);
return SignalRace(this.ProcessAndReportStatus(job, signal), signal);
}
private async ProcessAndReportStatus(job: Job, signal: AbortSignal) {
try {
const container = await this.DIContainer(job, job.data.app);
const result = await this.Process(container, job, signal);
await this.ReportErrorRecovered(job);
return result;
} catch (err) {
this.ReportErrorForJob(job, err).catch((exc) =>
this.logger.error(`Failed to report error for job ${job.id}: ${exc}`),
);
throw err;
}
}
protected conversationForReport(container, body) {
const conversation = container
.ContactConversationService()
.ConversationById(body.conversation.id);
if (!IsCommandsChat(body)) {
conversation.forceNote();
}
return conversation;
}
/**
* Report an error for a scheduled job
*/
protected async ReportErrorForJob(job: Job, err: any) {
const container = await this.DIContainer(job, job.data.app);
let header = this.ErrorHeaderKey()
? container.Locale().key(this.ErrorHeaderKey()).render()
: err.message || `${err}`;
header = `${job.queueName}: ${header}`;
const conversation = this.conversationForReport(container, job.data.body);
const reporter = container.ChatWootErrorReporter(job);
await reporter.ReportError(conversation, header, MessageType.INCOMING, err);
throw err;
}
protected async ReportErrorRecovered(job: Job) {
if (!HasBeenRetried(job)) {
return;
}
const container = await this.DIContainer(job, job.data.app);
const conversation = this.conversationForReport(container, job.data.body);
const reporter = container.ChatWootErrorReporter(job);
await reporter.ReportSucceeded(conversation, MessageType.INCOMING);
}
}
@@ -0,0 +1,306 @@
import { Processor } from '@nestjs/bullmq';
import * as lodash from 'lodash';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job, JobState } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { ChatWootTaskConsumer } from '@waha/apps/chatwoot/consumers/task/base';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
import { ContactSortField } from '@waha/structures/contacts.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
import {
AvatarUpdateMode,
ContactService,
} from '@waha/apps/chatwoot/client/ContactService';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
import { sleep } from '@waha/utils/promiseTimeout';
import { isJidGroup, isLidUser, isPnUser } from '@waha/core/utils/jids';
import {
ArrayPaginator,
PaginatorParams,
} from '@waha/apps/app_sdk/waha/Paginator';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export interface ContactsPullOptions {
batch: number;
progress: number | null;
avatar: null | 'if-missing' | 'update';
attributes: boolean;
contacts: {
groups: boolean;
lids: boolean;
};
delay: {
contact: number;
batch: number;
};
}
/**
* Task consumer that pulls WhatsApp contacts into ChatWoot
* Runs in batches to keep the job resumable and observable in ChatWoot
*/
@Processor(QueueName.TASK_CONTACTS_PULL, { concurrency: JOB_CONCURRENCY })
export class TaskContactsPullConsumer extends ChatWootTaskConsumer {
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
super(manager, log, rmutex, TaskContactsPullConsumer.name);
}
protected ErrorHeaderKey(): TKey {
return null;
}
/**
* Process the contact pull job
* Streams batches of contacts from WAHA into ChatWoot storage
*/
protected async Process(
container: DIContainer,
job: Job,
signal?: AbortSignal,
): Promise<any> {
const options: ContactsPullOptions = job.data.options;
const locale = container.Locale();
const waha = container.WAHASelf();
const session = new WAHASessionAPI(job.data.session, waha);
const conversation = this.conversationForReport(container, job.data.body);
const handler = new ContactsPullHandler(
signal,
container.Logger(),
conversation,
session,
locale,
container.ContactService(),
);
return await handler.handle(options, job);
}
}
interface Progress {
created: number;
updated: number;
skipped: number;
errors: number;
avatar: {
updated: number;
};
}
function total(progress: Progress) {
return (
progress.created + progress.updated + progress.skipped + progress.errors
);
}
const NullProgress: Progress = {
created: 0,
updated: 0,
skipped: 0,
errors: 0,
avatar: {
updated: 0,
},
};
class ContactsPullHandler {
private activity: TaskActivity;
constructor(
private signal: AbortSignal,
private logger: ILogger,
private conversation: Conversation,
private session: WAHASessionAPI,
private l: Locale,
private contactService: ContactService,
) {
this.activity = new TaskActivity(this.l, this.conversation);
}
async handle(options: ContactsPullOptions, job) {
const batch = options.batch;
let progress = lodash.merge({}, NullProgress, job.progress);
this.logger.debug(
`Pulling contacts for session ${job.data.session} with batch size ${batch}...`,
);
if (lodash.isEqual(progress, NullProgress)) {
await this.activity.started(progress);
}
const query = {
sortBy: ContactSortField.ID,
sortOrder: SortOrder.ASC,
limit: batch,
offset: 0,
};
const params: PaginatorParams = {
processed: total(progress),
};
const paginator = new ArrayPaginator<any>(params);
const contacts = paginator.iterate((processed: number) => {
query.offset = processed;
return this.session.getContacts(query, {
signal: this.signal,
});
});
for await (const contact of contacts) {
this.signal.throwIfAborted();
//
// Show progress
//
const thetotal = total(progress);
if (options.progress && thetotal) {
if (thetotal % options.progress == 0) {
await this.activity.progress(progress);
}
}
// Delay on batch
if (thetotal && options.delay.batch) {
if (thetotal % batch == 0) {
await sleep(options.delay.batch);
}
}
// Process
try {
if (!EngineHelper.ContactIsMy(contact)) {
progress.skipped += 1;
await job.updateProgress(progress);
continue;
}
const chatId = contact.id;
if (isJidGroup(chatId)) {
if (!options.contacts.groups) {
this.logger.debug(`Skipping group contact ${chatId}...`);
progress.skipped += 1;
await job.updateProgress(progress);
continue;
}
} else if (isLidUser(chatId)) {
if (!options.contacts.lids) {
this.logger.debug(`Skipping LID contact ${chatId}...`);
progress.skipped += 1;
await job.updateProgress(progress);
continue;
}
} else if (!isPnUser(chatId)) {
this.logger.info(`Skipping non-phone-number contact ${chatId}...`);
progress.skipped += 1;
await job.updateProgress(progress);
continue;
}
this.logger.debug(`Pulling ${chatId}...`);
const result = await this.pullOneContact(options, chatId);
progress.created += result.created;
progress.updated += result.updated;
progress.avatar.updated += result.avatar.updated;
this.logger.info(
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, avatar.updated=${result.avatar.updated}`,
);
} catch (e) {
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
progress.errors += 1;
}
// update progress before signal fails
await job.updateProgress(progress);
await sleep(options.delay.contact);
}
await this.activity.completed(progress);
}
private async pullOneContact(options: ContactsPullOptions, chatId: string) {
// Contact
const contactInfo = WhatsAppContactInfo(this.session, chatId, this.l);
let [cwContact, created] =
await this.contactService.findOrCreateContact(contactInfo);
// Attributes
if (options.attributes) {
const attributes = await contactInfo.Attributes();
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
);
}
// Avatar
let avatarUpdated = false;
switch (options.avatar) {
case 'if-missing':
avatarUpdated = await this.contactService.updateAvatar(
cwContact,
contactInfo,
AvatarUpdateMode.IF_MISSING,
);
break;
case 'update':
avatarUpdated = await this.contactService.updateAvatar(
cwContact,
contactInfo,
AvatarUpdateMode.ALWAYS,
);
break;
}
return {
created: created ? 1 : 0,
updated: created ? 0 : 1,
avatar: {
updated: avatarUpdated ? 1 : 0,
},
};
}
}
class TaskActivity {
constructor(
private l: Locale,
private conversation: Conversation,
) {}
public async started(progress: Progress) {
await this.conversation.activity(
this.l.r('task.contacts.started', { progress: progress }),
);
}
public async progress(progress: Progress) {
await this.conversation.activity(
this.l.r('task.contacts.progress', { progress: progress }),
);
}
/**
* Final report
*/
public async completed(progress: Progress) {
await this.conversation.activity(
this.l.r('task.contacts.completed', { progress: progress }),
);
}
}
export function ContactsPullStatusMessage(
l: Locale,
job: Job,
state: JobState | 'unknown',
) {
const details = JobLink(job);
const progress = lodash.merge({}, NullProgress, job.progress);
const payload = {
error: state === 'failed' || state === 'unknown' || progress.errors > 0,
state: lodash.capitalize(state ?? 'unknown'),
progress: progress,
details: details,
};
return l.r('task.contacts.status', payload);
}
@@ -0,0 +1,523 @@
import { Processor } from '@nestjs/bullmq';
import * as ms from 'ms';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { ChatWootTaskConsumer } from '@waha/apps/chatwoot/consumers/task/base';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { Job, JobState } from 'bullmq';
import * as lodash from 'lodash';
import { PinoLogger } from 'nestjs-pino';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import {
GetChatMessagesFilter,
GetChatMessagesQuery,
MessageSortField,
} from '@waha/structures/chats.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
import { MessageAnyHandler } from '@waha/apps/chatwoot/consumers/waha/message.any';
import { MessageReportInfo } from '@waha/apps/chatwoot/consumers/waha/base';
import { MessageAckEmoji } from '@waha/apps/chatwoot/emoji';
import { SortOrder } from '@waha/structures/pagination.dto';
import {
ArrayPaginator,
PaginatorParams,
} from '@waha/apps/app_sdk/waha/Paginator';
import { EngineHelper } from '@waha/apps/chatwoot/waha/engines';
import { IgnoreJidConfig, isNullJid, JidFilter } from '@waha/core/utils/jids';
import { ErrorRenderer } from '@waha/apps/chatwoot/error/ErrorRenderer';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
import { addUnique } from '@waha/utils/list';
export enum ChatID {
ALL = 'all',
SUMMARY = 'summary',
}
export type MessagesPullOptions = {
chat: string;
batch: number;
progress: number | null;
period: {
start: number;
end: number;
};
force: boolean;
pause: boolean;
timeout: {
media: number;
};
media: boolean;
ignore: IgnoreJidConfig;
resolveConversations: boolean;
};
interface Progress {
ok: number;
exists: number;
ignored: number;
errors: number;
chats: string[];
messages: {
first?: number;
last?: number;
};
// Conversations
conversations: number[];
conversationResolve: {
success: number;
error: number;
};
}
const NullProgress: Progress = {
ok: 0,
exists: 0,
ignored: 0,
errors: 0,
chats: [],
messages: {
first: 0,
last: 0,
},
conversations: [],
conversationResolve: {
success: 0,
error: 0,
},
};
function total(progress: Progress) {
return progress.ok + progress.exists + progress.ignored + progress.errors;
}
/**
* Placeholder task consumer for pulling WhatsApp messages.
* Throws until the implementation is completed.
*/
@Processor(QueueName.TASK_MESSAGES_PULL, { concurrency: JOB_CONCURRENCY })
export class TaskMessagesPullConsumer extends ChatWootTaskConsumer {
constructor(
manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
protected queueManager: QueueManager,
) {
super(manager, log, rmutex, TaskMessagesPullConsumer.name);
}
protected ErrorHeaderKey(): TKey | null {
return null;
}
protected async Process(
container: DIContainer,
job: Job,
signal: AbortSignal,
) {
const options: MessagesPullOptions = job.data.options;
const locale = container.Locale();
const conversation = this.conversationForReport(container, job.data.body);
const waha = container.WAHASelf();
const session = new WAHASessionAPI(job.data.session, waha);
const handler = new MessagesPullHandler(
this.queueManager,
container,
signal,
container.Logger(),
conversation,
session,
locale,
);
await handler.start(options, job);
job = await handler.handle(options, job);
return await handler.end(options, job);
}
}
class MessagesPullHandler {
private activity: TaskActivity;
constructor(
protected queueManager: QueueManager,
protected container: DIContainer,
protected signal: AbortSignal,
protected logger: ILogger,
conversation: Conversation,
protected session: WAHASessionAPI,
protected l: Locale,
) {
this.activity = new TaskActivity(l, conversation);
}
/**
* Calculate summary progress including current task and direct children
*/
protected async summaryProgress(job: Job): Promise<Progress> {
const current = lodash.merge({}, NullProgress, job.progress);
const childrenValues = await job.getChildrenValues();
const values: Progress[] = [current, ...Object.values(childrenValues)];
let progress = lodash.merge({}, NullProgress);
// Result
for (const p of values) {
progress.ok += p.ok;
progress.exists += p.exists;
progress.ignored += p.ignored;
progress.errors += p.errors;
progress.conversationResolve.success += p.conversationResolve.success;
progress.conversationResolve.error += p.conversationResolve.error;
}
// Chats
progress.chats = lodash.uniq(lodash.flatten(values.map((p) => p.chats)));
// Conversations
progress.conversations = lodash.uniq(
lodash.flatten(values.map((p) => p.conversations)),
);
// Messages timestamps
progress.messages.first = lodash.min(
values.map((p) => p.messages.first || Infinity),
);
if (progress.messages.first === Infinity) {
progress.messages.first = undefined;
}
progress.messages.last = lodash.max(
values.map((p) => p.messages.last || 0),
);
if (progress.messages.last === 0) {
progress.messages.last = undefined;
}
return progress;
}
async start(options: MessagesPullOptions, job: Job) {
const { ignored, processed, unprocessed, failed } =
await job.getDependenciesCount();
const total = ignored + processed + unprocessed + failed;
const hasChildren = total > 0;
if (hasChildren) {
return;
}
// Perform some actions before any job started
if (options.pause) {
await this.queueManager.pause();
await this.activity.queue(true);
}
}
/**
* Perform actions after all jobs are done
*/
async end(options: MessagesPullOptions, job: Job) {
if (job.parentKey) {
// Not end yet, have parent tasks to process
return await this.summaryProgress(job);
}
// Resume queues if paused
if (options.pause) {
await this.queueManager.resume();
await this.activity.queue(false);
}
// Progress summary
const progress = await this.summaryProgress(job);
await job.updateProgress(progress);
// Resolve conversations
if (options.resolveConversations) {
await this.resolveConversations(progress);
await job.updateProgress(progress);
}
// Report completed
await this.activity.completed(progress, options);
return progress;
}
protected async resolveConversations(progress: Progress) {
const service = this.container.ConversationService();
for (const conversationId of progress.conversations) {
try {
await service.resolve(conversationId);
progress.conversationResolve.success += 1;
} catch (err) {
progress.conversationResolve.error += 1;
}
}
}
async handle(options: MessagesPullOptions, job: Job): Promise<Job> {
if (options.chat === ChatID.SUMMARY) {
// Do nothing for "summary" chat
return job;
}
const jids = new JidFilter(options.ignore);
let progress = lodash.merge({}, NullProgress, job.progress);
const batch = options.batch ?? 100;
this.logger.debug(
`Pulling messages for session ${job.data.session} with batch size ${batch}...`,
);
if (lodash.isEqual(progress, NullProgress)) {
await this.activity.started(job, options);
}
// Handler
const container = this.container;
const info = new MessageReportInfo();
const handler = new MessageAnyHistoryHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
this.session,
container.Locale(),
container.WAHASelf(),
);
handler.force = options.force;
// Messages
const lte = job.timestamp - options.period.end;
const gte = job.timestamp - options.period.start;
const filters: GetChatMessagesFilter = {
'filter.timestamp.lte': EnsureSeconds(lte),
'filter.timestamp.gte': EnsureSeconds(gte),
};
const query: GetChatMessagesQuery = {
limit: batch,
offset: 0,
downloadMedia: false,
sortBy: MessageSortField.TIMESTAMP,
sortOrder: SortOrder.ASC,
};
const params: PaginatorParams = {
processed: total(progress),
};
const paginator = new ArrayPaginator<WAMessage>(params);
let messages = paginator.iterate((processed: number) => {
query.offset = processed;
return this.session.getMessages(options.chat, query, filters, {
signal: this.signal,
});
});
messages = EngineHelper.IterateMessages(messages);
const all = options.chat == ChatID.ALL;
for await (let message of messages) {
this.signal.throwIfAborted();
//
// Show progress
//
const thetotal = total(progress);
if (options.progress && thetotal) {
if (thetotal % options.progress == 0) {
await this.activity.progress(progress, options);
}
}
// Process
try {
progress.messages.first = progress.messages.first || message.timestamp;
progress.messages.last = message.timestamp;
if (all && !jids.include(EngineHelper.ChatID(message))) {
progress.ignored += 1;
continue;
}
if (isNullJid(EngineHelper.ChatID(message))) {
progress.ignored += 1;
continue;
}
if (
options.media &&
message.hasMedia &&
(await handler.ShouldProcessMessage(message))
) {
// Fetch media for the message
let signal = AbortSignal.timeout(options.timeout.media);
signal = AbortSignal.any([signal, this.signal]);
message = await this.session.getMessageById('all', message.id, true, {
signal: signal,
});
}
const chatwoot = await handler.handle(message);
if (chatwoot) {
progress.ok += 1;
addUnique(progress.chats, EngineHelper.ChatID(message));
if (info.conversationId) {
addUnique(progress.conversations, info.conversationId);
}
} else {
progress.exists += 1;
}
} catch (error) {
const renderer = new ErrorRenderer();
const text = renderer.text(error);
this.logger.error(`Error: ${text}`);
this.logger.error(`Message:\n${JSON.stringify(message, null, 2)}`);
try {
const data = renderer.data(error);
this.logger.error(JSON.stringify(data, null, 2));
} catch (err) {
this.logger.error(
`Error occurred while login details for error: ${err}`,
);
}
progress.errors += 1;
}
// update progress before signal fails
await job.updateProgress(progress);
}
job.progress = progress;
return job;
}
}
export class TaskActivity {
constructor(
private l: Locale,
private conversation: Conversation,
) {}
public async queue(paused: boolean) {
let msg: string;
if (paused) {
msg = this.l.r('cli.cmd.queue.paused');
} else {
msg = this.l.r('cli.cmd.queue.resumed');
}
await this.conversation.activity(msg);
}
public async details(data) {
await this.conversation.activity(
this.l.r('task.messages.details', {
prefix: IsCommandsChat(data.body) ? '' : CommandPrefix,
}),
);
}
public async started(job: Job, options: MessagesPullOptions) {
await this.conversation.activity(
this.l.r('task.messages.started', {
chat: options.chat,
period: period(options),
}),
);
}
public async progress(progress: Progress, options: MessagesPullOptions) {
const format: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
await this.conversation.activity(
this.l.r('task.messages.progress', {
progress: progress,
chat: options.chat,
messages: {
first: this.l.FormatTimestampOpts(
progress.messages.first,
format,
false,
),
last: this.l.FormatTimestampOpts(
progress.messages.last,
format,
false,
),
},
}),
);
}
/**
* Final report
*/
public async completed(progress, options) {
const msg = this.l.r('task.messages.completed', {
progress: progress,
period: period(options),
chat: options.chat,
});
await this.conversation.activity(msg);
}
}
class MessageAnyHistoryHandler extends MessageAnyHandler {
public force: boolean = false;
public shouldLogUnsupported = true;
protected get historyMessage() {
return true;
}
protected get shouldAddFromTag() {
return false;
}
protected get delayFromMeAPI() {
return 0;
}
async ShouldProcessMessage(payload: WAMessage) {
if (this.force) {
return true;
}
return await super.ShouldProcessMessage(payload);
}
}
function period(options: MessagesPullOptions) {
const start = options.period.start;
const end = options.period.end;
if (end == 0) {
return ms(start);
}
return `${ms(start)}-${ms(end)}`;
}
export function MessagesPullStatusMessage(
l: Locale,
job: Job,
state: JobState | 'unknown',
) {
const progress = lodash.merge({}, NullProgress, job.progress);
const details = JobLink(job);
const options = job.data.options as MessagesPullOptions;
const payload = {
options: options,
chat: options.chat,
chats: progress.chats.length,
period: period(options),
error: state === 'failed' || state === 'unknown' || progress.errors > 0,
state: lodash.capitalize(state ?? 'unknown'),
progress: progress,
details: details,
messages: {
first: l.FormatTimestamp(progress.messages.first, false),
last: l.FormatTimestamp(progress.messages.last, false),
},
};
return l.r('task.messages.status', payload);
}
+117 -40
View File
@@ -13,7 +13,7 @@ import { EventData } from '@waha/apps/chatwoot/consumers/types';
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
AppRepository,
ChatwootMessage,
@@ -24,27 +24,36 @@ import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { MessageSource, WAMessageBase } from '@waha/structures/responses.dto';
import {
WAHAWebhook,
WAHAWebhookMessageRevoked,
} from '@waha/structures/webhooks.dto';
import { MessageSource } from '@waha/structures/responses.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { isJidBroadcast, isJidGroup, toCusFormat } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { EnsureSeconds } from '@waha/utils/timehelper';
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
import {
ChatWootAppConfig,
ChatWootConfig,
} from '@waha/apps/chatwoot/dto/config.dto';
export function ListenEventsForChatWoot() {
return [
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
WAHAEvents.MESSAGE_ANY,
WAHAEvents.MESSAGE_REACTION,
WAHAEvents.MESSAGE_EDITED,
WAHAEvents.MESSAGE_REVOKED,
WAHAEvents.MESSAGE_ACK,
WAHAEvents.SESSION_STATUS,
WAHAEvents.CALL_RECEIVED,
WAHAEvents.CALL_ACCEPTED,
WAHAEvents.CALL_REJECTED,
];
if (config.conversations.markAsRead) {
events.push(WAHAEvents.MESSAGE_ACK);
}
return events;
}
/**
@@ -177,7 +186,7 @@ export interface IMessageInfo {
onMessageType(type: MessageType): void;
}
class MessageReportInfo implements IMessageInfo {
export class MessageReportInfo implements IMessageInfo {
public conversationId: number | null = null;
public type: MessageType | null = null;
@@ -196,7 +205,17 @@ export interface ChatWootMessagePartial {
private?: boolean;
}
export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
export interface MessageBaseHandlerPayload {
id: string;
timestamp: number;
from?: string;
fromMe?: boolean;
source?: MessageSource;
}
export abstract class MessageBaseHandler<
Payload extends MessageBaseHandlerPayload,
> {
constructor(
protected job: Job,
protected mappingService: MessageMappingService,
@@ -212,10 +231,65 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
payload: Payload,
): Promise<ChatWootMessagePartial>;
protected get historyMessage() {
return false;
}
protected finalizeContent(content: string, payload: Payload): string {
if (!content) {
return content;
}
if (!payload.timestamp) {
return content;
}
if (!this.historyMessage) {
// Check if the message is recent enough to be considered current
const now = EnsureSeconds(Date.now() / 1000);
const messageAge = now - EnsureSeconds(payload.timestamp);
const recent = messageAge <= CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS;
if (recent) {
return content;
}
}
const date = this.l.ParseTimestamp(payload.timestamp);
if (!date) {
return content;
}
const timestamp = this.l.FormatHumanDate(date);
return this.l.r('whatsapp.history.message.wrapper', {
content: content,
payload: payload,
history: this.historyMessage,
timestamp: timestamp,
});
}
abstract getReplyToWhatsAppID(payload: Payload): string | undefined;
async handle(data: WAHAWebhook<Payload>) {
const payload = data.payload;
protected get delayFromMeAPI() {
return 3_000;
}
/**
* Weather to add a tag to messages sent from me like via API or WhatsApp
*/
protected get shouldAddFromTag() {
return true;
}
async ShouldProcessMessage(payload: Payload): Promise<boolean> {
const key = parseMessageIdSerialized(payload.id);
const chatwoot = await this.mappingService.getChatWootMessage({
chat_id: toCusFormat(key.remoteJid),
message_id: key.id,
});
if (chatwoot) {
return false;
}
return true;
}
async handle(payload: Payload): Promise<conversation_message_create | null> {
// Find the type as soon as possible for error reporting
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
this.info.onMessageType(type);
@@ -224,28 +298,25 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
// It also handles messages fromMe and ChatWoot (but does not if send via API)
if (payload.fromMe && payload.source === MessageSource.API) {
// Sleep a few seconds to save it to a database
await sleep(3_000);
await sleep(this.delayFromMeAPI);
}
const key = parseMessageIdSerialized(payload.id);
const chatwoot = await this.mappingService.getChatWootMessage({
chat_id: toCusFormat(key.remoteJid),
message_id: key.id,
});
if (chatwoot) {
this.logger.info(
`Message '${payload.id}' already in ChatWoot: conversation.id=${chatwoot.conversation_id}, message.id=${chatwoot.message_id}`,
);
return;
if (!(await this.ShouldProcessMessage(payload))) {
const log = `Skipping existing message '${payload.id}' from WhatsApp`;
this.logger.debug(log);
return null;
}
const contactInfo = WhatsAppContactInfo(this.session, payload.from, this.l);
const contactInfo = WhatsAppContactInfo(
this.session,
EngineHelper.ChatID(payload as any),
this.l,
);
const conversation = await this.repo.ConversationByContact(contactInfo);
this.info.onConversationId(conversation.conversationId);
const message = await this.buildChatWootMessage(payload);
const response = await conversation.send(message);
this.logger.info(
this.logger.debug(
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
);
await this.saveMapping(response, payload);
@@ -254,7 +325,7 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
private async saveMapping(
chatwootMessage: generic_id & message,
whatsappMessage: WAMessageBase,
whatsappMessage: MessageBaseHandlerPayload,
) {
const chatwoot: Omit<ChatwootMessage, 'id'> = {
timestamp: new Date(chatwootMessage.created_at * 1000),
@@ -280,22 +351,26 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
let content = message.content;
// Format the content if the message from me
if (payload.fromMe && payload.source === MessageSource.APP) {
const key = TKey.MESSAGE_FROM_WHATSAPP;
content = this.l.key(key).render({ text: content });
} else if (payload.fromMe && payload.source === MessageSource.API) {
const key = TKey.MESSAGE_FROM_API;
content = this.l.key(key).render({ text: content });
if (payload.fromMe && this.shouldAddFromTag) {
if (payload.source === MessageSource.APP) {
const key = TKey.MESSAGE_FROM_WHATSAPP;
content = this.l.key(key).render({ text: content });
} else if (payload.source === MessageSource.API) {
const key = TKey.MESSAGE_FROM_API;
content = this.l.key(key).render({ text: content });
}
}
const chatId = payload.from;
const chatId = EngineHelper.ChatID(payload);
// Add participant name to group messages
const manyParticipants = isJidGroup(chatId) || isJidBroadcast(chatId);
if (!payload.fromMe && manyParticipants) {
const key = parseMessageIdSerialized(payload.id, true);
let participant = toCusFormat(key.participant);
const contact: any = await this.session.getContact(key.participant);
let participant = toCusFormat(
key.participant || (payload as any)._data.participant,
);
const contact: any = await this.session.getContact(participant);
const name = contact?.name || contact?.pushName || contact?.pushname;
if (name) {
participant = `${name} (${participant})`;
@@ -314,11 +389,13 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
},
);
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
const private_ = message.private ?? payload.fromMe;
const type = private_ ? MessageType.OUTGOING : MessageType.INCOMING;
content = this.finalizeContent(content, payload);
return {
content: content,
message_type: type,
private: message.private ?? payload.fromMe,
private: private_,
attachments: message.attachments as any,
content_attributes: {
in_reply_to: replyTo,
@@ -334,7 +411,7 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
return;
}
const chatwoot = await this.mappingService.getChatWootMessage({
chat_id: payload.from,
chat_id: EngineHelper.ChatID(payload),
message_id: replyToWhatsAppID,
});
return chatwoot?.message_id;
@@ -0,0 +1,58 @@
import { CallData } from '@waha/structures/calls.dto';
import { MessageSource } from '@waha/structures/responses.dto';
import { SerializeMessageKey } from '@waha/core/utils/ids';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { MessageBaseHandlerPayload } from '@waha/apps/chatwoot/consumers/waha/base';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldProcessCall(event: { payload: CallData }): boolean {
if (event.payload.isGroup) {
// No group calls for now
// GroupJID (group-jid tag) is empty in all cases
// and the "call" appears in the 1-1 chat
// with the participant who started the call
return false;
}
return true;
}
// The MessageBaseHandler works on top of "MessageBase",
// but we need the call original properties, so we keep it here
export type CallMessagePayload = CallData &
MessageBaseHandlerPayload & { callId: string };
const SUFFIX = {
[WAHAEvents.CALL_RECEIVED]: '0',
[WAHAEvents.CALL_ACCEPTED]: '1',
[WAHAEvents.CALL_REJECTED]: '2',
};
type Events = keyof typeof SUFFIX;
/**
* So we can reference from "accepted" or "rejected" to the first one, "received"
* and "reply" to it
*/
export function BuildFakeCallMessageId(id: string, event: Events) {
return id + SUFFIX[event];
}
export function BuildCallMessagePayload(
call: CallData,
event: Events,
): CallMessagePayload {
const key = {
fromMe: false,
remoteJid: EngineHelper.CallChatID(call),
id: BuildFakeCallMessageId(call.id, event),
};
const id = SerializeMessageKey(key);
return {
...call,
callId: call.id,
id: id,
// Use the "Creator" one if there's any
from: EngineHelper.CallChatID(call),
fromMe: false,
source: MessageSource.APP,
};
}
@@ -0,0 +1,88 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
BuildFakeCallMessageId,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallAccepted } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_ACCEPTED, { concurrency: JOB_CONCURRENCY })
export class WAHACallAcceptedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallAcceptedConsumer');
}
ShouldProcess(event: WAHAWebhookCallAccepted): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallAccepted): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallAccepted;
const locale = container.Locale();
const handler = new CallAcceptedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
WAHAEvents.CALL_ACCEPTED,
);
return await handler.handle(msg);
}
}
export class CallAcceptedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: CallMessagePayload,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_ACCEPTED)
.render({ call: payload });
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: true,
};
}
getReplyToWhatsAppID(payload: CallMessagePayload): string | undefined {
return BuildFakeCallMessageId(payload.callId, WAHAEvents.CALL_RECEIVED);
}
}
@@ -0,0 +1,92 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
MessageBaseHandlerPayload,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallReceived } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { CallData } from '@waha/structures/calls.dto';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_RECEIVED, { concurrency: JOB_CONCURRENCY })
export class WAHACallReceivedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallReceivedConsumer');
}
ShouldProcess(event: WAHAWebhookCallReceived): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallReceived): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallReceived;
const locale = container.Locale();
const handler = new CallReceivedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
event.event as WAHAEvents.CALL_RECEIVED,
);
return await handler.handle(msg);
}
}
export class CallReceivedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: MessageBaseHandlerPayload & CallData,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_RECEIVED)
.render({ call: payload });
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: false,
};
}
getReplyToWhatsAppID(
payload: MessageBaseHandlerPayload & CallData,
): string | undefined {
// No reply required for the first call received
return undefined;
}
}
@@ -0,0 +1,102 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
BuildFakeCallMessageId,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallRejected } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_REJECTED, { concurrency: JOB_CONCURRENCY })
export class WAHACallRejectedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallRejectedConsumer');
}
ShouldProcess(event: WAHAWebhookCallRejected): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallRejected): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallRejected;
const locale = container.Locale();
const handler = new CallRejectedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
WAHAEvents.CALL_REJECTED,
);
return await handler.handle(msg);
}
}
export class CallRejectedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: CallMessagePayload,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_REJECTED)
.render({ call: payload });
let private_ = false;
// GOWS
if (payload._data.Data?.Tag == 'reject') {
private_ = true;
}
if (payload._data.Reason == 'rejected_elsewhere') {
private_ = true;
}
// WEBJS
if (payload._data.api) {
private_ = true;
}
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: private_,
};
}
getReplyToWhatsAppID(payload: CallMessagePayload): string | undefined {
return BuildFakeCallMessageId(payload.callId, WAHAEvents.CALL_RECEIVED);
}
}
+38 -18
View File
@@ -10,15 +10,16 @@ import {
} from '@waha/apps/chatwoot/consumers/waha/base';
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents, WAMessageAck } from '@waha/structures/enums.dto';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { isJidCusFormat } from '@waha/utils/wa';
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
@Processor(QueueName.WAHA_MESSAGE_ACK, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
@@ -30,18 +31,8 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
super(manager, log, rmutex, 'WAHAMessageAckConsumer');
}
ShouldProcess(event: WAHAWebhookMessageAck): boolean {
// Mark as seen only if it's DM
// Ignore groups and other multiple participants chats
const chatId = this.GetChatId(event);
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
return false;
}
// Only READ and PLAYED
const payload = event.payload;
return (
payload.ack === WAMessageAck.READ || payload.ack == WAMessageAck.PLAYED
);
ShouldProcess(event: any): boolean {
return ShouldMarkAsReadInChatWoot(event);
}
GetChatId(event: WAHAWebhookMessageAck): string {
@@ -57,18 +48,26 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
const session = new WAHASessionAPI(event.session, container.WAHASelf());
const handler = new MessageAckHandler(
container.ContactConversationService(),
container.MessageMappingService(),
container.Logger(),
info,
session,
container.Locale(),
);
await handler.handle(event);
try {
await handler.handle(event);
} catch (e) {
// TODO: Investigate errors
// https://github.com/devlikeapro/waha/issues/1492
this.logger.error(e);
}
}
}
class MessageAckHandler {
constructor(
private readonly contactConversationService: ContactConversationService,
protected mappingService: MessageMappingService,
private readonly logger: ILogger,
private readonly info: IMessageInfo,
private readonly session: WAHASessionAPI,
@@ -77,13 +76,34 @@ class MessageAckHandler {
async handle(event: WAHAWebhookMessageAck): Promise<void> {
const payload = event.payload;
const key = parseMessageIdSerialized(payload.id);
const chatwoot = await this.mappingService.getChatWootMessage({
chat_id: null,
message_id: key.id,
});
// No chatwoot message found, so we don't mark it as read
// Filters out old messages and some service ack messages
if (!chatwoot) {
return;
}
const contactInfo = WhatsAppContactInfo(
this.session,
payload.from,
this.locale,
);
const conversation =
await this.contactConversationService.ConversationByContact(contactInfo);
await this.contactConversationService.FindConversationByContact(
contactInfo,
);
if (!conversation) {
this.logger.debug(
`No suitable conversation found to mark as read for chat.id: ${payload.from}`,
);
return;
}
this.info.onConversationId(conversation.conversationId);
const sourceId = conversation.sourceId;
@@ -0,0 +1,466 @@
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
interface TestParam {
name: string;
expected: boolean;
GOWS: any;
NOWEB: any;
WEBJS: any;
}
/**
* Me - 444444444444 (lid - 144444444444444)
* Participant - 11111111111 (lid - 011111111111111)
* Group - 222222222222222222
*/
const ShouldMarkAsReadCases: TestParam[] = [
{
name: 'Participant read My message (READ ack)',
expected: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'true_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '11111111111@s.whatsapp.net',
IsFromMe: false,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: 'read',
MessageSender: '',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: true,
ack: 3,
ackName: 'READ',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAA',
from: '444444444444@c.us',
fromMe: true,
source: 'app',
to: '11111111111@c.us',
body: 'Nn',
hasMedia: false,
media: null,
ack: 3,
ackName: 'READ',
vCards: [],
_data: {
id: {
fromMe: true,
remote: '11111111111@c.us',
id: 'AAA',
_serialized: 'true_11111111111@c.us_AAA',
},
viewed: false,
from: '444444444444@c.us',
to: '11111111111@c.us',
author: '444444444444@c.us',
ack: 3,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
{
name: 'Participant received My message (DEVICE ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'true_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: true,
ack: 2,
ackName: 'DEVICE',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '11111111111:3@s.whatsapp.net',
IsFromMe: false,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: '',
MessageSender: '',
},
},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: true,
ack: 2,
ackName: 'DEVICE',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAA',
from: '444444444444@c.us',
fromMe: true,
source: 'app',
to: '11111111111@c.us',
body: 'Nn',
hasMedia: false,
media: null,
ack: 2,
ackName: 'DEVICE',
vCards: [],
_data: {
id: {
fromMe: true,
remote: '11111111111@c.us',
id: 'AAA',
_serialized: 'true_11111111111@c.us_AAA',
},
from: '444444444444@c.us',
to: '11111111111@c.us',
author: '444444444444@c.us',
ack: 2,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
{
name: 'Me read Participant message on another device (READ ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'false_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: false,
ack: 3,
ackName: 'READ',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '444444444444@s.whatsapp.net',
IsFromMe: true,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: 'read',
MessageSender: '11111111111@s.whatsapp.net',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: '11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: false,
ack: 3,
ackName: 'READ',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: null,
},
{
name: 'Participant read My Group Message (READ ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '011111111111111@lid',
participant: '011111111111111@lid',
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
Chat: '222222222222222222@g.us',
Sender: '011111111111111@lid',
IsFromMe: false,
IsGroup: true,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
Type: 'read',
MessageSender: '',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '222222222222222222@g.us',
participant: '011111111111111@lid',
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
key: {
remoteJid: '222222222222222222@g.us',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
fromMe: true,
participant: '011111111111111@lid',
},
receipt: {
userJid: '011111111111111@lid',
},
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '222222222222222222@g.us',
participant: '011111111111111@lid',
fromMe: false,
ack: 3,
ackName: 'READ',
_data: {
tag: 'receipt',
attrs: {
from: {
$1: {
type: 0,
user: '222222222222222222',
server: 'g.us',
},
},
type: 'read',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
participant: {
$1: {
type: 1,
user: '011111111111111',
device: 0,
domainType: 1,
},
},
},
content: null,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
];
describe('ShouldMarkAsReadInChatWoot', () => {
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
for (const engine of engines) {
describe(engine, () => {
for (const param of ShouldMarkAsReadCases) {
const name = `[${param.expected}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.expected;
testfn(name, () => {
// Test
const result = ShouldMarkAsReadInChatWoot(event);
expect(result).toBe(expected);
});
}
});
}
});
@@ -0,0 +1,32 @@
import type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { isJidCusFormat } from '@waha/utils/wa';
import { WAMessageAck } from '@waha/structures/enums.dto';
import { isLidUser, toCusFormat } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldMarkAsReadInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
// Mark as seen only if it's DM
// Ignore groups and other multiple participants chats
const chatId = EngineHelper.ChatID(event.payload);
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
return false;
}
const payload = event.payload;
// Only READ and PLAYED
const read = payload.ack === WAMessageAck.READ;
const played = payload.ack == WAMessageAck.PLAYED;
if (!read && !played) {
return false;
}
// Only process when OUR message (fromMe: true) was read by recipient
if (!payload.fromMe) {
return false;
}
// Mark ChatWoot conversation as read when recipient reads our message
return true;
}
@@ -8,7 +8,7 @@ import {
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
@@ -24,11 +24,15 @@ import {
LocationMessage,
MessageToChatWootConverter,
PollMessage,
ListMessage,
ShareContactMessage,
TextMessage,
UnsupportedMessage,
AlbumMessage,
resolveProtoMessage,
} from '@waha/apps/chatwoot/messages/to/chatwoot';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { HasMediaWithNoMediaMessage } from '@waha/apps/chatwoot/messages/to/chatwoot/HasMediaWithNoMediaMessage';
@Processor(QueueName.WAHA_MESSAGE_ANY, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
@@ -41,7 +45,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageAny): string {
return event.payload.from;
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -61,17 +65,19 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
export class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
public shouldLogUnsupported: boolean = false;
protected async getMessage(
payload: WAMessage,
): Promise<ChatWootMessagePartial> {
let converter: MessageToChatWootConverter;
let msg: ChatWootMessagePartial;
const protoMessage = resolveProtoMessage(payload);
const protoMessage = resolveProtoMessage(payload._data);
// Check for Facebook Ad first - but let it use the normal flow later
converter = new FacebookAdMessage(this.l, this.logger);
@@ -80,7 +86,7 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
return msg;
}
converter = new TextMessage(this.l, this.logger, this.waha);
converter = new TextMessage(this.l, this.logger, this.waha, this.job);
msg = await converter.convert(payload, null);
if (msg) {
return msg;
@@ -104,6 +110,12 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
return msg;
}
converter = new ListMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new EventMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
@@ -116,8 +128,25 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
return msg;
}
converter = new AlbumMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new HasMediaWithNoMediaMessage(this.l, this.job);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new UnsupportedMessage(this.l, this.job);
msg = await converter.convert(payload, protoMessage);
if (this.shouldLogUnsupported) {
this.logger.warn(
`UnsupportedMessage:\n${JSON.stringify(payload, null, 2)}`,
);
}
return msg;
}
@@ -126,6 +155,9 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
if (!replyTo) {
return undefined;
}
if (!replyTo.id) {
return undefined;
}
const key = parseMessageIdSerialized(replyTo.id, true);
return key.id;
}
@@ -18,12 +18,13 @@ import {
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { WAHASessionAPI } from '../../session/WAHASelf';
import { WAHASessionAPI } from '../../../app_sdk/waha/WAHASelf';
import {
MessageEdited,
MessageToChatWootConverter,
resolveProtoMessage,
} from '@waha/apps/chatwoot/messages/to/chatwoot';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
@Processor(QueueName.WAHA_MESSAGE_EDITED, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
@@ -36,7 +37,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageEdited): string {
return event.payload.from;
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -56,7 +57,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
@@ -64,7 +65,7 @@ class MessageEditedHandler extends MessageBaseHandler<WAMessageEditedBody> {
protected async getMessage(
payload: WAMessageEditedBody,
): Promise<ChatWootMessagePartial> {
const protoMessage = resolveProtoMessage(payload);
const protoMessage = resolveProtoMessage(payload._data);
const converter: MessageToChatWootConverter = new MessageEdited(this.l);
return converter.convert(payload, protoMessage);
}
@@ -9,16 +9,17 @@ import {
IMessageInfo,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { WAMessageReaction, WAReaction } from '@waha/structures/responses.dto';
import { WAHAWebhookMessageReaction } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
@Processor(QueueName.WAHA_MESSAGE_REACTION, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
@@ -31,7 +32,13 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageReaction): string {
return event.payload.from;
if (event.environment?.engine == WAHAEngine.WEBJS) {
// chat in "to" field for WEBJS for message.reaction
// probably we need to set it to "from"
// but for backward compatability we do this
return event.payload.to;
}
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -51,7 +58,7 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more