Compare commits

...
196 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
devlikepro 5cfb53c367 [core] 2025.10.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-10-12 18:45:57 +07:00
devlikepro 9466407b8a [core] ChatWoot - update last seen (on message.ack)
closes #1436
fixes #1319
2025-10-12 18:45:56 +07:00
devlikepro 4b65f0c462 [core] ChatWoot - up to v4.6.0 2025-10-12 18:45:56 +07:00
devlikepro 23504b35c8 [core] ChatWoot - remove /n at the end of any templated 2025-10-12 18:45:55 +07:00
devlikepro dd6ec4ca3a [core] ChatWoot - Pix fixes 2025-10-12 18:45:55 +07:00
devlikepro 8235e5a111 [core] ChatWoot - i18n - pt enable timeZone Sao Paulo 2025-10-12 18:45:55 +07:00
devlikepro 55499a6144 [core] ChatWoot - i18n - datetime 2025-10-12 18:45:54 +07:00
devlikepro 9dbc4a6085 [core] ChatWoot - i18n - poll, event, pix 2025-10-12 18:45:54 +07:00
devlikepro f7c6ce847c [core] ChatWoot - i18n - poll no empty line 2025-10-12 18:45:53 +07:00
devlikepro 84eed00415 [core] ChatWoot - i18n - location url (not URL) 2025-10-12 18:45:53 +07:00
devlikepro 51aa0e3154 [core] ChatWoot - i18n - datetime 2025-10-12 18:45:52 +07:00
devlikepro 77893a0eb0 [core] ChatWoot - i18n - locale base 2025-10-12 18:45:52 +07:00
devlikepro 4a866d8a08 [core] ChatWoot - PIX
(cherry picked from bergpinheiro/waha@696de94859)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-10-12 18:45:52 +07:00
devlikepro f912353a5c [core] ChatWoot - Event Message
(cherry picked from bergpinheiro/waha@696de94859)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-10-12 18:45:51 +07:00
devlikepro a6a1d8dc28 [core] ChatWoot - Poll message
(cherry picked from bergpinheiro/waha@696de94859)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-10-12 18:45:50 +07:00
devlikepro 150e73aa74 [core] ChatWoot - adjust location message
(cherry picked from bergpinheiro/waha@696de94859)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-10-12 18:45:49 +07:00
devlikepro a9300b3134 [core] ChatWoot add URL test 2025-10-12 18:45:48 +07:00
devlikepro bbe3859085 [core] ChatWoot - Facebook Ads Message Template
closes #1461

(cherry picked from bergpinheiro/waha@5863590208)

Thanks @bergpinheiro
Co-authored-by: bergpinheiro <berg.pinheiro2009@gmail.com>
2025-10-12 18:45:48 +07:00
devlikepro b420548dd9 [core] export fetchBuffer 2025-10-12 18:45:47 +07:00
devlikepro 741483eec7 [core] ChatWoot - remove unused getAttachments in message.reaction 2025-10-12 18:45:47 +07:00
devlikepro 2acf7e4e17 [core] ChatWoot - add name for location message 2025-10-12 18:45:46 +07:00
devlikepro 9bca6d904d [core] ChatWoot - Refactor message.any 2025-10-12 18:45:45 +07:00
devlikepro 857c586df6 [core] AGENTS.md 2025-10-12 18:45:45 +07:00
devlikepro df8cb7cecb [core] NOWEB - fix ssl issue
fix #1474
2025-10-12 18:45:45 +07:00
devlikepro 9d0050adb8 [core] NOWEB - filter PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE
fix #1459
2025-10-12 18:45:44 +07:00
devlikepro df927ab04a [core] Remove + at the start
fix #1475
2025-10-12 18:45:43 +07:00
devlikepro 02e5934e8f [core] Up NOWEB 2025-10-10 - fixes 2025-10-12 18:45:42 +07:00
devlikepro 825a4152db [core] Up NOWEB 2025-10-10 2025-10-12 18:45:41 +07:00
devlikepro 81b10099af [core] Up WEBJS
fix #1469
fix #1468
2025-10-12 18:45:41 +07:00
devlikepro 3be5f328b0 [core] Up GOWS 2025-10-12 18:45:41 +07:00
devlikepro 5caa3b8b81 [core] do not rename title 2025-10-10 10:07:02 +07:00
devlikepro 8231bf7d8e [core] GitHub - en/pt by default 2025-10-06 18:30:15 +07:00
devlikepro 22d94808fb [core] Fix lint
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-04 17:12:34 +07:00
devlikepro 57daefa680 [core] 2025.9.8 2025-10-04 17:00:06 +07:00
devlikepro 5cf8704973 [core] NOWEB - no await in start
fix #1397
2025-10-04 17:00:05 +07:00
devlikepro 28da9c34bb [core] Fix NOWEB Apps on start
fix #1397
2025-10-04 17:00:05 +07:00
devlikepro 51ba5e3266 [core] Up GOWS
fix #1293
fix #1412
fix #1406
2025-10-04 17:00:05 +07:00
devlikepro f696794ee1 [core] Up WEBJS
fix #1276
fix #1407
2025-10-04 17:00:04 +07:00
devlikepro c6eca9510b [core] NOWEB - 2025-10-03 2025-10-04 17:00:04 +07:00
devlikepro 9a2fd2fccd [core] make up* 2025-10-04 17:00:04 +07:00
devlikepro c0a2d02f0d [core] NOWEB - send poll to newsletters
fix #1270
fix #1415
2025-10-04 17:00:04 +07:00
devlikepro 6493d16fd2 [core] NOWEB - try to get message just by id for poll
fix #1396
2025-10-04 17:00:04 +07:00
devlikeapro 9f96c678dc [core] support CSAT messages in webhook processing
fix(chatwoot): support CSAT messages in webhook processing
2025-10-04 16:59:36 +07:00
Berg Pinheiro a81f2ab553 fix(chatwoot): support CSAT messages in webhook processing
- Add support for 'input_csat' content_type in MessageHandler
- Previously CSAT messages were being ignored due to strict content_type check
- CSAT messages now processed as text messages and sent to WhatsApp
- Fixes issue where Chatwoot CSAT surveys were not being delivered to users

Changes:
- Modified content_type validation in message_created.ts to accept both 'text' and 'input_csat'
- CSAT messages will now be sent to WhatsApp with their survey content and links
- Maintains existing behavior for all other message types
2025-10-01 22:23:36 -03:00
devlikepro 8fbf7fa0e5 [core] ChatWoot - brazil phone number
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
fix #1261
2025-09-29 12:10:09 +07:00
devlikepro af0bba313a [core] 2025.9.7 2025-09-29 12:10:09 +07:00
233 changed files with 18973 additions and 2265 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 }}
+51
View File
@@ -0,0 +1,51 @@
name: 'github-translate'
on:
issues:
types: [opened]
issue_comment:
types: [created]
discussion:
types: [created]
discussion_comment:
types: [created]
# pull_request_target:
# types: [opened, edited]
# pull_request_review_comment:
# types: [created, edited]
jobs:
en:
if: github.repository == 'devlikeapro/waha'
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: true
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'
+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
+2
View File
@@ -67,9 +67,11 @@ repos:
exclude: |
(?x)^(
entrypoint.sh|
AGENTS.md|
.pre-commit-config.yaml|
^src/core/engines/webjs/session.webjs.core.ts|
^src/core/engines/webjs/_lodash.js|
^tests/perf/.*|
^scripts/.*|
^scripts/init-waha.js|
)$
+130
View File
@@ -0,0 +1,130 @@
# WAHA Agent Playbook
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
only supports the default session plus minimal media features. Plus extends
core via `src/plus` to add multi-session orchestration, richer media handling,
and external storage integrations.
- Core code must remain free from Plus-only references. A pre-commit hook
rejects the word `plus` inside core files; reuse abstractions exposed through
`@waha/core/**` instead of importing Plus modules from Core.
- Commit subjects are validated: changes that touch `src/plus` require a
`[PLUS] …` prefix and must not include non-Plus files; everything else must
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
unsure.
## Tech Stack Snapshot
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
scripts with Yarn.
- **Framework**: NestJS v11 with dependency injection, modular controllers in
`src/api`, and Pino-based logging via `nestjs-pino`.
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
storage backends (Mongo/Postgres/SQLite).
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
once.
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
before adding bespoke logic.
## Repository Landmarks
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
(Core vs Plus) and configures global interceptors/filters.
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
always thread the session name through request DTOs and guards instead of
hardcoding `default`.
- `src/core/**`: shared abstractions (config services, engine bootstrap,
storage, session management). Core only allows the `default` session and
cleans up storage on boot.
- `src/plus/**`: multi-session orchestration, advanced media services, and
external persistence layers (Mongo, Postgres). Reuse this layer when adding
Plus-only capabilities.
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
services.
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
`domain.action`), helper utilities. Maintain naming consistency when
introducing new events.
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
but keep existing tests working.
## Coding Expectations
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
logic. Lodash ships with the project—prefer its utilities over hand-rolled
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
helpers like `lodash.camelCase` stay consistent across files. When a new
third-party library seems necessary, confirm with the user, especially if the
package looks stale.
- Stick to NestJS patterns: inject dependencies through constructors, expose
provider tokens from modules, and keep controllers free from business logic.
- Logging goes through injected `PinoLogger` or helpers in
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- For configs, prefer runtime configurability over constants. Environment keys
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
`session.config.*` for per-session overrides. If both env and config are
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
## Language & Localization
- Keep identifiers (classes, methods, variables) and code comments in English so
the codebase stays consistent for the global team.
- Route user-visible strings through the existing i18n structure rather than
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
English as the source locale before adding translations.
## ChatWoot Integration Notes
- Avoid introducing synthetic events; map onto existing webhook or engine events
whenever plausible. Always use the official ChatWoot API client located in
`src/apps/chatwoot/client`.
- When adding events, align consumer names with webhook/event identifiers (e.g.,
`message.any`).
## Workflow Checklist
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
isolate changes to `src/plus` and ensure the commit prefix matches.
2. Lean on existing services/managers; extend the appropriate session manager
rather than branching logic inline.
3. After edits run:
- `pre-commit run --all-files`
- `yarn build`
- `yarn test` Use Node 22. Address lint or formatting issues before
proceeding.
4. Do **not** start the application yourself; ask the user to run it if runtime
validation is required.
5. Capture any assumptions or open questions for the user, especially when
touching configs, introducing dependencies, or modifying public APIs.
## Additional Tips
- Concurrency-sensitive sections (session start/stop) rely on `async-lock`. When
adding async flows, reuse `SessionManager.withLock` or existing retry
utilities (`promiseTimeout`, `waitUntil`).
- Media features centralize through `MediaManager` and `MediaStorageFactory`.
When altering media behavior, update both Core and Plus variants where
applicable.
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## 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
+4 -4
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:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-09-21
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-11-21
up-noweb-libsignal:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-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.5.2
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.
+17 -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-09-21",
"@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,11 +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",
@@ -98,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"
},
@@ -113,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",
@@ -128,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",
@@ -138,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": [
@@ -148,6 +153,9 @@
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
+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),
};
+12
View File
@@ -0,0 +1,12 @@
import { ILogger } from '@waha/apps/app_sdk/ILogger';
export function JestLogger(): ILogger {
return {
fatal: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
trace: jest.fn(),
};
}
+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;
}
@@ -44,4 +52,16 @@ export class AppsDisabledService implements IAppsService {
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
async afterSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
async syncSessionApps(
manager: SessionManager,
sessionName: string,
apps?: App[] | null,
): Promise<void> {
throw new AppsIsDisabledError();
}
}
+117 -25
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,7 +195,55 @@ export class AppsEnabledService implements IAppsService {
const apps = await repo.getEnabledBySession(session.name);
for (const app of apps) {
const service = this.getAppService(app);
await service.beforeSessionStart(app, session);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.beforeSessionStart(app, session);
}
}
async afterSessionStart(session: WhatsappSession, store: DataStore) {
const knex = store.getWAHADatabase();
const repo = new AppRepository(knex);
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);
}
}
@@ -153,18 +251,12 @@ export class AppsEnabledService implements IAppsService {
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`);
}
+3 -1
View File
@@ -23,5 +23,7 @@ export interface IAppService {
beforeDeleted(app: App): Promise<void>;
beforeSessionStart(app: App, session: WhatsappSession): Promise<void>;
beforeSessionStart(app: App, session: WhatsappSession): void;
afterSessionStart(app: App, session: WhatsappSession): void;
}
+13 -1
View File
@@ -11,12 +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,17 +80,30 @@ 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:
if (!isCommandsChat) {
await this.chatWootQueueService.addMessageUpdatedJob(data);
} else {
// We handle only "retries" on message_update
// This is the attribute ChatWoot send
// There's NO other way to identify "status: read" updates right now
// There's no "body.status" in message_updated webhook :(
const isRetryNull = body.content_attributes?.external_error === null;
const isRetrySomething = Boolean(
body.content_attributes?.external_error,
);
const isRetry = isRetryNull || isRetrySomething;
if (!isRetry) {
return { success: true };
}
if (isCommandsChat || body.content?.startsWith(CommandPrefix)) {
await this.chatWootQueueService.addCommandsJob(body.event, data);
} else {
await this.chatWootQueueService.addMessageUpdatedJob(data);
}
return { success: true };
default:
+4 -3
View File
@@ -1,7 +1,8 @@
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
import * as NodeCache from 'node-cache';
import { ConversationId, IConversationCache } from './IConversationCache';
import { IConversationCache } from './IConversationCache';
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
const cache: NodeCache = new NodeCache({
stdTTL: 24 * 60 * 60, // 1 day
@@ -24,7 +25,7 @@ class ConversationCache implements IConversationCache {
cache.del(fullKey);
}
get(key: string): ConversationId | null {
get(key: string): ContactIds | null {
const fullKey = this.fullKey(key);
return cache.get(fullKey) || null;
}
@@ -34,7 +35,7 @@ class ConversationCache implements IConversationCache {
return cache.has(fullKey);
}
set(key: string, value: ConversationId): void {
set(key: string, value: ContactIds): void {
const fullKey = this.fullKey(key);
cache.set(fullKey, value);
}
+3 -3
View File
@@ -1,8 +1,8 @@
export type ConversationId = number;
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
export interface IConversationCache {
get(key: string): ConversationId | null;
set(key: string, value: ConversationId): void;
get(key: string): ContactIds | null;
set(key: string, value: ContactIds): void;
has(key: string): boolean;
delete(key: string): void;
clean(): void;
+49 -1
View File
@@ -15,22 +15,34 @@ 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';
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),
@@ -39,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),
@@ -55,6 +75,22 @@ const IMPORTS = lodash.flatten([
name: QueueName.WAHA_MESSAGE_REVOKED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
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),
@@ -93,17 +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;
}
@@ -0,0 +1,26 @@
import axios from 'axios';
export class ChatWootInboxNewAPI {
constructor(
private base: string,
private inboxIdentifier: string,
) {}
async updateLastSeen(sourceId: string, conversationId: number) {
// TODO: Move to the lib (fork?)
// Call the update_last_seen endpoint using a direct HTTP request
// since the SDK doesn't have this method
const path = `/public/api/v1/inboxes/${this.inboxIdentifier}/contacts/${sourceId}/conversations/${conversationId}/update_last_seen`;
const url = new URL(path, this.base).toString();
await axios.post(
url,
{},
{
headers: {
api_access_token: this.inboxIdentifier,
'Content-Type': 'application/json',
},
},
);
}
}
@@ -3,18 +3,22 @@ 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 { ConversationService } from '@waha/apps/chatwoot/client/ConversationService';
import {
ContactIds,
ConversationResult,
ConversationService,
} from '@waha/apps/chatwoot/client/ConversationService';
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
import { InboxContactInfo } from '@waha/apps/chatwoot/contacts/InboxContactInfo';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { CacheForConfig } from '../cache/ConversationCache';
import {
ConversationId,
IConversationCache,
} from '../cache/IConversationCache';
import { IConversationCache } from '../cache/IConversationCache';
export interface ContactInfo {
ChatId(): string;
@@ -40,9 +44,10 @@ export class ContactConversationService {
this.cache = CacheForConfig(config);
}
private async upsertByContactInfo(
private async getByContactInfo(
contactInfo: ContactInfo,
): Promise<ConversationId> {
upsert: boolean,
): Promise<ContactIds | null> {
const chatId = contactInfo.ChatId();
// Check cache for chat id
@@ -50,65 +55,72 @@ 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
this.cache.set(chatId, conversation.id);
return conversation.id;
const ids = {
id: conversation.id,
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 conversationId = await this.upsertByContactInfo(contactInfo);
const ids = await this.getByContactInfo(contactInfo, upsert);
if (!ids) {
return null;
}
const conversation = new Conversation(
this.accountAPI,
this.config.accountId,
conversationId,
ids.id,
ids.sourceId,
);
conversation.onError = (err) => {
if (err instanceof ChatWootAPIError) {
@@ -116,13 +128,33 @@ export class ContactConversationService {
this.cache.delete(chatId);
this.logger.error(`ApiError: ${err.message}`);
this.logger.error(
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${conversationId}`,
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${ids.id}, source.id: ${ids.sourceId}`,
);
}
};
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,
@@ -140,24 +172,31 @@ 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);
}
}
public ResetMismatchedCache(chatIds: Array<string>, value: ConversationId) {
public ResetMismatchedCache(chatIds: Array<string>, contactId: number) {
for (const chatId of chatIds) {
if (!this.cache.has(chatId)) {
continue;
}
const current = this.cache.get(chatId);
if (current !== value) {
this.logger.info(
`Resetting cache for chat id: ${chatId}, value changed from ${current} to ${value}`,
if (current.id !== contactId) {
this.logger.debug(
`Resetting cache for chat id: ${chatId}, value changed from ${current} to ${contactId}`,
);
this.cache.delete(chatId);
}
}
}
public async markConversationAsRead(
conversationId: number,
sourceId: string,
): Promise<void> {
await this.conversationService.markAsRead(conversationId, sourceId);
}
}
@@ -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;
});
}
}
+21 -2
View File
@@ -4,14 +4,19 @@ import { MessageType } from '@waha/apps/chatwoot/client/types';
export class Conversation {
public onError: (e: any) => void;
public overrideIncoming: any = null;
constructor(
private accountAPI: ChatwootClient,
private accountId: number,
public conversationId: number,
public sourceId: string | null = null,
) {}
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,
@@ -28,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);
}
@@ -43,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,6 +6,7 @@ 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 { ChatWootInboxNewAPI } from '@waha/apps/chatwoot/client/ChatWootInboxNewAPI';
export type ConversationResult = Pick<conversation, 'id' | 'account_id'>;
@@ -23,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> {
@@ -38,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;
@@ -49,9 +56,35 @@ 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;
}
async markAsRead(conversationId: number, sourceId: string): Promise<void> {
try {
const inboxNewAPI = new ChatWootInboxNewAPI(
this.config.url,
this.config.inboxIdentifier,
);
await inboxNewAPI.updateLastSeen(sourceId, conversationId);
this.logger.info(
`Marked conversation.id: ${conversationId} as read in inbox: ${this.config.inboxIdentifier} for contact: ${sourceId}`,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
this.logger.error(
`Error marking conversation.id: ${conversationId} as read: ${reason}`,
);
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;
}
+14
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
//
@@ -13,6 +19,10 @@ export enum QueueName {
WAHA_MESSAGE_REACTION = 'chatwoot.waha | message.reaction',
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
//
@@ -26,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,14 +8,14 @@ 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,
MessageMappingService,
} from '@waha/apps/chatwoot/storage';
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/text';
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/messages/to/whatsapp/markdown';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import {
@@ -80,7 +80,10 @@ export class MessageHandler {
async handle(body: any) {
const chatId = await LookupAndCheckChatId(this.session, body);
const message = body;
if (message.content_type != 'text') {
if (
message.content_type != 'text' &&
message.content_type != 'input_csat'
) {
this.logger.info(
`Message content type not supported. Content type: ${message.content_type}`,
);
@@ -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);
}
+125 -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,26 +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.SESSION_STATUS,
WAHAEvents.CALL_RECEIVED,
WAHAEvents.CALL_ACCEPTED,
WAHAEvents.CALL_REJECTED,
];
if (config.conversations.markAsRead) {
events.push(WAHAEvents.MESSAGE_ACK);
}
return events;
}
/**
@@ -96,8 +106,15 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
messageInfo: IMessageInfo,
): Promise<any>;
ShouldProcess(event: any): boolean {
return true;
}
async processJob(job: Job<EventData, any, WAHAEvents>): Promise<any> {
const event: WAHAWebhookMessageRevoked = job.data.event as any;
const event = job.data.event as any;
if (!this.ShouldProcess(event)) {
return;
}
const key = WhatsAppChatIdKey(job.data.app, this.GetChatId(event));
return await this.withMutex(job, key, () =>
this.ProcessAndReportErrors(job),
@@ -169,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;
@@ -188,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,
@@ -204,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);
@@ -216,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);
@@ -246,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),
@@ -272,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})`;
@@ -306,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,
@@ -326,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);
}
}
@@ -0,0 +1,133 @@
import { Processor } from '@nestjs/bullmq';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootWAHABaseConsumer,
IMessageInfo,
} 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/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
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 {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHAMessageAckConsumer');
}
ShouldProcess(event: any): boolean {
return ShouldMarkAsReadInChatWoot(event);
}
GetChatId(event: WAHAWebhookMessageAck): string {
return event.payload.from;
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<void> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookMessageAck;
const session = new WAHASessionAPI(event.session, container.WAHASelf());
const handler = new MessageAckHandler(
container.ContactConversationService(),
container.MessageMappingService(),
container.Logger(),
info,
session,
container.Locale(),
);
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,
private readonly locale: Locale,
) {}
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.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;
if (!sourceId) {
this.logger.warn(
`Contact not found for chat.id: ${payload.from}. Skipping read update for message ${payload.id}`,
);
return;
}
try {
await this.contactConversationService.markConversationAsRead(
conversation.conversationId,
sourceId,
);
this.logger.info(
`Marked conversation ${conversation.conversationId} as read for chat.id: ${payload.from} (message: ${payload.id}, sourceId: ${sourceId})`,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
this.logger.error(
`Error marking conversation ${conversation.conversationId} as read for chat.id: ${payload.from}. Reason: ${reason}`,
);
throw error;
}
}
}
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more