Compare commits

..
140 Commits
Author SHA1 Message Date
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
devlikepro 76307dec12 [core] 2025.9.6
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-09-29 09:09:47 +07:00
devlikepro 0b441054ae [core] Up dashboard 2025-09-29 09:09:46 +07:00
devlikepro 2e8b78aebc [core] ChatWoot fix duplicating messages for transcribe feature
fix #1371
2025-09-29 09:09:45 +07:00
devlikepro d01b221a24 [core] Add ChatWootAppConfig to openapi
fix #1416
2025-09-29 09:09:44 +07:00
devlikepro 61bb111768 [core] ChatWoot - conversation selector
fix #1216
fix #1357
fix #1237
close #1343
close #1213
2025-09-29 09:09:43 +07:00
devlikepro de47d6e445 [core] ChatWoot - list custom attributes once on upsert 2025-09-29 09:09:41 +07:00
devlikepro 59fe4b30f0 [core] ChatWoot - use Service suffix 2025-09-29 09:09:40 +07:00
devlikepro 166dae8e27 [core] ChatWoot - fix brazil numbers
fix #1261
2025-09-29 09:09:39 +07:00
devlikepro 5ce64fd30d [core] ChatWoot - check JID format 2025-09-29 09:09:37 +07:00
devlikepro 1a4513f437 [core] GOWS - do not fail in case of wrong numbers
fix #1173
2025-09-29 09:09:37 +07:00
devlikepro 59be4a780f [core] ChatWoot - check attributes before updating
fix #1173
2025-09-29 09:09:37 +07:00
devlikepro 74001c2b49 [core] ChatWoot - get group name
fix #1245
2025-09-29 09:09:37 +07:00
devlikepro a9ce0fb2d9 [core] ChatWoot - fix /jobs auth
fix #1398
2025-09-29 09:09:36 +07:00
devlikepro 4c3fd1c214 [core] ChatWoot - do not send STOPPED if server restarts 2025-09-29 09:09:34 +07:00
devlikepro a9459fe746 [core] ChatWoot - logging 2025-09-29 09:09:32 +07:00
devlikepro 5a3228f05b [core] ChatWoot - safe get avatar from WhatsApp 2025-09-29 09:09:31 +07:00
devlikepro befc238db9 [core] ChatWoot - fix 500 Error - use Account API to fetch contact conversations list
fix #1389
2025-09-29 09:09:31 +07:00
devlikepro ae75a1f8d4 [core] ChatWoot - use JobLink for success 2025-09-29 09:09:30 +07:00
devlikepro d1c50bfd73 [core] ChatWoot - add WAHA_PUBLIC_URL 2025-09-29 09:09:30 +07:00
devlikepro 6f540366f4 [core] ChatWoot - add RACK_TIMEOUT_SERVICE_TIMEOUT 60s
mention #1389
2025-09-29 09:09:29 +07:00
devlikepro 151fc760fe [core] ChatWoot log additional errors 2025-09-29 09:09:29 +07:00
devlikepro be4391a4b6 [core] ChatWoot fix reply if not found 2025-09-29 09:09:29 +07:00
194 changed files with 16190 additions and 2169 deletions

No files matched your search

+17 -13
View File
@@ -3,31 +3,35 @@
# 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
# Server configuration (if you need to customize hostname/port)
# WHATSAPP_API_SCHEMA=http
# 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
# ===================
# ===== 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
@@ -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|
)$
+124
View File
@@ -0,0 +1,124 @@
# 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.
Following this playbook keeps contributions aligned with WAHA’s structure,
automation hooks, and release process.
+5
View File
@@ -195,6 +195,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
+3 -3
View File
@@ -35,13 +35,13 @@ for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
up-noweb:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-09-21
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-10-19
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-09-10
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
+5
View File
@@ -1,6 +1,10 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Increase rack default timeout (30s) to avoid timeout errors
# Workaround for https://github.com/devlikeapro/waha/issues/1389
RACK_TIMEOUT_SERVICE_TIMEOUT=60
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
@@ -216,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
+15 -14
View File
@@ -3,31 +3,32 @@
# 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 =====
# ==================
# Base URL for the API (used for webhooks, file URLs, etc.)
WAHA_BASE_URL=http://waha:3000
WAHA_PUBLIC_URL=http://localhost:3000
# Server configuration (if you need to customize hostname/port)
# WHATSAPP_API_SCHEMA=http
# 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.
+11 -5
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-10-19",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
@@ -65,6 +65,7 @@
"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,9 +101,11 @@
"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",
"undici": "^7.16.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-09-10",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
@@ -113,7 +118,6 @@
"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": {
@@ -138,8 +142,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 +151,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'));
+4
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,8 @@ class ChatsController {
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
) {
query.sortBy = query.sortBy || MessageSortField.TIMESTAMP;
query.sortOrder = query.sortOrder || SortOrder.DESC;
filter = transformAck(filter);
return session.getChatMessages(chatId, query, filter);
}
+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
+3 -3
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;
}
@@ -32,8 +32,8 @@ export class AxiosLogging {
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}`);
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
this.logger.trace(`${methodStr} ${status}:OK ${url} ${responseData}`);
} else {
this.logger.warn(`${methodStr} ${status} ${url} ${responseData}`);
}
+39 -5
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,10 +51,44 @@ export function HasBeenRetried(job: Job): boolean {
return attemptsMade > 1;
}
let base =
process.env.WAHA_PUBLIC_URL ||
process.env.WAHA_BASE_URL ||
'http://localhost:3000';
// cut / at the end
base = base.replace(/\/+$/, '');
export function JobLink(job: Job): { text: string; url: string } {
const text = `${job.queueName} => ${job.id}`;
const url = `http://localhost:3000/jobs/queue/${encodeURIComponent(
job.queueName,
)}/${job.id}`;
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;
}
+5 -4
View File
@@ -2,14 +2,15 @@ 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) {
// Generate a random uuid4 username /password to prevent access
// Generate a random uuid4 username/password to effectively disable access
username = 'admin';
password = crypto.randomUUID();
}
@@ -18,7 +19,7 @@ export function BullAuthMiddleware() {
return basicAuth({
challenge: true,
users: {
[username]: password,
[String(username)]: String(password),
},
});
}
+2 -1
View File
@@ -7,7 +7,7 @@ import {
IsString,
ValidateNested,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
export type AllowedAppConfig = ChatWootAppConfig;
@@ -15,6 +15,7 @@ export enum AppName {
chatwoot = 'chatwoot',
}
@ApiExtraModels(ChatWootAppConfig)
export class App<T extends AllowedAppConfig = any> {
@IsString()
id: string;
+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(),
};
}
@@ -44,4 +44,8 @@ export class AppsDisabledService implements IAppsService {
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
async afterSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
}
@@ -145,7 +145,17 @@ 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);
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);
service.afterSessionStart(app, session);
}
}
+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;
}
@@ -17,6 +17,8 @@ export interface IAppsService {
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
afterSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
migrate(knex: Knex): Promise<void>;
}
+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);
}
}
+3 -2
View File
@@ -13,6 +13,7 @@ import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.loca
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { parseBool } from '@waha/helpers';
import { RMutexModule } from '@waha/modules/rmutex';
import { Auth } from '@waha/core/auth/config';
const IMPORTS = [
BullModule.forRoot({
@@ -93,11 +94,11 @@ const AppsDisabled = {
};
function checkApiKey() {
const key = process.env.WHATSAPP_API_KEY || process.env.WAHA_API_KEY;
const key = Auth.key.value;
if (!key) {
return;
}
const plain = process.env.WAHA_API_KEY_PLAIN;
const plain = Auth.keyplain.value;
if (!plain) {
throw Error(
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
-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,21 +80,35 @@ 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:
// Ignore other events
await this.chatWootQueueService.addJobToQueue(body.event, data);
return { success: true };
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
import { public_conversation } from '@figuro/chatwoot-sdk';
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
import * as NodeCache from 'node-cache';
import { IConversationCache } from './IConversationCache';
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
const cache: NodeCache = new NodeCache({
stdTTL: 24 * 60 * 60, // 1 day
@@ -16,7 +16,7 @@ export function CacheForConfig(config: ChatWootAPIConfig): ConversationCache {
class ConversationCache implements IConversationCache {
constructor(private prefix: string) {}
fullKey(key: string) {
private fullKey(key: string) {
return `${this.prefix}.${key}`;
}
@@ -25,7 +25,7 @@ class ConversationCache implements IConversationCache {
cache.del(fullKey);
}
get(key: string): public_conversation | null {
get(key: string): ContactIds | null {
const fullKey = this.fullKey(key);
return cache.get(fullKey) || null;
}
@@ -35,7 +35,7 @@ class ConversationCache implements IConversationCache {
return cache.has(fullKey);
}
set(key: string, value: public_conversation): void {
set(key: string, value: ContactIds): void {
const fullKey = this.fullKey(key);
cache.set(fullKey, value);
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { public_conversation } from '@figuro/chatwoot-sdk';
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
export interface IConversationCache {
get(key: string): public_conversation | null;
set(key: string, value: public_conversation): void;
get(key: string): ContactIds | null;
set(key: string, value: ContactIds): void;
has(key: string): boolean;
delete(key: string): void;
clean(): void;
+44 -1
View File
@@ -15,20 +15,31 @@ 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 { 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),
@@ -37,6 +48,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),
@@ -53,6 +72,10 @@ 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_SESSION_STATUS,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -65,6 +88,14 @@ const IMPORTS = lodash.flatten([
name: QueueName.INBOX_MESSAGE_UPDATED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.INBOX_CONVERSATION_CREATED,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.INBOX_CONVERSATION_STATUS_CHANGED,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.INBOX_MESSAGE_DELETED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -79,18 +110,30 @@ const PROVIDERS = [
ChatWootInboxMessageCreatedConsumer,
ChatWootInboxMessageUpdatedConsumer,
ChatWootInboxMessageDeletedConsumer,
// Conversation events
ChatWootConversationCreatedConsumer,
ChatWootConversationStatusChangedConsumer,
ChatWootInboxCommandsConsumer,
// Tasks
TaskContactsPullConsumer,
TaskMessagesPullConsumer,
// WAHA
WAHASessionStatusConsumer,
WAHAMessageAnyConsumer,
WAHAMessageReactionConsumer,
WAHAMessageEditedConsumer,
WAHAMessageRevokedConsumer,
WAHAMessageAckConsumer,
// 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;
}
+52
View File
@@ -0,0 +1,52 @@
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';
function repr(name: string): string {
// No chatwoot in the name always (it start with chatwoot)
name = name.replace('chatwoot.', '');
// No waha in the name
name = name.replace('waha |', 'whatsapp |');
return name;
}
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 = repr(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',
},
},
);
}
}
@@ -1,12 +1,17 @@
import ChatwootClient, {
ApiError,
ApiError as ChatWootAPIError,
public_contact_create_update_payload,
public_conversation,
} from '@figuro/chatwoot-sdk';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { ContactAPI } from '@waha/apps/chatwoot/client/ContactAPI';
import {
AvatarUpdateMode,
ContactService,
} from '@waha/apps/chatwoot/client/ContactService';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { ConversationAPI } from '@waha/apps/chatwoot/client/ConversationAPI';
import {
ContactIds,
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';
@@ -29,8 +34,8 @@ export class ContactConversationService {
constructor(
private config: ChatWootAPIConfig,
private contactAPI: ContactAPI,
private conversationAPI: ConversationAPI,
private contactService: ContactService,
private conversationService: ConversationService,
private accountAPI: ChatwootClient,
private logger: ILogger,
private l: Locale,
@@ -38,9 +43,9 @@ export class ContactConversationService {
this.cache = CacheForConfig(config);
}
private async upsertByContactInfo(
public async upsertByContactInfo(
contactInfo: ContactInfo,
): Promise<public_conversation> {
): Promise<ContactIds> {
const chatId = contactInfo.ChatId();
// Check cache for chat id
@@ -48,64 +53,65 @@ export class ContactConversationService {
return this.cache.get(chatId);
}
//
// Find or create contact
//
let contact = await this.contactAPI.searchByAnyID(chatId);
if (!contact) {
const request = await contactInfo.PublicContactCreate();
contact = await this.contactAPI.create(chatId, request);
}
let [cwContact, created] =
await this.contactService.findOrCreateContact(contactInfo);
// Update custom attributes - always
const attributes = await contactInfo.Attributes();
this.logger.info(
`Updating 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.contactAPI.updateCustomAttributes(contact.data, attributes);
// Update Avatar if nothing, but keep the original one if any
if (!contact.data.thumbnail) {
const avatarUrl = await contactInfo.AvatarUrl();
if (avatarUrl) {
this.contactAPI.updateAvatarUrlSafe(contact.data.id, avatarUrl);
}
}
this.logger.info(
`Using contact for chat.id: ${chatId}, contact.id: ${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.conversationAPI.upsert(contact.sourceId);
this.logger.info(
`Using conversation for chat.id: ${chatId}, conversation.id: ${conversation.id}, contact.id: ${contact.sourceId}`,
const conversation = await this.conversationService.upsert({
id: cwContact.data.id,
sourceId: cwContact.sourceId,
});
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);
return conversation;
const ids = {
id: conversation.id,
sourceId: cwContact.sourceId,
};
this.cache.set(chatId, ids);
return ids;
}
public async ConversationByContact(
contactInfo: ContactInfo,
): Promise<Conversation> {
const chatId = contactInfo.ChatId();
const publicConversation = await this.upsertByContactInfo(contactInfo);
const ids = await this.upsertByContactInfo(contactInfo);
const conversation = new Conversation(
this.accountAPI,
this.config.accountId,
publicConversation.id,
ids.id,
ids.sourceId,
);
conversation.onError = (err) => {
if (err instanceof ApiError) {
if (err instanceof ChatWootAPIError) {
// invalidate cache
this.cache.delete(chatId);
this.logger.error(`ApiError: ${err.message}`);
this.logger.error(
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${publicConversation.id}`,
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${ids.id}, source.id: ${ids.sourceId}`,
);
}
};
@@ -127,4 +133,33 @@ export class ContactConversationService {
public async InboxNotifications() {
return this.ConversationByContact(new InboxContactInfo(this.l));
}
public ResetCache(chatIds: Array<string>) {
this.logger.debug(`Resetting cache chat ids: ${chatIds.join(', ')}`);
for (const chatId of chatIds) {
this.cache.delete(chatId);
}
}
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.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);
}
}
@@ -13,13 +13,20 @@ import { isJidCusFormat } from '@waha/utils/wa';
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 class ContactAPI {
export enum AvatarUpdateMode {
IF_MISSING,
ALWAYS,
}
export class ContactService {
constructor(
private config: ChatWootAPIConfig,
private accountAPI: ChatwootClient,
@@ -27,6 +34,20 @@ export class ContactAPI {
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[] = [
{
@@ -64,8 +85,8 @@ export class ContactAPI {
if (isJidCusFormat(chatId)) {
// Search by phone
let phone_number = chatId.split('@')[0];
phone_number = phone_number.replace('+', '');
const phoneNumberE164 = E164Parser.fromJid(chatId);
const phone_number = phoneNumberE164.replace('+', '');
payload[payload.length - 1].query_operator = 'OR';
payload.push({
attribute_key: 'phone_number',
@@ -96,18 +117,22 @@ export class ContactAPI {
};
}
public updateCustomAttributes(
public async upsertCustomAttributes(
contact: generic_id & contact,
attributes: any,
) {
): Promise<boolean> {
if (lodash.isEqual(attributes, contact.custom_attributes)) {
return false;
}
const update: contact_update = {
custom_attributes: { ...contact.custom_attributes, ...attributes },
};
return this.accountAPI.contacts.update({
await this.accountAPI.contacts.update({
id: contact.id,
accountId: this.config.accountId,
data: update,
});
return true;
}
public async create(
@@ -131,8 +156,32 @@ export class ContactAPI {
};
}
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,
@@ -140,11 +189,15 @@ export class ContactAPI {
avatar_url: avatarUrl,
},
})
.then(() => {
return true;
})
.catch((e) => {
this.logger.warn(
'Error updating avatar_url for contact.id: ' + contactId,
`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,
};
}
}
@@ -1,52 +0,0 @@
import { public_conversation } from '@figuro/chatwoot-sdk';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import {
ChatWootAPIConfig,
ChatWootInboxAPI,
} from '@waha/apps/chatwoot/client/interfaces';
export class ConversationAPI {
constructor(
private config: ChatWootAPIConfig,
private inboxAPI: ChatWootInboxAPI,
private logger: ILogger,
) {}
private async find(
contactIdentifier: string,
): Promise<public_conversation | null> {
const request = {
inboxIdentifier: this.config.inboxIdentifier,
contactIdentifier: contactIdentifier,
};
const conversations = await this.inboxAPI.conversations.list(request);
if (conversations.length != 0) {
return conversations[0];
}
return null;
}
private async create(
contactIdentifier: string,
): Promise<public_conversation> {
const conversation = await this.inboxAPI.conversations.create({
inboxIdentifier: this.config.inboxIdentifier,
contactIdentifier: contactIdentifier,
});
this.logger.info(
`Created conversation.id: ${conversation.id} for contact.id: ${contactIdentifier}`,
);
return conversation;
}
async upsert(contactIdentifier: string): Promise<public_conversation> {
let conversation = await this.find(contactIdentifier);
if (!conversation) {
conversation = await this.create(contactIdentifier);
}
this.logger.info(
`Using conversation.id: ${conversation.id} for contact.id: ${contactIdentifier}`,
);
return conversation;
}
}
@@ -0,0 +1,88 @@
import ChatwootClient, { contact_conversations } from '@figuro/chatwoot-sdk';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import {
ChatWootAPIConfig,
ChatWootInboxAPI,
} from '@waha/apps/chatwoot/client/interfaces';
import type { conversation } from '@figuro/chatwoot-sdk/dist/models/conversation';
import { ConversationSelector } from '@waha/apps/chatwoot/services/ConversationSelector';
import axios from 'axios';
import { ChatWootInboxNewAPI } from '@waha/apps/chatwoot/client/ChatWootInboxNewAPI';
export type ConversationResult = Pick<conversation, 'id' | 'account_id'>;
export interface ContactIds {
id: number;
sourceId: string;
}
export class ConversationService {
constructor(
private config: ChatWootAPIConfig,
private accountAPI: ChatwootClient,
private inboxAPI: ChatWootInboxAPI,
private selector: ConversationSelector,
private logger: ILogger,
) {}
private 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);
}
private async create(contact: ContactIds): Promise<ConversationResult> {
const conversation = await this.inboxAPI.conversations.create({
inboxIdentifier: this.config.inboxIdentifier,
contactIdentifier: contact.sourceId,
});
this.logger.debug(
`Created conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
);
return conversation;
}
async upsert(contact: ContactIds): Promise<ConversationResult> {
let conversation = await this.find(contact);
if (!conversation) {
conversation = await this.create(contact);
}
this.logger.debug(
`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',
},
});
}
}
@@ -1,53 +0,0 @@
import ChatwootClient from '@figuro/chatwoot-sdk';
import { ChatWootAccountAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
import {
CustomAttributeModel,
CustomAttributeType,
} from '@waha/apps/chatwoot/client/types';
export interface CustomAttribute {
key: string;
name: string;
type: CustomAttributeType;
description: string;
model: CustomAttributeModel;
}
export class CustomAttributesAPI {
constructor(
private config: ChatWootAccountAPIConfig,
private accountAPI: ChatwootClient,
) {}
async upsert(attribute: CustomAttribute): Promise<void> {
const attributes = await this.accountAPI.customAttributes.list({
accountId: this.config.accountId,
attributeModel: String(attribute.model) as '0' | '1',
});
const existing = attributes.find((a) => a.attribute_key === attribute.key);
if (existing) {
await this.accountAPI.customAttributes.update({
accountId: this.config.accountId,
id: existing.id,
data: {
attribute_key: attribute.key,
attribute_display_name: attribute.name,
attribute_display_type: attribute.type,
attribute_description: attribute.description,
},
});
} else {
await this.accountAPI.customAttributes.create({
accountId: this.config.accountId,
data: {
attribute_key: attribute.key,
attribute_display_name: attribute.name,
attribute_display_type: attribute.type,
attribute_description: attribute.description,
attribute_model: attribute.model,
},
});
}
}
}
@@ -0,0 +1,68 @@
import ChatwootClient from '@figuro/chatwoot-sdk';
import { ChatWootAccountAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
import {
CustomAttributeModel,
CustomAttributeType,
} from '@waha/apps/chatwoot/client/types';
import type { custom_attribute } from '@figuro/chatwoot-sdk/dist/models/custom_attribute';
export interface CustomAttribute {
key: string;
name: string;
type: CustomAttributeType;
description: string;
model: CustomAttributeModel;
}
export class CustomAttributesService {
constructor(
private config: ChatWootAccountAPIConfig,
private accountAPI: ChatwootClient,
) {}
async upsert(attributes: Array<CustomAttribute>): Promise<void> {
const current: Record<CustomAttributeModel, Array<custom_attribute>> = {
[CustomAttributeModel.CONVERSATION]: [],
[CustomAttributeModel.CONTACT]: [],
};
const models = [
CustomAttributeModel.CONVERSATION,
CustomAttributeModel.CONTACT,
];
for (const model of models) {
current[model] = await this.accountAPI.customAttributes.list({
accountId: this.config.accountId,
attributeModel: String(model) as '0' | '1',
});
}
for (const attribute of attributes) {
const existing = current[attribute.model].find(
(a) => a.attribute_key === attribute.key,
);
if (existing) {
await this.accountAPI.customAttributes.update({
accountId: this.config.accountId,
id: existing.id,
data: {
attribute_key: attribute.key,
attribute_display_name: attribute.name,
attribute_display_type: attribute.type,
attribute_description: attribute.description,
},
});
} else {
await this.accountAPI.customAttributes.create({
accountId: this.config.accountId,
data: {
attribute_key: attribute.key,
attribute_display_name: attribute.name,
attribute_display_type: attribute.type,
attribute_description: attribute.description,
attribute_model: attribute.model,
},
});
}
}
}
}
+18 -1
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';
@@ -15,6 +16,16 @@ export function GetChatID(contact: any): string | null {
return contact?.custom_attributes?.[AttributeKey.WA_CHAT_ID];
}
export function GetAllChatIDs(contact: any): Array<string> {
const attrs = contact?.custom_attributes || {};
const ids = [
attrs[AttributeKey.WA_CHAT_ID],
attrs[AttributeKey.WA_JID],
attrs[AttributeKey.WA_LID],
];
return lodash.uniq(ids.filter(Boolean));
}
export function FindChatID(contact: any): string | null {
if (GetJID(contact)) {
return GetJID(contact);
@@ -45,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;
}
+7
View File
@@ -51,3 +51,10 @@ export enum CustomAttributeModel {
CONVERSATION = 0,
CONTACT = 1,
}
export enum ConversationStatus {
OPEN = 'open',
PENDING = 'pending',
SNOOZED = 'snoozed',
RESOLVED = 'resolved',
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { CustomAttribute } from '@waha/apps/chatwoot/client/CustomAttributesAPI';
import { CustomAttribute } from '@waha/apps/chatwoot/client/CustomAttributesService';
import { CustomAttributeModel, CustomAttributeType } from './client/types';
+13
View File
@@ -5,6 +5,12 @@ export enum QueueName {
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
//
// Task
//
TASK_CONTACTS_PULL = 'chatwoot.task | contacts.pull',
TASK_MESSAGES_PULL = 'chatwoot.task | messages.pull',
//
// WAHA Events
//
@@ -13,14 +19,21 @@ 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',
//
// ChatWoot Events - Real
//
INBOX_MESSAGE_CREATED = 'chatwoot.inbox | message_created',
INBOX_MESSAGE_UPDATED = 'chatwoot.inbox | message_updated',
INBOX_CONVERSATION_CREATED = 'chatwoot.inbox | conversation_created',
INBOX_CONVERSATION_STATUS_CHANGED = 'chatwoot.inbox | conversation_status_changed',
//
// ChatWoot Events - Artificial
//
INBOX_MESSAGE_DELETED = 'chatwoot.inbox | message_deleted',
INBOX_COMMANDS = 'chatwoot.inbox | commands',
}
export enum FlowProducerName {
MESSAGES_PULL_FLOW = 'messages.pull.flow',
}
+18 -8
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
@@ -58,13 +59,20 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
job: Job,
): Promise<any>;
protected GetConversationID(body) {
return body.conversation.id;
}
/**
* Process the job
* This method is called by the queue processor
*/
async processJob(job: Job<InboxData, any, EventName>): Promise<any> {
const body = job.data.body;
const key = ChatWootConversationKey(job.data.app, body.conversation.id);
const key = ChatWootConversationKey(
job.data.app,
this.GetConversationID(body),
);
return await this.withMutex(job, key, () =>
this.ProcessAndReportStatus(job),
);
@@ -83,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
*/
@@ -91,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,
@@ -111,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;
}
}
@@ -0,0 +1,57 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { ChatWootConversationKey } from '@waha/apps/chatwoot/consumers/mutex';
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
import { Job } from 'bullmq';
import { GetAllChatIDs } from '@waha/apps/chatwoot/client/ids';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PinoLogger } from 'nestjs-pino';
import { RMutexService } from '@waha/modules/rmutex';
@Processor(QueueName.INBOX_CONVERSATION_CREATED, {
concurrency: JOB_CONCURRENCY,
})
export class ChatWootConversationCreatedConsumer extends ChatWootInboxMessageConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'ChatWootConversationCreatedConsumer');
}
protected ErrorHeaderKey(): TKey | null {
return null;
}
protected GetConversationID(body) {
return body.id;
}
protected async Process(
container: DIContainer,
body: any,
job: Job,
): Promise<any> {
const handler = new ConversationCreatedHandler(
container.ContactConversationService(),
);
return handler.handle(body);
}
}
class ConversationCreatedHandler {
constructor(private service: ContactConversationService) {}
async handle(body: any) {
const ids = GetAllChatIDs(body?.meta?.sender);
if (!ids || ids.length === 0) {
return;
}
this.service.ResetMismatchedCache(ids, body.id);
}
}
@@ -0,0 +1,62 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
import { Job } from 'bullmq';
import { DIContainer } from '../../di/DIContainer';
import { TKey } from '../../i18n/templates';
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { AttributeKey } from '@waha/apps/chatwoot/const';
import { ConversationSelector } from '@waha/apps/chatwoot/services/ConversationSelector';
import { GetAllChatIDs } from '@waha/apps/chatwoot/client/ids';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PinoLogger } from 'nestjs-pino';
import { RMutexService } from '@waha/modules/rmutex';
@Processor(QueueName.INBOX_CONVERSATION_STATUS_CHANGED, {
concurrency: JOB_CONCURRENCY,
})
export class ChatWootConversationStatusChangedConsumer extends ChatWootInboxMessageConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'ChatWootConversationStatusChangedConsumer');
}
protected ErrorHeaderKey(): TKey | null {
return null;
}
protected GetConversationID(body) {
return body.id;
}
protected async Process(
container: DIContainer,
body: any,
job: Job,
): Promise<any> {
const handler = new ConversationStatusChangedHandler(
container.ContactConversationService(),
container.ConversationSelector(),
);
return await handler.handle(body);
}
}
class ConversationStatusChangedHandler {
constructor(
private service: ContactConversationService,
private selector: ConversationSelector,
) {}
async handle(body) {
if (!this.selector.hasStatusFilter()) {
return;
}
const ids = GetAllChatIDs(body.meta?.sender);
this.service.ResetCache(ids);
}
}
@@ -8,13 +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 {
@@ -79,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}`,
);
@@ -88,29 +92,48 @@ export class MessageHandler {
const content = MarkdownToWhatsApp(message.content);
const results = [];
let part = 1;
let part = 0; // Start from 0 and increment before each send/check
const replyTo = await this.getReplyTo(message).catch((err) => {
this.logger.error(`Error getting reply to message ID: ${err}`);
this.logger.error(
`ChatWoot => WhatsApp: error getting reply to message ID: ${err}`,
);
return undefined;
});
// Send text
// Send text (Part 1 if present)
const attachments = message.attachments || [];
if (content && attachments.length !== 1) {
const textTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_TEXT);
const text = textTemplate.render({
content: content,
chatwoot: body,
});
const msg = await this.sendTextMessage(chatId, text, replyTo);
results.push(msg);
part = await this.saveMapping(message, msg, part);
this.logger.info(`Text message sent: ${msg.id}`);
const sendText = content && attachments.length !== 1;
if (sendText) {
part += 1; // Text is the first possible part
const exists = await this.getMapping(message, part);
if (exists) {
this.logger.warn(
`Skip part ${part}: mapping exists for Chatwoot message ${message.id}`,
);
} else {
const textTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_TEXT);
const text = textTemplate.render({
content: content,
chatwoot: body,
});
const msg = await this.sendTextMessage(chatId, text, replyTo);
results.push(msg);
await this.saveMapping(message, msg, part);
this.logger.info(`Text message sent: ${msg.id}`);
}
}
// Send files
const captionTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_MEDIA_CAPTION);
for (const file of attachments) {
part += 1; // Increment before each attachment send/check
const exists = await this.getMapping(message, part);
if (exists) {
this.logger.warn(
`Skip part ${part}: mapping exists for Chatwoot message ${message.id}`,
);
continue;
}
const caption = captionTemplate.render({
content: content,
chatwoot: body,
@@ -121,7 +144,7 @@ export class MessageHandler {
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
);
results.push(msg);
part = await this.saveMapping(message, msg, part);
await this.saveMapping(message, msg, part);
}
return results;
}
@@ -130,15 +153,27 @@ export class MessageHandler {
chatwootMessage: any,
whatsappMessage: any,
part: number,
): Promise<number> {
): Promise<void> {
const chatwoot: Omit<ChatwootMessage, 'id'> = {
timestamp: new Date(chatwootMessage.created_at),
conversation_id: chatwootMessage.conversation.id,
message_id: chatwootMessage.id,
};
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
await this.mappingService.map(chatwoot, whatsapp, 1);
return part + 1;
await this.mappingService.map(chatwoot, whatsapp, part);
}
private async getMapping(
message: any,
part: number,
): Promise<MessageMapping | null> {
return this.mappingService.getMappingByChatwootCombinedKeyAndPart(
{
conversation_id: message.conversation.id,
message_id: message.id,
},
part,
);
}
async getReplyTo(message): Promise<string | undefined> {
@@ -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);
}
+101 -35
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,
@@ -25,16 +25,15 @@ 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 { 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';
export function ListenEventsForChatWoot() {
return [
@@ -42,6 +41,7 @@ export function ListenEventsForChatWoot() {
WAHAEvents.MESSAGE_REACTION,
WAHAEvents.MESSAGE_EDITED,
WAHAEvents.MESSAGE_REVOKED,
WAHAEvents.MESSAGE_ACK,
WAHAEvents.SESSION_STATUS,
];
}
@@ -96,8 +96,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 +176,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;
@@ -204,10 +211,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 +278,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);
@@ -272,22 +331,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})`;
@@ -299,12 +362,15 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
}
const replyTo = await this.getReplyToChatWootMessageID(payload).catch(
(err) => {
this.logger.error(`Error getting reply to message ID: ${err}`);
this.logger.error(
`WhatsApp => ChatWoot - error getting reply to message ID: ${err}`,
);
return undefined;
},
);
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
content = this.finalizeContent(content, payload);
return {
content: content,
message_type: type,
@@ -324,9 +390,9 @@ 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;
return chatwoot?.message_id;
}
}
@@ -0,0 +1,125 @@
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 { toCusFormat } from '@waha/core/utils/jids';
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.ConversationByContact(contactInfo);
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;
}
}
}
@@ -0,0 +1,466 @@
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
interface TestParam {
name: string;
expected: boolean;
GOWS: any;
NOWEB: any;
WEBJS: any;
}
/**
* Me - 444444444444 (lid - 144444444444444)
* Participant - 11111111111 (lid - 011111111111111)
* Group - 222222222222222222
*/
const ShouldMarkAsReadCases: TestParam[] = [
{
name: 'Participant read My message (READ ack)',
expected: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'true_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '11111111111@s.whatsapp.net',
IsFromMe: false,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: 'read',
MessageSender: '',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: true,
ack: 3,
ackName: 'READ',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAA',
from: '444444444444@c.us',
fromMe: true,
source: 'app',
to: '11111111111@c.us',
body: 'Nn',
hasMedia: false,
media: null,
ack: 3,
ackName: 'READ',
vCards: [],
_data: {
id: {
fromMe: true,
remote: '11111111111@c.us',
id: 'AAA',
_serialized: 'true_11111111111@c.us_AAA',
},
viewed: false,
from: '444444444444@c.us',
to: '11111111111@c.us',
author: '444444444444@c.us',
ack: 3,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
{
name: 'Participant received My message (DEVICE ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'true_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: true,
ack: 2,
ackName: 'DEVICE',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '11111111111:3@s.whatsapp.net',
IsFromMe: false,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: '',
MessageSender: '',
},
},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: true,
ack: 2,
ackName: 'DEVICE',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_11111111111@c.us_AAA',
from: '444444444444@c.us',
fromMe: true,
source: 'app',
to: '11111111111@c.us',
body: 'Nn',
hasMedia: false,
media: null,
ack: 2,
ackName: 'DEVICE',
vCards: [],
_data: {
id: {
fromMe: true,
remote: '11111111111@c.us',
id: 'AAA',
_serialized: 'true_11111111111@c.us_AAA',
},
from: '444444444444@c.us',
to: '11111111111@c.us',
author: '444444444444@c.us',
ack: 2,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
{
name: 'Me read Participant message on another device (READ ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack' as WAHAEvents,
payload: {
id: 'false_11111111111@c.us_3333333333333333333333',
from: '11111111111@c.us',
to: null,
participant: null,
fromMe: false,
ack: 3,
ackName: 'READ',
_data: {
Chat: '11111111111@s.whatsapp.net',
Sender: '444444444444@s.whatsapp.net',
IsFromMe: true,
IsGroup: false,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['3333333333333333333333'],
Type: 'read',
MessageSender: '11111111111@s.whatsapp.net',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
engine: 'GOWS' as WAHAEngine,
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: '11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
from: '11111111111@c.us',
fromMe: false,
ack: 3,
ackName: 'READ',
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: null,
},
{
name: 'Participant read My Group Message (READ ack)',
expected: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '011111111111111@lid',
participant: '011111111111111@lid',
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
Chat: '222222222222222222@g.us',
Sender: '011111111111111@lid',
IsFromMe: false,
IsGroup: true,
AddressingMode: '',
SenderAlt: '',
RecipientAlt: '',
BroadcastListOwner: '',
BroadcastRecipients: null,
MessageIDs: ['AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
Type: 'read',
MessageSender: '',
},
},
metadata: {},
me: {
id: '444444444444@c.us',
lid: '144444444444444@lid',
jid: '444444444444:42@s.whatsapp.net',
pushName: '',
},
environment: {
version: '2025.10.4',
engine: 'GOWS',
tier: 'PLUS',
browser: null,
},
},
NOWEB: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '222222222222222222@g.us',
participant: '011111111111111@lid',
fromMe: true,
ack: 3,
ackName: 'READ',
_data: {
key: {
remoteJid: '222222222222222222@g.us',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
fromMe: true,
participant: '011111111111111@lid',
},
receipt: {
userJid: '011111111111111@lid',
},
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
lid: '1144444444444444@lid',
},
engine: 'NOWEB',
environment: {
version: '2025.10.4',
engine: 'NOWEB',
tier: 'PLUS',
browser: null,
},
},
WEBJS: {
id: 'evt_00000000000000000000000000',
session: 'default',
event: 'message.ack',
payload: {
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
from: '222222222222222222@g.us',
to: '222222222222222222@g.us',
participant: '011111111111111@lid',
fromMe: false,
ack: 3,
ackName: 'READ',
_data: {
tag: 'receipt',
attrs: {
from: {
$1: {
type: 0,
user: '222222222222222222',
server: 'g.us',
},
},
type: 'read',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
participant: {
$1: {
type: 1,
user: '011111111111111',
device: 0,
domainType: 1,
},
},
},
content: null,
},
},
metadata: {},
me: {
id: '444444444444@c.us',
pushName: '',
},
engine: 'WEBJS',
environment: {
version: '2025.10.4',
engine: 'WEBJS',
tier: 'PLUS',
browser: '/usr/bin/google-chrome-stable',
},
},
},
];
describe('ShouldMarkAsReadInChatWoot', () => {
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
for (const engine of engines) {
describe(engine, () => {
for (const param of ShouldMarkAsReadCases) {
const name = `[${param.expected}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.expected;
testfn(name, () => {
// Test
const result = ShouldMarkAsReadInChatWoot(event);
expect(result).toBe(expected);
});
}
});
}
});
@@ -0,0 +1,32 @@
import type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { isJidCusFormat } from '@waha/utils/wa';
import { WAMessageAck } from '@waha/structures/enums.dto';
import { isLidUser, toCusFormat } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldMarkAsReadInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
// Mark as seen only if it's DM
// Ignore groups and other multiple participants chats
const chatId = EngineHelper.ChatID(event.payload);
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
return false;
}
const payload = event.payload;
// Only READ and PLAYED
const read = payload.ack === WAMessageAck.READ;
const played = payload.ack == WAMessageAck.PLAYED;
if (!read && !played) {
return false;
}
// Only process when OUR message (fromMe: true) was read by recipient
if (!payload.fromMe) {
return false;
}
// Mark ChatWoot conversation as read when recipient reads our message
return true;
}
+78 -152
View File
@@ -1,7 +1,5 @@
import type { proto } from '@adiwajshing/baileys';
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { SendAttachment } from '@waha/apps/chatwoot/client/types';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
@@ -10,8 +8,7 @@ import {
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/text';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
@@ -20,13 +17,22 @@ import { WAMessage } from '@waha/structures/responses.dto';
import { WAHAWebhookMessageAny } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
import * as lodash from 'lodash';
import { parseVCardV3, SimpleVCardInfo } from '@waha/core/vcard';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
import {
FacebookAdMessage,
EventMessage,
PixMessage,
LocationMessage,
MessageToChatWootConverter,
PollMessage,
ListMessage,
ShareContactMessage,
TextMessage,
UnsupportedMessage,
AlbumMessage,
resolveProtoMessage,
} from '@waha/apps/chatwoot/messages/to/chatwoot';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { HasMediaWithNoMediaMessage } from '@waha/apps/chatwoot/messages/to/chatwoot/HasMediaWithNoMediaMessage';
@Processor(QueueName.WAHA_MESSAGE_ANY, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
@@ -39,7 +45,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageAny): string {
return event.payload.from;
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -59,142 +65,89 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
function isEmptyString(content: string) {
if (!content) {
return true;
}
return content == '' || content == '\n';
}
export class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
public shouldLogUnsupported: boolean = false;
class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
protected async getMessage(
payload: WAMessage,
): Promise<ChatWootMessagePartial> {
const protoMessage = this.getProtoMessage(payload);
let msg = await this.getTextMessage(payload, protoMessage);
let converter: MessageToChatWootConverter;
let msg: ChatWootMessagePartial;
const protoMessage = resolveProtoMessage(payload);
// Check for Facebook Ad first - but let it use the normal flow later
converter = new FacebookAdMessage(this.l, this.logger);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
msg = this.getLocationMessage(payload, protoMessage);
converter = new TextMessage(this.l, this.logger, this.waha, this.job);
msg = await converter.convert(payload, null);
if (msg) {
return msg;
}
msg = this.getShareContactMessage(payload, protoMessage);
converter = new LocationMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
return this.getUnsupportedMessage();
}
protected async getTextMessage(
payload: WAMessage,
_: proto.Message | null,
): Promise<ChatWootMessagePartial | null> {
const attachments = await this.getAttachments(payload);
let content = this.l
.key(TKey.WA_TO_CW_MESSAGE)
.render({ payload: payload });
if (isEmptyString(content) && attachments.length == 0) {
return null;
converter = new ShareContactMessage(this.l, this.logger);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
if (isEmptyString(content)) {
content = null;
}
return {
content: WhatsappToMarkdown(content),
attachments: attachments,
private: undefined,
};
}
private getLocationMessage(
payload: WAMessage,
message: proto.Message | null,
): ChatWootMessagePartial | null {
const hasLocation = !lodash.isEmpty(message?.locationMessage);
const hasLiveLocation = !lodash.isEmpty(message?.liveLocationMessage);
if (!hasLocation && !hasLiveLocation) {
return null;
converter = new PollMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
const location = this.l.key(TKey.WA_TO_CW_MESSAGE_LOCATION).r({
payload,
message,
});
if (isEmptyString(location)) {
return null;
}
return {
content: location,
attachments: [],
private: undefined,
};
}
private getShareContactMessage(
_payload: WAMessage,
message: proto.Message | null,
): ChatWootMessagePartial | null {
let vcards: string[] = [];
converter = new ListMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
if (!lodash.isEmpty(message?.contactsArrayMessage?.contacts)) {
vcards = message!.contactsArrayMessage!.contacts.map((c) => c.vcard);
} else if (!lodash.isEmpty(message?.contactMessage?.vcard)) {
vcards = [message!.contactMessage!.vcard];
converter = new EventMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
if (vcards.length === 0) {
return null;
converter = new PixMessage(this.l, this.logger);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
const attachments: SendAttachment[] = vcards.map((v, i) => ({
content: Buffer.from(v, 'utf8').toString('base64'),
encoding: 'base64',
filename: `vcard-${i + 1}.vcf`,
}));
let contacts: SimpleVCardInfo[] = [];
try {
contacts = vcards.map(parseVCardV3);
} catch (err) {
this.logger.error(
`Error parsing some vcards: vcards=${vcards}, err=${err}`,
converter = new AlbumMessage(this.l);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new HasMediaWithNoMediaMessage(this.l, this.job);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new UnsupportedMessage(this.l, this.job);
msg = await converter.convert(payload, protoMessage);
if (this.shouldLogUnsupported) {
this.logger.warn(
`UnsupportedMessage:\n${JSON.stringify(payload, null, 2)}`,
);
}
const msg = this.l.key(TKey.WA_TO_CW_MESSAGE_CONTACTS).r({ contacts });
if (contacts.length === 0 && attachments.length === 0) {
return null;
}
return {
content: msg,
attachments: attachments,
private: undefined,
};
}
private getUnsupportedMessage(): ChatWootMessagePartial {
const unsupported = this.l
.key(TKey.WA_TO_CW_MESSAGE_UNSUPPORTED)
.render({ details: JobLink(this.job) });
return {
content: unsupported,
attachments: [],
private: true,
};
}
private getProtoMessage(payload: WAMessage): proto.Message | null {
// GOWS
if (payload._data.Message) {
return payload._data.Message;
}
// NOWEB
if (payload._data.message) {
return payload._data.message;
}
// WEBJS - not available
return null;
return msg;
}
getReplyToWhatsAppID(payload: WAMessage): string {
@@ -202,37 +155,10 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
if (!replyTo) {
return undefined;
}
if (!replyTo.id) {
return undefined;
}
const key = parseMessageIdSerialized(replyTo.id, true);
return key.id;
}
async getAttachments(payload: WAMessage): Promise<SendAttachment[]> {
//
// WAHA
//
const hasMedia = payload.media?.url;
if (!hasMedia) {
return [];
}
const attachments: SendAttachment[] = [];
const media = payload.media;
this.logger.info(`Downloading media from '${media.url}'...`);
const buffer = await this.waha.fetch(media.url);
const fileContent = buffer.toString('base64');
let filename = media.filename;
if (!filename) {
const extension = mime.extension(media.mimetype);
filename = `no-filename.${extension}`;
}
const attachment: SendAttachment = {
content: fileContent,
filename: filename,
encoding: 'base64',
};
attachments.push(attachment);
this.logger.info(`Downloaded media from '${media.url}' as '${filename}'`);
return attachments;
}
}
@@ -6,9 +6,8 @@ import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/text';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
@@ -19,10 +18,13 @@ import {
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { SendAttachment } from '../../client/types';
import { WAHASessionAPI } from '../../session/WAHASelf';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WAMessage } from '@waha/structures/responses.dto';
import { WAHASessionAPI } from '../../../app_sdk/waha/WAHASelf';
import {
MessageEdited,
MessageToChatWootConverter,
resolveProtoMessage,
} from '@waha/apps/chatwoot/messages/to/chatwoot';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
@Processor(QueueName.WAHA_MESSAGE_EDITED, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
@@ -35,7 +37,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageEdited): string {
return event.payload.from;
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -55,7 +57,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
@@ -63,25 +65,12 @@ class MessageEditedHandler extends MessageBaseHandler<WAMessageEditedBody> {
protected async getMessage(
payload: WAMessageEditedBody,
): Promise<ChatWootMessagePartial> {
const body = payload.body;
const formatted = WhatsappToMarkdown(body);
const content = this.l
.key(TKey.MESSAGE_EDITED_IN_WHATSAPP)
.render({ text: formatted });
return {
content: content,
attachments: [],
private: undefined,
};
const protoMessage = resolveProtoMessage(payload);
const converter: MessageToChatWootConverter = new MessageEdited(this.l);
return converter.convert(payload, protoMessage);
}
getReplyToWhatsAppID(payload: WAMessageEditedBody): string {
return payload.editedMessageId;
}
async getAttachments(
payload: WAMessageEditedBody,
): Promise<SendAttachment[]> {
return [];
}
}
@@ -9,16 +9,17 @@ import {
IMessageInfo,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { WAMessageReaction, WAReaction } from '@waha/structures/responses.dto';
import { WAHAWebhookMessageReaction } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
@Processor(QueueName.WAHA_MESSAGE_REACTION, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
@@ -31,7 +32,13 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageReaction): string {
return event.payload.from;
if (event.environment?.engine == WAHAEngine.WEBJS) {
// chat in "to" field for WEBJS for message.reaction
// probably we need to set it to "from"
// but for backward compatability we do this
return event.payload.to;
}
return EngineHelper.ChatID(event.payload);
}
async Process(
@@ -51,7 +58,7 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
container.Locale(),
container.WAHASelf(),
);
return await handler.handle(event);
return await handler.handle(event.payload);
}
}
@@ -82,8 +89,4 @@ export class MessageReactionHandler extends MessageBaseHandler<WAMessageReaction
const key = parseMessageIdSerialized(messageId, false);
return key.id;
}
async getAttachments(payload: WAMessageReaction): Promise<SendAttachment[]> {
return [];
}
}
@@ -11,8 +11,8 @@ import {
IMessageInfo,
} from '@waha/apps/chatwoot/consumers/waha/base';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf } from '@waha/apps/chatwoot/session/WAHASelf';
import { SessionStatusEmoji } from '@waha/apps/chatwoot/SessionStatusEmoji';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionStatusEmoji } from '@waha/apps/chatwoot/emoji';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
@@ -20,7 +20,6 @@ import { WAHAWebhookSessionStatus } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { sleep } from '@nestjs/terminus/dist/utils';
import { waitUntil } from '@waha/utils/promiseTimeout';
@Processor(QueueName.WAHA_SESSION_STATUS, { concurrency: JOB_CONCURRENCY })
@@ -103,6 +102,20 @@ export class SessionStatusHandler {
});
break;
case WAHASessionStatus.STOPPED:
try {
// Check the session is yet in STOPPED status.
// In case of server restarts,
// the consumer can process STOPPED after STARTING/WORKING
const response = await this.waha.get(data.session);
if (response.status != WAHASessionStatus.STOPPED) {
return;
}
} catch (_) {}
text = this.l.key(TKey.APP_SESSION_STATUS_ERROR).r();
text += '\n\n';
text += this.l.key(TKey.APP_HELP_REMINDER).r();
text += '\n\n';
break;
case WAHASessionStatus.FAILED:
text = this.l.key(TKey.APP_SESSION_STATUS_ERROR).r();
text += '\n\n';
@@ -2,7 +2,7 @@ import { public_contact_create_update_payload as Contact } from '@figuro/chatwoo
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
import { AttributeKey } from '@waha/apps/chatwoot/const';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { Channel } from '@waha/structures/channels.dto';
import { CacheAsync } from '@waha/utils/Cache';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
@@ -12,7 +12,10 @@ import {
isJidNewsletter,
isJidStatusBroadcast,
isLidUser,
isPnUser,
} from '@waha/core/utils/jids';
import { UnknownJIDFormat } from '@waha/apps/chatwoot/errors';
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
/**
* Base WhatsApp contact info class
@@ -66,8 +69,7 @@ class JidContactInfo extends ChatContactInfo {
const contact: any = await this.session.getContact(this.chatId);
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
const phoneNumber = this.chatId.split('@')[0];
const phoneNumberE164 = '+' + phoneNumber;
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
const result: Contact = {
name: name,
@@ -89,6 +91,9 @@ class LidContactInfo extends ChatContactInfo {
@CacheAsync()
async jid() {
const pn = await this.session.findPNByLid(this.chatId);
if (!pn) {
return null;
}
return new JidContactInfo(this.session, pn, this.locale);
}
@@ -97,7 +102,7 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
return await jid.AvatarUrl();
}
return null;
return await this.session.getChatPicture(this.chatId);
}
@CacheAsync()
@@ -107,7 +112,6 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
attributes = await jid.Attributes();
}
attributes[AttributeKey.WA_CHAT_ID] = this.chatId;
attributes[AttributeKey.WA_LID] = this.chatId;
return attributes;
}
@@ -149,6 +153,7 @@ class GroupContactInfo extends ChatContactInfo {
const group: any = await this.session?.getGroup(this.chatId);
if (group) {
name = group.subject || group.name || group.topic || name;
name = group.Name || name;
const suffix = this.locale
.key(TKey.WHATSAPP_CONTACT_GROUP_SUFFIX)
.render();
@@ -266,6 +271,9 @@ export function WhatsAppContactInfo(
return new BroadcastContactInfo(session, chatId, locale);
} else if (isLidUser(chatId)) {
return new LidContactInfo(session, chatId, locale);
} else if (isPnUser(chatId)) {
return new JidContactInfo(session, chatId, locale);
} else {
throw new UnknownJIDFormat(chatId);
}
return new JidContactInfo(session, chatId, locale);
}
+96 -92
View File
@@ -2,10 +2,10 @@ import ChatwootClient from '@figuro/chatwoot-sdk';
import * as lodash from 'lodash';
import { AxiosLogging } from '@waha/apps/app_sdk/AxiosLogging';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { ContactAPI } from '@waha/apps/chatwoot/client/ContactAPI';
import { ContactService } from '@waha/apps/chatwoot/client/ContactService';
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { ConversationAPI } from '@waha/apps/chatwoot/client/ConversationAPI';
import { CustomAttributesAPI } from '@waha/apps/chatwoot/client/CustomAttributesAPI';
import { ConversationService } from '@waha/apps/chatwoot/client/ConversationService';
import { CustomAttributesService } from '@waha/apps/chatwoot/client/CustomAttributesService';
import { ChatWootInboxAPI } from '@waha/apps/chatwoot/client/interfaces';
import {
ChatWootAppConfig,
@@ -15,7 +15,7 @@ import {
} from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootErrorReporter } from '@waha/apps/chatwoot/error/ChatWootErrorReporter';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf } from '@waha/apps/chatwoot/session/WAHASelf';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
ChatwootMessageRepository,
MessageMappingRepository,
@@ -25,21 +25,17 @@ import {
import { Job } from 'bullmq';
import { Knex } from 'knex';
import { i18n } from '@waha/apps/chatwoot/i18n';
import { CacheSync } from '@waha/utils/Cache';
import {
ConversationSelector,
ConversationSort,
} from '@waha/apps/chatwoot/services/ConversationSelector';
/**
* Dependency Injection Container for ChatWoot
* Manages the creation and caching of various clients and repositories
*/
export class DIContainer {
private accountAPI: ChatwootClient;
private inboxAPI: ChatWootInboxAPI;
private contactAPI: ContactAPI;
private conversationAPI: ConversationAPI;
private contactConversationService: ContactConversationService;
private locale: Locale;
private messageMappingService: MessageMappingService;
private wahaSelf: WAHASelf;
/**
* Creates a new DIContainer with the given configuration
*/
@@ -68,122 +64,124 @@ export class DIContainer {
return this.knex;
}
@CacheSync()
public Locale(): Locale {
if (!this.locale) {
this.locale = i18n.locale(this.config.locale || DEFAULT_LOCALE);
this.locale = this.locale.override(this.ChatWootConfig().templates);
}
return this.locale;
let locale = i18n.locale(this.config.locale || DEFAULT_LOCALE);
locale = locale.override(this.ChatWootConfig().templates);
return locale;
}
/**
* Gets the AccountAPI client
* @returns ChatwootClient instance
*/
@CacheSync()
public AccountAPI(): ChatwootClient {
if (!this.accountAPI) {
this.accountAPI = new ChatwootClient({
config: {
basePath: this.config.url,
with_credentials: true,
credentials: 'include',
token: this.config.accountToken,
},
});
}
return this.accountAPI;
return new ChatwootClient({
config: {
basePath: this.config.url,
with_credentials: true,
credentials: 'include',
token: this.config.accountToken,
},
});
}
/**
* Gets the InboxAPI client
* @returns ChatWootInboxAPI instance
*/
@CacheSync()
public InboxAPI(): ChatWootInboxAPI {
if (!this.inboxAPI) {
const chatwootClientAPI = new ChatwootClient({
config: {
basePath: this.config.url,
with_credentials: true,
credentials: 'include',
token: this.config.inboxIdentifier,
},
});
this.inboxAPI = chatwootClientAPI.client as ChatWootInboxAPI;
}
return this.inboxAPI;
const chatwootClientAPI = new ChatwootClient({
config: {
basePath: this.config.url,
with_credentials: true,
credentials: 'include',
token: this.config.inboxIdentifier,
},
});
return chatwootClientAPI.client as ChatWootInboxAPI;
}
/**
* Gets the ContactAPI
* @returns ContactAPI instance
* Gets the ContactService
* @returns ContactService instance
*/
private ContactAPI(): ContactAPI {
if (!this.contactAPI) {
this.contactAPI = new ContactAPI(
this.config,
this.AccountAPI(),
this.InboxAPI(),
this.logger,
);
}
return this.contactAPI;
@CacheSync()
public ContactService(): ContactService {
return new ContactService(
this.config,
this.AccountAPI(),
this.InboxAPI(),
this.logger,
);
}
@CacheSync()
public ConversationSelector() {
const config = this.ChatWootConfig();
return new ConversationSelector({
sort: config.conversations.sort,
status: config.conversations.status,
inboxId: this.config.inboxId,
});
}
/**
* Gets the ConversationAPI
* @returns ConversationAPI instance
* Gets the ConversationService
* @returns ConversationService instance
*/
private ConversationAPI(): ConversationAPI {
if (!this.conversationAPI) {
this.conversationAPI = new ConversationAPI(
this.config,
this.InboxAPI(),
this.logger,
);
}
return this.conversationAPI;
@CacheSync()
public ConversationService(): ConversationService {
return new ConversationService(
this.config,
this.AccountAPI(),
this.InboxAPI(),
this.ConversationSelector(),
this.logger,
);
}
/**
* Gets the ContactConversationService
* @returns ContactConversationService instance
*/
@CacheSync()
public ContactConversationService(): ContactConversationService {
if (!this.contactConversationService) {
this.contactConversationService = new ContactConversationService(
this.config,
this.ContactAPI(),
this.ConversationAPI(),
this.AccountAPI(),
this.logger,
this.Locale(),
);
}
return this.contactConversationService;
return new ContactConversationService(
this.config,
this.ContactService(),
this.ConversationService(),
this.AccountAPI(),
this.logger,
this.Locale(),
);
}
@CacheSync()
private ChatwootMessageRepository(): ChatwootMessageRepository {
return new ChatwootMessageRepository(this.Knex(), this.AppPk());
}
@CacheSync()
private WhatsAppMessageRepository(): WhatsAppMessageRepository {
return new WhatsAppMessageRepository(this.Knex(), this.AppPk());
}
@CacheSync()
private MessageMappingRepository(): MessageMappingRepository {
return new MessageMappingRepository(this.Knex(), this.AppPk());
}
@CacheSync()
public MessageMappingService(): MessageMappingService {
if (!this.messageMappingService) {
this.messageMappingService = new MessageMappingService(
this.Knex(),
this.WhatsAppMessageRepository(),
this.ChatwootMessageRepository(),
this.MessageMappingRepository(),
);
}
return this.messageMappingService;
return new MessageMappingService(
this.Knex(),
this.WhatsAppMessageRepository(),
this.ChatwootMessageRepository(),
this.MessageMappingRepository(),
);
}
public ChatWootErrorReporter(job: Job): ChatWootErrorReporter {
@@ -194,25 +192,31 @@ export class DIContainer {
* Gets the WAHASelf instance
* @returns WAHASelf instance
*/
@CacheSync()
public WAHASelf(): WAHASelf {
if (!this.wahaSelf) {
this.wahaSelf = new WAHASelf();
const logging = new AxiosLogging(this.Logger());
logging.applyTo(this.wahaSelf.client);
}
return this.wahaSelf;
const self = new WAHASelf();
const logging = new AxiosLogging(this.Logger());
logging.applyTo(self.client);
return self;
}
public CustomAttributesAPI() {
return new CustomAttributesAPI(this.config, this.AccountAPI());
@CacheSync()
public CustomAttributesService() {
return new CustomAttributesService(this.config, this.AccountAPI());
}
@CacheSync()
public ChatWootConfig(): ChatWootConfig {
const defaults: ChatWootConfig = {
templates: {},
linkPreview: LinkPreview.OFF,
commands: {
server: true,
queue: false,
},
conversations: {
sort: ConversationSort.created_newest,
status: null,
},
};
return lodash.defaults({}, this.config, defaults);
+25
View File
@@ -9,12 +9,21 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { IsDynamicObject } from '@waha/nestjs/validation/IsDynamicObject';
import {
ConversationSelectorConfig,
ConversationSort,
} from '@waha/apps/chatwoot/services/ConversationSelector';
import { ConversationStatus } from '@waha/apps/chatwoot/client/types';
export const DEFAULT_LOCALE = 'en-US';
export class ChatWootCommandsConfig {
@IsBoolean()
server: boolean = true;
@IsOptional()
@IsBoolean()
queue?: boolean = false;
}
export enum LinkPreview {
@@ -23,10 +32,20 @@ export enum LinkPreview {
HQ = 'HG',
}
export class ChatWootConversationsConfig {
@IsEnum(ConversationSort)
sort: ConversationSort;
@IsOptional()
@IsEnum(ConversationStatus, { each: true })
status: Array<ConversationStatus> | null;
}
export interface ChatWootConfig {
templates: Record<string, string>;
linkPreview: LinkPreview;
commands: ChatWootCommandsConfig;
conversations: ChatWootConversationsConfig;
}
export class ChatWootAppConfig implements ChatWootAPIConfig {
@@ -56,7 +75,13 @@ export class ChatWootAppConfig implements ChatWootAPIConfig {
@IsDynamicObject()
templates?: Record<string, string>;
@IsOptional()
@ValidateNested()
@Type(() => ChatWootCommandsConfig)
commands?: ChatWootCommandsConfig;
@IsOptional()
@ValidateNested()
@Type(() => ChatWootConversationsConfig)
conversations?: ChatWootConversationsConfig;
}
+37
View File
@@ -0,0 +1,37 @@
import { WAHASessionStatus, WAMessageAck } 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 '❓';
}
}
export function MessageAckEmoji(ack: WAMessageAck) {
switch (ack) {
case WAMessageAck.ERROR:
return '❌';
case WAMessageAck.PENDING:
return '⏳';
case WAMessageAck.SERVER:
return '✔️';
case WAMessageAck.DEVICE:
return '✔️';
case WAMessageAck.READ:
return '✅';
case WAMessageAck.PLAYED:
return '✅';
default:
return '❔';
}
}
+10
View File
@@ -0,0 +1,10 @@
export const CHATWOOT_COMMAND_PREFIX =
process.env.WAHA_CHATWOOT_COMMAND_PREFIX || 'wa/';
export const CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS =
parseInt(process.env.WAHA_CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS) || 600;
export const APPS_CHATWOOT_LANGUAGES_FOLDER =
process.env.WAHA_APPS_CHATWOOT_LANGUAGES_FOLDER?.trim();
export const TZ = process.env.TZ;
@@ -11,8 +11,10 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { Job } from 'bullmq';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
const renderer: ErrorRenderer = new ErrorRenderer();
export class ChatWootErrorReporter {
private errorRenderer: ErrorRenderer = new ErrorRenderer();
private errorRenderer: ErrorRenderer = renderer;
constructor(
private logger: ILogger,
@@ -27,10 +29,22 @@ export class ChatWootErrorReporter {
error: any,
replyTo?: number,
) {
const errorText = this.errorRenderer.renderError(error);
const errorText = this.errorRenderer.text(error);
this.logger.error(errorText);
try {
const data = this.errorRenderer.data(error);
this.logger.error(JSON.stringify(data, null, 2));
} catch (err) {
this.logger.error(`Error occurred while login details for error: ${err}`);
}
const template = this.l.key(TKey.JOB_REPORT_ERROR);
const nextDelay = NextAttemptDelayInWholeSeconds(this.job);
if (nextDelay) {
// TODO: Add a way to enable it back in config
// https://github.com/devlikeapro/waha/issues/1395
// There's retries more left - ignore it for now
return;
}
const attempts = {
current: this.job.attemptsMade + 1,
max: this.job.opts?.attempts || 1,
@@ -69,9 +83,10 @@ export class ChatWootErrorReporter {
type: MessageType,
replyTo?: number,
): Promise<void> {
const jobUrl = `http://localhost:3000/jobs/queue/${encodeURIComponent(
this.job.queueName,
)}/${this.job.id}`;
// TODO: Add a way to enable it back in config
// https://github.com/devlikeapro/waha/issues/1395
return null;
const template = this.l.key(TKey.JOB_REPORT_SUCCEEDED);
const attempts = {
current: this.job.attemptsMade + 1,
@@ -79,10 +94,7 @@ export class ChatWootErrorReporter {
};
const content = template.render({
details: {
text: `${this.job.queueName} => ${this.job.id}`,
url: jobUrl,
},
details: JobLink(this.job),
attempts: attempts,
});
+105 -40
View File
@@ -1,45 +1,28 @@
import { ApiError } from '@figuro/chatwoot-sdk/dist/core/ApiError';
import { AxiosError } from 'axios';
import { ApiError as ChatWootAPIError } from '@figuro/chatwoot-sdk/dist/core/ApiError';
import type { AxiosError } from 'axios';
/**
* Class responsible for rendering error information based on error type
*/
export class ErrorRenderer {
interface IErrorRender<T> {
/**
* Renders error information based on error type
* @param error The error to render
* @returns Formatted error text
* Get an error text to show to the user
*/
public renderError(error: any): string {
// Handle Axios errors
if (error.isAxiosError) {
return this.renderAxiosError(error as AxiosError);
}
// Handle Chatwoot SDK ApiError
if (error instanceof ApiError) {
return this.renderApiError(error);
}
// For all other errors
return this.renderGenericError(error);
}
text(error: T): string;
/**
* Renders Axios error information
* @param error The Axios error to render
* @returns Formatted error text
* Structured error data for logging/inspection
*/
private renderAxiosError(error: AxiosError): string {
let errorText = `Axios Error: ${error.message}`;
data(error: T): Record<string, any>;
}
class AxiosErrorRenderer implements IErrorRender<AxiosError> {
text(error: AxiosError): string {
let errorText = `API Error: ${error.message}`;
if (!error.response?.data) {
return errorText;
}
// Add response data if it exists and is JSON
try {
const data = error.response.data as any;
let json: undefined;
let json: any;
if (Buffer.isBuffer(data)) {
json = JSON.parse(data.toString());
} else if (typeof data === 'object') {
@@ -55,16 +38,35 @@ export class ErrorRenderer {
return errorText;
}
/**
* Renders Chatwoot SDK ApiError information
* @param error The ApiError to render
* @returns Formatted error text
*/
private renderApiError(error: ApiError): string {
data(error: AxiosError): Record<string, any> {
const { config, response, request } = error as any;
return {
class: 'APIError',
request: {
method: request?.method || config?.method,
url: request?.path || config?.url,
params: config?.params,
body: request?.body || config?.data,
},
response: {
status: response?.status,
statusText: response?.statusText,
headers: response?.headers,
body: response?.data,
},
message: error.message,
code: (error as any)?.code,
stack: error.stack,
};
}
}
class ChatWootAPIErrorRenderer implements IErrorRender<ChatWootAPIError> {
text(error: ChatWootAPIError): string {
let errorText = `ChatWoot API Error: ${error.message}`;
errorText += `\nStatus: ${error.status}`;
// Add body if it exists
if (error.body) {
try {
const body =
@@ -78,12 +80,75 @@ export class ErrorRenderer {
return errorText;
}
data(error: ChatWootAPIError): Record<string, any> {
return {
class: 'ChatWootAPIError',
request: {
method: error.request?.method,
url: error.request?.url,
query: error.request?.query,
path: error.request?.path,
body: error.request?.body,
},
response: {
status: error.status,
statusText: error.statusText,
body: error.body,
},
message: error.message,
stack: error.stack,
};
}
}
class GenericErrorRenderer implements IErrorRender<any> {
text(error: any): string {
return error?.toString?.() ?? String(error);
}
data(error: any): Record<string, any> {
return {
class: error?.name ?? error?.constructor?.name ?? typeof error,
message: error?.message ?? String(error),
stack: error.stack,
};
}
}
/**
* Class responsible for rendering error information based on error type
*/
export class ErrorRenderer implements IErrorRender<any> {
private readonly axiosRenderer = new AxiosErrorRenderer();
private readonly chatwootRenderer = new ChatWootAPIErrorRenderer();
private readonly genericRenderer = new GenericErrorRenderer();
/**
* Renders generic error information
* Renders error information based on error type
* @param error The error to render
* @returns Formatted error text
*/
private renderGenericError(error: any): string {
return error.toString();
public text(error: any): string {
if (error?.isAxiosError) {
return this.axiosRenderer.text(error as AxiosError);
}
if (error instanceof ChatWootAPIError) {
return this.chatwootRenderer.text(error);
}
return this.genericRenderer.text(error);
}
public data(error: any): Record<string, any> {
if (error?.isAxiosError) {
return this.axiosRenderer.data(error as AxiosError);
}
if (error instanceof ChatWootAPIError) {
return this.chatwootRenderer.data(error);
}
return this.genericRenderer.data(error);
}
}
+6
View File
@@ -9,3 +9,9 @@ export class PhoneNumberNotFoundInWhatsAppError extends Error {
super(`Phone number not found in WhatsApp: ${phone}`);
}
}
export class UnknownJIDFormat extends Error {
constructor(public jid: string) {
super(`WhatsApp Chat ID is not recognized: ${jid}`);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import * as path from 'path';
import { YamlLocaleLoader } from '@waha/apps/chatwoot/i18n/loader';
import { I18N } from '@waha/apps/chatwoot/i18n/i18n';
import { APPS_CHATWOOT_LANGUAGES_FOLDER } from '@waha/apps/chatwoot/env';
const i18n = new I18N();
const localesDir = path.join(__dirname, 'locales');
@@ -9,7 +10,7 @@ i18n.load(new YamlLocaleLoader(localesDir, 'yml').load());
i18n.load(new YamlLocaleLoader(localesDir, 'yaml').load());
// Resolve env var (supports '.' and '~'); fallback to default
const additional = process.env.WAHA_APPS_CHATWOOT_LANGUAGES_FOLDER?.trim();
const additional = APPS_CHATWOOT_LANGUAGES_FOLDER;
if (additional) {
const resolved = path.resolve(additional);
i18n.load(new YamlLocaleLoader(resolved, 'yml').load());
+86
View File
@@ -0,0 +1,86 @@
import { i18n } from '@waha/apps/chatwoot/i18n/index';
const enLocale = i18n.locale('en-US');
// @ts-ignore
enLocale.strings.datetime.timeZone = 'America/New_York';
const ptBrLocale = i18n.locale('pt-BR');
// @ts-ignore
ptBrLocale.strings.datetime.timeZone = 'America/Sao_Paulo';
describe('Locale', () => {
describe('FormatHumanDate', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('formats dates occurring today with the Today prefix', () => {
const now = new Date('2024-03-15T10:30:00Z');
jest.setSystemTime(now);
const result = enLocale.FormatHumanDate(new Date(now));
expect(result).toBe('Today, Mar 15 • 06:30 AM EDT');
});
it('formats dates occurring yesterday with the Yesterday prefix', () => {
const now = new Date('2024-03-15T10:30:00Z');
const yesterday = new Date('2024-03-14T10:30:00Z');
jest.setSystemTime(now);
const result = enLocale.FormatHumanDate(yesterday);
expect(result).toBe('Yesterday, Mar 14 • 06:30 AM EDT');
});
it('formats dates from the current year without the year', () => {
const now = new Date('2024-05-01T09:00:00Z');
const date = new Date('2024-02-10T14:45:00Z');
jest.setSystemTime(now);
const result = enLocale.FormatHumanDate(date);
expect(result).toBe('Sat, Feb 10 • 09:45 AM EST');
});
it('formats dates from previous years including the year', () => {
const now = new Date('2024-05-01T09:00:00Z');
const date = new Date('2023-07-10T12:15:00Z');
jest.setSystemTime(now);
const result = enLocale.FormatHumanDate(date);
expect(result).toBe('Mon, Jul 10, 2023 • 08:15 AM EDT');
});
});
describe('FormatHumanDate (pt-BR)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('uses localized Today and Yesterday labels', () => {
const now = new Date('2024-03-15T10:30:00Z');
const yesterday = new Date('2024-03-14T10:30:00Z');
jest.setSystemTime(now);
const todayResult = ptBrLocale.FormatHumanDate(now);
const yesterdayResult = ptBrLocale.FormatHumanDate(yesterday);
expect(todayResult).toBe('Hoje, 15 de mar. • 07:30 BRT');
expect(yesterdayResult).toBe('Ontem, 14 de mar. • 07:30 BRT');
});
});
});
+149 -2
View File
@@ -1,13 +1,32 @@
import * as Mustache from 'mustache';
import * as lodash from 'lodash';
import { TemplatePayloads, TKey } from '@waha/apps/chatwoot/i18n/templates';
import Long from 'long';
import { ensureNumber } from '@waha/core/engines/noweb/utils';
import { EnsureMilliseconds } from '@waha/utils/timehelper';
import { TZ } from '@waha/apps/chatwoot/env';
import { isToday, isYesterday, isSameYear } from '@waha/utils/datehelper';
export class Locale {
constructor(private readonly strings: Record<string, string>) {}
constructor(public readonly strings: Record<string, string>) {}
/**
* Return Base Locale
* For number, datetime, currency formats
*/
get locale(): string {
return this.strings['locale.base'] || 'en';
}
key<K extends TKey>(key: K): Template<K> {
return new Template(this.strings[key] || key);
}
r<K extends TKey | string>(key: K, data: TemplatePayloads[K] = null): string {
const template = this.key(key as TKey);
return template.render(data as any);
}
/**
* Overrides the existing strings with the provided strings for the locale.
* Merges the new strings with the current strings.
@@ -15,13 +34,141 @@ export class Locale {
override(strings: Record<string, string>): Locale {
return new Locale({ ...this.strings, ...strings });
}
FormatCurrency(
currency: string,
value: number | null,
offset: number = 1,
): string | null {
if (value == null) {
return null;
}
if (!currency) {
return null;
}
offset = offset || 1;
try {
const fmt = new Intl.NumberFormat(this.locale, {
style: 'currency',
currency: currency,
});
return fmt.format(value / offset);
} catch (err) {
return `${currency} ${value}`;
}
}
FormatDatetime(date: Date | null, year): string | null {
const options: any = lodash.clone(this.strings['datetime'] || {});
return this.FormatDatetimeOpts(date, options, year);
}
ParseTimestamp(timestamp: Long | string | number | null): Date | null {
const value = ensureNumber(timestamp);
if (!value) {
return undefined;
}
if (!Number.isFinite(value)) {
return undefined;
}
const milliseconds = EnsureMilliseconds(value);
const date = new Date(milliseconds);
if (Number.isNaN(date.getTime())) {
return undefined;
}
return date;
}
FormatTimestamp(
timestamp: Long | string | number | null,
year: boolean = true,
): string | null {
const date = this.ParseTimestamp(timestamp);
return this.FormatDatetime(date, year);
}
/**
* Format date using custom options
*/
FormatDatetimeOpts(
date: Date,
options: Intl.DateTimeFormatOptions,
year: boolean,
) {
options = lodash.cloneDeep(options);
if (!date) {
return null;
}
const opts: any = this.strings['datetime'] || {};
// Copy timezone if any
options.timeZone = opts.timeZone || opts.timezone || TZ;
if (!year && date.getFullYear() === new Date().getFullYear()) {
// Hide year if current year
options.year = undefined;
}
return date.toLocaleDateString(this.locale, options);
}
FormatTimestampOpts(
timestamp: Long | string | number | null,
options: Intl.DateTimeFormatOptions,
year: boolean = true,
): string | null {
const date = this.ParseTimestamp(timestamp);
return this.FormatDatetimeOpts(date, options, year);
}
FormatHumanDate(date: Date): string {
const options: any = lodash.clone(this.strings['datetime'] || {});
const now = new Date();
const today = isToday(date, now);
const yesterday = isYesterday(date, now);
const sameYear = isSameYear(date, now);
const dateOptions: Intl.DateTimeFormatOptions = {
...options,
hour: undefined,
minute: undefined,
second: undefined,
timeZoneName: undefined,
year: sameYear ? undefined : options.year,
weekday: today || yesterday ? undefined : options.weekday,
};
const timeOptions: Intl.DateTimeFormatOptions = {
...options,
year: undefined,
month: undefined,
day: undefined,
weekday: undefined,
};
const dateStr = date.toLocaleDateString(this.locale, dateOptions);
const timeStr = date.toLocaleTimeString(this.locale, timeOptions);
if (today) {
const todayLabel = this.r('Today');
return `${todayLabel}, ${dateStr} • ${timeStr}`;
}
if (yesterday) {
const yesterdayLabel = this.r('Yesterday');
return `${yesterdayLabel}, ${dateStr} • ${timeStr}`;
}
// weekday already included in dateStr
return `${dateStr} • ${timeStr}`;
}
}
export class Template<K extends TKey> {
constructor(private readonly template: string) {}
render(data: TemplatePayloads[K]): string {
return Mustache.render(this.template, data);
const text = Mustache.render(this.template, data);
// Remove /n at the end if any
return text.replace(/\n$/, '');
}
r(data: TemplatePayloads[K]): string {
+144
View File
@@ -0,0 +1,144 @@
Here’s a complete `AGENTS.md` draft for your i18n workflow 👇
---
# 🌐 AGENTS.md — Locale Maintenance Guide
This document defines how the **i18n Agent** manages, updates, and synchronizes
locale files (e.g. `en-US.yaml`, `pt-BR.yaml`, etc.) across all supported
languages.
---
## 🧩 Overview
Each locale file (e.g. `en-US.yaml`) contains translation keys and values used
throughout the system. The **English (en-US)** file is the **source of truth** —
every other language must mirror its structure, keys, emojis, and formatting.
---
## 🪄 Core Commands
### 1. `update all i18n`
**Purpose:** Ensures all locales are synchronized with the current `en-US.yaml`.
**Steps:**
1. **Start with `en-US.yaml`.**
- Scan all code and templates to verify each key is used.
- Ignore usage warnings for `datetime.*`, flat keys without a dot, and any
`locale.*` entries — they are considered always in use.
- Propagate every key change (add, edit, remove) from `en-US.yaml` to all
other locale files with the corresponding translations so structures stay
aligned.
- Preserve `datetime.locales` in every non-English locale; it pins the
language hint for formatting and must never be dropped.
- If any keys appear **unused**, list them and **ask whether to remove or
keep**.
2. **Check for “ChatWoot” mentions.**
- Any occurrence must be reviewed — the system should not reference
_ChatWoot_ directly.
- Ask:
> “Found ‘ChatWoot’ in key `X.Y.Z`. Should we reword this? If yes, how?”
3. **Validate formatting and structure.**
- Ensure all keys use consistent YAML indentation and ordering.
- Emojis, punctuation, and tags (`{{variable}}`, `**bold**`, etc.) must stay
exactly as in `en-US`.
4. **Synchronize all target languages.**
- For each locale (`es-ES.yaml`, `fr-FR.yaml`, etc.):
- Copy the structure and keys from `en-US.yaml`.
- Translate the values from English → target language.
- **Ignore any manual changes in target files** — always use the English
source.
- Preserve all markdown, emojis, punctuation, and placeholders.
5. **Report summary.**
- Added / updated / removed keys count per language.
---
### 2. `update key.* for i18n`
**Purpose:** Update or add translations for specific keys only.
**Steps:**
1. Match all keys provided in the command (supports wildcard like `key.auth.*`).
2. Update those keys in `en-US.yaml` first.
3. Propagate and translate the updated keys to all other locale files.
4. Do **not** touch unrelated keys or structure.
---
## ⚙️ Translation Rules
| Aspect | Rule |
| -------------- | --------------------------------------------------------------------------------- |
| **Structure** | Must always follow `en-US.yaml`. |
| **Formatting** | Keep emojis, punctuation, capitalization, and markdown exactly as in English. |
| **Tags** | Preserve placeholders (`{{variable}}`) and code blocks. |
| **Priority** | English (`en-US.yaml`) is always the canonical version. |
| **Comments** | If a translation key needs explanation, add a YAML comment above it (not inline). |
---
## 🚫 ChatWoot Mentions
- The word **ChatWoot** must never appear in translations.
- When found, the agent must pause and ask:
> “ChatWoot found in key `X.Y.Z`: `{{string}}`. Should we rephrase it (e.g.,
> ‘Inbox’, ‘Messaging Platform’, or something else)?”
---
## 📘 Terms (reserved)
> This section will define key terminology and translation conventions (e.g.,
> “Contact” vs “Customer”, “Session” vs “Chat”), once finalized.
---
## ⚠️ Exceptions
- Treat `datetime.*` keys as standard infrastructure strings; do not flag them
as unused.
- Always keep `datetime.locales` aligned with the target locale code (e.g.,
`es-ES`); if it is missing, restore it during the sync.
- Keys without a dot (e.g., `UNKNOWN`) and any under `locale.*` are canonical
metadata — skip unused checks for them.
---
## 🧾 Example Workflow
```bash
# Full synchronization
> update all i18n
→ Checking unused keys...
→ Found 3 unused keys: session.old, inbox.archive, legacy.test
Keep or remove? [k/r]
→ Found "ChatWoot" in en-US.yaml: "ChatWoot inbox connected"
Replace? [yes/no]
→ Translating en-US → es-ES, fr-FR, pt-BR...
✅ All i18n files updated and aligned with en-US.yaml
```
---
Would you like me to add an example **YAML structure** and show how the agent
should align keys (e.g., diff between en-US and pt-BR)? It helps clarify exactly
how updates propagate.
+461 -38
View File
@@ -1,8 +1,33 @@
# Arabic translations for ChatWoot integration
# Arabic translations for WhatsApp inbox integration
locale.name: '🇦🇪 العربية (Arabic)'
app.inbox.contact.name: 'تكامل واتساب (WAHA)'
# رمز اللغة الأساسي - يجب أن يكون محليًا صالحًا وفق BCP 47
# https://en.wikipedia.org/wiki/IETF_language_tag
# تستخدمه WAHA لعرض الأرقام والعملات والتاريخ/الوقت
locale.base: 'ar-AE'
app.inbox.contact.name: 'تكامل واتساب'
app.inbox.contact.avatar.url: 'https://raw.githubusercontent.com/devlikeapro/waha/core/logo.png'
# "options" من date.toLocaleString
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options
datetime:
weekday: short
locales: ar-AE
day: 2-digit
month: short
year: numeric
hour: 2-digit
minute: 2-digit
hour12: true
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#timezonename
timeZoneName: short
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# timeZone: 'Asia/Dubai'
Today: 'اليوم'
Yesterday: 'أمس'
job.report.error: |-
⚠️ **{{{header}}}**
{{#attempts.nextDelay}}
@@ -31,14 +56,14 @@ whatsapp.message.removing.error: تعذر إزالة رسالة من واتسا
job.scheduled.error.header: فشل تنفيذ المهمة المجدولة
message.from.whatsapp: |-
{{{text}}}
*📱 أرسلت من واتساب*
📱 *أرسلت من واتساب*
{{{text}}}
message.from.api: |-
{{{text}}}
*📱 أرسلت عبر API*
📱 *أرسلت عبر API*
{{{text}}}
message.removed.in.whatsapp: |-
❌ *تم حذف هذه الرسالة في واتساب*
@@ -66,11 +91,11 @@ whatsapp.contact.broadcast.suffix: 'قائمة البث'
whatsapp.contact.status.name: 'الحالة'
app.connected.message: |-
🔗 **متصل: جلسة واتساب إلى صندوق وارد ChatWoot**
🔗 **متصل: جلسة واتساب إلى صندوق الوارد**
✅ تم توصيل جلسة واتساب **{{{name}}}** بنجاح بصندوق وارد ChatWoot هذا.
✅ تم توصيل جلسة واتساب **{{{name}}}** بنجاح بهذا الصندوق الوارد.
صندوق الوارد هذا مرتبط الآن بـ **حساب واتساب** الخاص بك. يمكنك إرسال واستقبال الرسائل مباشرة من هنا — تمامًا مثل استخدام واتساب، ولكن داخل ChatWoot.
صندوق الوارد هذا مرتبط الآن بـ **حساب واتساب** الخاص بك. يمكنك إرسال واستقبال الرسائل مباشرة من هنا — تمامًا مثل استخدام واتساب، ولكن داخل مساحة العمل.
يمكنك أيضًا إدارة جلسة واتساب المرتبطة بصندوق الوارد هذا. لعرض الإجراءات المتاحة، أرسل **help** إلى هذه المحادثة.
@@ -84,9 +109,9 @@ app.connected.message: |-
لنبدأ! 🚀
app.disconnected.message: |-
🔌 **غير متصل: جلسة واتساب من صندوق وارد ChatWoot**
🔌 **غير متصل: جلسة واتساب من صندوق الوارد**
❌ تم قطع اتصال جلسة واتساب **{{{name}}}** من صندوق وارد ChatWoot هذا.
❌ تم قطع اتصال جلسة واتساب **{{{name}}}** من هذا الصندوق الوارد.
لن تتمكن بعد الآن من إرسال أو استقبال رسائل من واتساب.
@@ -97,34 +122,156 @@ app.updated.message: |-
✅ تم تحديث إعدادات جلسة واتساب **{{{name}}}**.
app.commands.list: |-
يمكنك إدارة **تكامل واتساب** عن طريق إرسال أحد الأوامر التالية:
cli.usage.arguments: |-
[الإجراء]
🖥️ **الجلسة**:
1. **status** - الحصول على حالة الجلسة الحالية.
2. **restart** - إعادة تشغيل الجلسة - سيحاول هذا إعادة توصيل حساب واتساب الخاص بك.
3. **start** - بدء الجلسة.
4. **stop** - إيقاف الجلسة.
5. **logout** - تسجيل الخروج من الجلسة، حتى تتمكن من مسح رمز QR وإصلاح الجلسة.
6. **qr** - الحصول على رمز QR للجلسة.
7. **screenshot** - الحصول على لقطة شاشة للجلسة (متاح فقط في محرك **WEBJS**).
8. **help** - الأوامر المتاحة.
cli.usage.command: |-
[الأمر]
{{#commands.server}}
⚙️ الخادم:
- **server status** - الحصول على معلومات إصدار الخادم وحالته.
- **server reboot** - إعادة تشغيل الخادم بسلاسة.
- **server reboot force** - إعادة تشغيل الخادم بالقوة.
{{/commands.server}}
cli.usage.options: |-
[الخيارات]
أرسل **أمرًا** (غير حساس لحالة الأحرف) أو **رقم أمر** إلى هذه المحادثة.
cli.cmd.disabled: ⛔ **تم تعطيل الأمر `{{command}}`**.
app.commands.server.disabled: ⛔ **تم تعطيل أوامر الخادم**.
يرجى التواصل مع مسؤول النظام لديك.
يرجى الاتصال بالمسؤول الخاص بك.
أرسل **help** للحصول على قائمة الأوامر المتاحة.
cli.cmd.root.description: |-
أدِر **تكامل واتساب** الخاص بك!
cli.cmd.root.sub.session: |-
🖥️ **الجلسة**:
cli.cmd.root.sub.server: |-
🖥️ **الخادم**:
cli.cmd.root.sub.sync: |-
🔄️ **المزامنة**:
cli.cmd.session.status.description: |-
اطّلع على حالة الجلسة الحالية.
cli.cmd.session.restart.description: |-
أعد تشغيل الجلسة.
cli.cmd.session.start.description: |-
ابدأ الجلسة.
cli.cmd.session.stop.description: |-
أوقف الجلسة.
cli.cmd.session.logout.description: |-
سجّل الخروج من الجلسة لمسح رمز QR جديد واستعادة الاتصال.
cli.cmd.session.qr.description: |-
اعرض رمز QR الخاص بالجلسة.
cli.cmd.session.screenshot.description: |-
التقط لقطة شاشة للجلسة (متاح فقط مع محرك **WEBJS**).
cli.cmd.contacts.summary: |-
اسحب جهات اتصال واتساب.
cli.cmd.contacts.description: |-
👤 شغِّل `contacts pull` لبدء المزامنة أو `contacts status` لمتابعة التقدم.
cli.cmd.contacts.action.description: |-
استخدم `start` لبدء سحب جهات الاتصال أو `status` لمراجعة المهمة النشطة.
cli.cmd.contacts.pull.description: |-
ابدأ بسحب جهات اتصال واتساب إلى الصندوق الوارد.
cli.cmd.contacts.pull.option.batch: |-
عدد جهات الاتصال التي يتم سحبها في كل دفعة.
cli.cmd.contacts.pull.option.delay-contact: |-
التأخير بين مزامنة كل جهة اتصال لتجنّب حدود معدل سحب الصور.
cli.cmd.contacts.pull.option.delay-batch: |-
التأخير بين دفعات جهات الاتصال.
cli.cmd.contacts.pull.option.avatar: |-
اختر كيفية سحب الصور الرمزية: `skip` أو `if-missing` أو `update`.
cli.cmd.contacts.pull.option.groups: |-
قم بتضمين جهات اتصال مجموعات واتساب في المزامنة.
cli.cmd.contacts.pull.option.no-attributes: |-
لا تسحب سمات جهة الاتصال إذا كانت موجودة بالفعل (هذا يسرّع العملية).
cli.cmd.contacts.pull.already-running: |-
⏳ مهمة **سحب جهات الاتصال** تعمل بالفعل. انتظر حتى تنتهي أو أوقفها قبل بدء واحدة جديدة.
أرسل `contacts status` للاطلاع على التفاصيل.
cli.cmd.options.job.attempts: |-
عدد مرات إعادة محاولة المهمة قبل اعتبارها فاشلة.
cli.cmd.options.job.timeout: |-
الحد الأقصى لمدة تشغيل المهمة قبل إعادة المحاولة (يدعم قيمًا مثل `30s` أو `5m` أو `1h`).
cli.cmd.contacts.status.description: |-
اعرض حالة مهمة السحب الحالية.
cli.cmd.contacts.status.not-found: |-
ℹ️ لم يتم العثور على مهمة **سحب جهات الاتصال** نشطة. ابدأ واحدة باستخدام `contacts pull`.
cli.cmd.server.description: |-
أدِر الخادم.
cli.cmd.server.status.description: |-
اعرض إصدار الخادم وحالته.
cli.cmd.server.reboot.description: |-
أعد تشغيل الخادم.
cli.cmd.server.reboot.option.force: |-
فرض إعادة تشغيل فورية.
cli.help.usage.title: |-
طريقة الاستخدام: أرسل أمراً إلى المحادثة بالشكل التالي:
cli.help.commands.defaultGroup: |-
**الأوامر:**
cli.help.arguments.title: |-
**المعاملات:**
cli.help.options.defaultGroup: |-
**الخيارات:**
cli.help.globalOptions.title: |-
**الخيارات العامة:**
task.contacts.status: |-
👤 سحب جهات الاتصال: **{{state}}**
- ✅ الناجحة: {{{progress.ok}}} جهة اتصال
- ⚪ تم تخطيها: {{{progress.skipped}}} جهة اتصال
- ⚠️ الأخطاء: {{{progress.errors}}} جهة اتصال
{{#job}}
⌛ الجدول الزمني:
- في قائمة الانتظار: {{{timestamp}}}
{{#processedOn}}
- وقت البدء: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- وقت الانتهاء: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
التفاصيل:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
جارٍ سحب جهات الاتصال...
task.contacts.progress: |-
سحب جهات الاتصال: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
app.help.reminder: |-
أرسل **help** للحصول على قائمة الأوامر المتاحة.
@@ -138,17 +285,17 @@ app.session.current.status: |-
📱 **{{{name}}} ({{{id}}})**
app.session.status.working: |-
✅ صندوق وارد ChatWoot هذا مرتبط بـ **حساب واتساب** وجاهز لإرسال واستقبال الرسائل!
✅ هذا الصندوق الوارد مرتبط بـ **حساب واتساب** وجاهز لإرسال واستقبال الرسائل!
📱 **{{{name}}} ({{{id}}})**
app.session.status.error: |-
⚠️ **صندوق وارد ChatWoot هذا مرتبط بحساب واتساب ولكنه غير جاهز لإرسال واستقبال الرسائل!**
⚠️ **هذا الصندوق الوارد مرتبط بحساب واتساب ولكنه غير جاهز لإرسال واستقبال الرسائل!**
يرجى التحقق من حالة الجلسة ومحاولة إعادة تشغيلها أو تسجيل الخروج ومسح رمز QR مرة أخرى.
app.session.scan.qr.code: |-
⚠️ **امسح رمز QR على هاتفك لربط حساب واتساب بصندوق وارد ChatWoot هذا!**
⚠️ **امسح رمز QR على هاتفك لربط حساب واتساب بهذا الصندوق الوارد!**
app.server.version.and.status: |-
**الإصدار:**
@@ -216,7 +363,19 @@ whatsapp.to.chatwoot.message.location: |-
{{#locationMessage}}
📍 **الموقع المشترك**
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **العنوان:** {{address}}
{{/address}}
{{#url}}
🌐 **الموقع الإلكتروني:** {{url}}
{{/url}}
🔗 **عرض على الخريطة:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/locationMessage}}
@@ -224,13 +383,277 @@ whatsapp.to.chatwoot.message.location: |-
📍📡 **مشاركة الموقع المباشر**
افتح **WhatsApp** لعرض التحديثات اللحظية.
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **العنوان:** {{address}}
{{/address}}
{{#url}}
🌐 **الموقع الإلكتروني:** {{url}}
{{/url}}
🔗 **عرض على الخريطة:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/liveLocationMessage}}
{{/message}}
whatsapp.to.chatwoot.message.poll: |-
{{#poll}}
📊 **استطلاع**
**{{name}}**
{{#options}}
- {{optionName}}
{{/options}}
{{/poll}}
whatsapp.to.chatwoot.message.list: |-
{{#message.listMessage}}
{{#title}}
*{{{title}}}*
{{/title}}
{{#description}}
{{{description}}}
{{/description}}
{{#footerText}}
{{{footerText}}}
{{/footerText}}
{{#buttonText}}
{{{buttonText}}}
{{/buttonText}}
{{#sections}}
{{#title}}
{{{title}}}:
{{/title}}
{{#rows}}
{{#title}}
- {{{title}}}
{{/title}}
{{/rows}}
{{/sections}}
{{/message.listMessage}}
whatsapp.to.chatwoot.message.event: |-
{{#message}}
{{#eventMessage}}
📅 **حدث**
**{{name}}**
{{#description}}
{{description}}
{{/description}}
{{#location.name}}
📍 **الموقع:** {{location.name}}
{{/location.name}}
{{#startTime}}
🕐 **البدء:** {{startTime}}
{{/startTime}}
{{#endTime}}
🕐 **الانتهاء:** {{endTime}}
{{/endTime}}
{{#joinLink}}
🔗 **رابط الانضمام:** [{{joinLink}}]({{joinLink}})
{{/joinLink}}
{{/eventMessage}}
{{/message}}
whatsapp.to.chatwoot.message.pix: |-
💳 **PIX - نسخ ولصق**
{{#pixData}}
{{#merchantName}}
**{{merchantName}}**
{{/merchantName}}
{{#key}}
**مفتاح PIX:** {{key}}
{{/key}}
{{#keyType}}
**النوع:** {{keyType}}
{{/keyType}}
{{#totalAmount}}
**المبلغ:** {{totalAmountFormatted}}
{{/totalAmount}}
{{#referenceId}}
**المرجع:** {{referenceId}}
{{/referenceId}}
💳 **تم إرسال PIX نسخ ولصق**
{{/pixData}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **هذا النوع من الرسائل غير مدعوم في ChatWoot.**
⚠️ **هذا النوع من الرسائل غير مدعوم في هذا الصندوق الوارد.**
📱 يرجى فتح **WhatsApp** لعرضه.
التفاصيل: [{{{details.text}}}]({{{details.url}}})
whatsapp.to.chatwoot.message.album: |-
📷 **تم استلام رسالة ألبوم**
{{#totalCount}}
تم استلام ألبوم يحتوي على **{{totalCount}}** ملف وسائط.
{{#expectedImageCount}}
📸 صور: **{{expectedImageCount}}**
{{/expectedImageCount}}
{{#expectedVideoCount}}
🎥 فيديوهات: **{{expectedVideoCount}}**
{{/expectedVideoCount}}
سيتم تسليم ملفات الوسائط في الرسائل التالية.
{{/totalCount}}
whatsapp.to.chatwoot.message.facebook.ad: |-
📢 **إعلان فيسبوك**
**العنوان:** {{{adData.title}}}
**الوصف:**
{{{adData.body}}}
**رابط الإعلان:** [عرض على فيسبوك]({{{adData.sourceUrl}}})
**معرّف الإعلان:** {{{adData.sourceId}}}
---
💬 **رسالة العميل:**
{{{payload.body}}}
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
UNKNOWN: غير معروف
ERROR: خطأ
PENDING: جارٍ الإرسال
SERVER: الخادم
DEVICE: الجهاز
READ: مقروء
PLAYED: مُشغَّل
whatsapp.to.chatwoot.message.has.media.no.media: |-
{{#content}}
{{{content}}}
{{/content}}
🖼️⚠️ **تحتوي الرسالة على وسائط ولكن تعذّر تنزيلها**
📱 يرجى فتح **واتساب** للاطلاع عليها.
{{^content}}
التفاصيل: [{{{details.text}}}]({{{details.url}}})
{{/content}}
cli.cmd.root.sub.queue: '🧩 **قائمة الانتظار**:'
cli.cmd.messages.summary: سحب رسائل واتساب.
cli.cmd.messages.description:
💬 نفّذ `messages pull` لبدء مزامنة الرسائل أو `messages status` لمتابعة
التقدم.
cli.cmd.messages.action.description:
'`pull` لبدء سحب الرسائل أو `status` لمراجعة المهمة النشطة.'
cli.cmd.messages.pull.option.chat: حصر السحب على معرّف دردشة واتساب محدد.
cli.cmd.messages.pull.option.force:
إجبار صندوق الوارد على إنشاء الرسائل حتى لو كانت رسالة واتساب موجودة بالفعل في
مخطط المحادثة.
cli.cmd.messages.pull.option.batch: عدد الرسائل التي يتم سحبها في كل دفعة.
cli.cmd.messages.pull.option.progress:
الإبلاغ عن التقدم كل عدد N من الرسائل. القيمة 0 لتعطيل التقارير.
cli.cmd.messages.pull.option.no-dm: تخطي الرسائل الخاصة أثناء سحب السجل.
cli.cmd.messages.pull.option.groups: تضمين مجموعات واتساب أثناء سحب السجل.
cli.cmd.messages.pull.option.channels: تضمين قنوات واتساب أثناء سحب السجل.
cli.cmd.messages.pull.option.status: تضمين حالات واتساب أثناء سحب السجل.
cli.cmd.messages.pull.option.broadcast: تضمين قوائم البث أثناء سحب السجل.
cli.cmd.messages.pull.option.media:
تنزيل المرفقات (صور، فيديوهات، ملفات) أثناء سحب السجل.
cli.cmd.messages.pull.option.pause:
وضع مهمة سحب الرسائل في قائمة الانتظار وهي متوقفة مؤقتًا وإيقاف معالجة رسائل
واتساب الجديدة حتى تكتمل.
cli.cmd.messages.pull.option.resolve-conversations:
وضع علامة على المحادثات كمحلولة بعد معالجة كل رسائل السجل.
cli.cmd.messages.pull.error.pause-no-queue:
⛔ لا يمكن استخدام `--pause` لأن أمر **queue** معطَّل في هذا التكوين.
cli.cmd.queue.description: إدارة قوائم الانتظار.
cli.cmd.queue.summary: تنفيذ إجراءات على قوائم الانتظار.
cli.cmd.queue.action.description: اختر إجراءً.
cli.cmd.queue.status.description: عرض حالة قائمة الانتظار.
cli.cmd.queue.argument.name:
'اسم قائمة الانتظار: `inbox` أو `whatsapp` (اختياري).'
cli.cmd.queue.start.description: بدء قائمة الانتظار بالاسم.
cli.cmd.queue.stop.description: إيقاف قائمة الانتظار بالاسم.
cli.cmd.queue.paused:
⚪🧩 تم إيقاف قوائم الانتظار مؤقتًا. أرسل `queue status` للتحقق من الحالة.
cli.cmd.queue.resumed:
🟢🧩 تم استئناف قوائم الانتظار. أرسل `queue status` للتحقق من الحالة.
cli.cmd.queue.status.result: >-
📊 حالة قائمة الانتظار:
{{#queues}}
- **{{name}}**: {{#locked}}🔒{{/locked}}{{#paused}}⚪ متوقفة
مؤقتًا{{/paused}}{{^paused}}🟢 تعمل{{/paused}}
{{/queues}}
cli.cmd.messages.pull.option.timeout-media:
تحديد مدة محاولة تنزيل الوسائط لكل رسالة.
cli.cmd.messages.pull.argument.end:
تحديد نقطة نهاية نافذة السحب؛ على سبيل المثال، `2d` يعني التوقف قبل يومين.
cli.cmd.messages.pull.argument.start:
تحديد نقطة بداية نافذة السحب؛ على سبيل المثال، `1d` يبدأ قبل يوم واحد (القيمة
الافتراضية `0d` أي الآن).
cli.cmd.messages.pull.already-running: >-
⏳ مهمة **Pull Messages** تعمل بالفعل لـ **{{{chat}}}**. انتظر حتى تنتهي أو
أوقفها قبل بدء مهمة جديدة.
أرسل `messages status` للحصول على التفاصيل.
cli.cmd.messages.status.not-found:
ℹ️ لم يتم العثور على مهمة **Pull Messages** نشطة. ابدأ واحدة باستخدام
`messages pull`.
cli.cmd.messages.pull.no-chats-found:
لم يتم العثور على دردشات واتساب للسحب خلال هذه الفترة.
cli.cmd.contacts.pull.option.progress:
الإبلاغ عن التقدم كل عدد N من جهات الاتصال. القيمة 0 لتعطيل التقارير.
cli.cmd.contacts.pull.option.lids:
تضمين جهات اتصال واتساب LID (مجهولة) في المزامنة.
task.contacts.completed:
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}،
⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️
{{progress.avatar.updated}}
task.messages.started: ⏳ جارٍ سحب الرسائل 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
أرسل "{{{prefix}}}messages status" للاطلاع على المزيد من التفاصيل.
task.messages.progress:
'⏳ رسائل {{{chat}}}: 🆕 {{progress.ok}}، 🟢 {{progress.exists}}، ⚪
{{progress.ignored}}، 🔴 {{progress.errors}}{{#messages.last}} | 📅
{{{messages.last}}}{{/messages.last}}'
task.messages.completed:
✅ رسائل {{{chat}}} خلال {{{period}}} → 🆕 {{progress.ok}}، 🟢
{{progress.exists}}، ⚪ {{progress.ignored}}، 🔴 {{progress.errors}}
task.messages.status: |-
رسائل **{{{chat}}}** خلال الفترة ⏱️ **{{{period}}}**. ⚙️ حالة المهمة: **{{state}}**.
🗨️ الرسائل:
- 🆕 تم الإنشاء: **{{{progress.ok}}}**
- 🟢 موجودة مسبقًا: {{{progress.exists}}}
- ⚪ تم تجاهلها: {{{progress.ignored}}}
- 🔴 أخطاء: {{{progress.errors}}}
{{#messages.first}}
- 🗓️ الأقدم: **{{{messages.first}}}**
{{/messages.first}}
{{#messages.last}}
- 🗓️ الأحدث: **{{{messages.last}}}**
{{/messages.last}}
💬 دردشات واتساب:
- 🆕 رسائل جديدة في **{{{chats}}}** دردشات
{{#options.resolveConversations}}
🗣️ المحادثات:
- 🟢 تم حلها: **{{{progress.conversationResolve.success}}}**
{{#progress.conversationResolve.error}}
- 🔴 أخطاء: **{{{progress.conversationResolve.error}}}**
{{/progress.conversationResolve.error}}
{{/options.resolveConversations}}
التفاصيل:
[{{{details.text}}}]({{{details.url}}})
+463 -37
View File
@@ -1,8 +1,33 @@
# Bengali translations for ChatWoot integration
# Bengali translations for WhatsApp inbox integration
locale.name: '🇧🇩 বাংলা (Bengali)'
app.inbox.contact.name: 'হোয়াটসঅ্যাপ ইন্টিগ্রেশন (WAHA)'
# বেস লোকেল কোড - এটি একটি বৈধ BCP 47 লোকেল হতে হবে
# https://en.wikipedia.org/wiki/IETF_language_tag
# WAHA এটি সংখ্যা, মুদ্রা এবং তারিখ/সময়ের ফরম্যাটিংয়ে ব্যবহার করে।
locale.base: 'bn-BD'
app.inbox.contact.name: 'হোয়াটসঅ্যাপ ইন্টিগ্রেশন'
app.inbox.contact.avatar.url: 'https://raw.githubusercontent.com/devlikeapro/waha/core/logo.png'
# date.toLocaleString এর "options"
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options
datetime:
weekday: short
locales: bn-BD
day: 2-digit
month: short
year: numeric
hour: 2-digit
minute: 2-digit
hour12: true
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#timezonename
timeZoneName: short
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# timeZone: 'Asia/Dhaka'
Today: 'আজ'
Yesterday: 'গতকাল'
job.report.error: |-
⚠️ **{{{header}}}**
{{#attempts.nextDelay}}
@@ -31,14 +56,14 @@ whatsapp.message.removing.error: হোয়াটসঅ্যাপ থেক
job.scheduled.error.header: নির্ধারিত কাজ সম্পাদন করতে ব্যর্থ হয়েছে
message.from.whatsapp: |-
{{{text}}}
*📱 হোয়াটসঅ্যাপ থেকে পাঠানো হয়েছে*
📱 *হোয়াটসঅ্যাপ থেকে পাঠানো হয়েছে*
{{{text}}}
message.from.api: |-
{{{text}}}
*📱 API এর মাধ্যমে পাঠানো হয়েছে*
📱 *API এর মাধ্যমে পাঠানো হয়েছে*
{{{text}}}
message.removed.in.whatsapp: |-
❌ *এই বার্তাটি হোয়াটসঅ্যাপে মুছে ফেলা হয়েছে*
@@ -66,11 +91,11 @@ whatsapp.contact.broadcast.suffix: 'প্রচার তালিকা'
whatsapp.contact.status.name: 'স্ট্যাটাস'
app.connected.message: |-
🔗 **সংযুক্ত: হোয়াটসঅ্যাপ সেশন থেকে ChatWoot ইনবক্স**
🔗 **সংযুক্ত: হোয়াটসঅ্যাপ সেশন থেকে ইনবক্স**
✅ হোয়াটসঅ্যাপ সেশন **{{{name}}}** সফলভাবে এই ChatWoot ইনবক্সের সাথে সংযুক্ত করা হয়েছে।
✅ হোয়াটসঅ্যাপ সেশন **{{{name}}}** সফলভাবে এই ইনবক্সের সাথে সংযুক্ত করা হয়েছে।
এই ইনবক্সটি এখন আপনার **হোয়াটসঅ্যাপ অ্যাকাউন্টের** সাথে সংযুক্ত। আপনি এখান থেকে সরাসরি বার্তা পাঠাতে এবং গ্রহণ করতে পারেন — ঠিক হোয়াটসঅ্যাপ ব্যবহার করার মতো, কিন্তু ChatWoot এর ভিতরে।
এই ইনবক্সটি এখন আপনার **হোয়াটসঅ্যাপ অ্যাকাউন্টের** সাথে সংযুক্ত। আপনি এখান থেকে সরাসরি বার্তা পাঠাতে এবং গ্রহণ করতে পারেন — ঠিক হোয়াটসঅ্যাপ ব্যবহার করার মতো, কিন্তু এই ওয়ার্কস্পেসের ভিতরে।
আপনি এই ইনবক্সের সাথে সংযুক্ত হোয়াটসঅ্যাপ সেশনও পরিচালনা করতে পারেন। উপলব্ধ কার্যগুলি দেখতে, এই চ্যাটে **help** পাঠান।
@@ -84,9 +109,9 @@ app.connected.message: |-
চলুন শুরু করা যাক! 🚀
app.disconnected.message: |-
🔌 **সংযোগ বিচ্ছিন্ন: হোয়াটসঅ্যাপ সেশন থেকে ChatWoot ইনবক্স**
🔌 **সংযোগ বিচ্ছিন্ন: হোয়াটসঅ্যাপ সেশন থেকে ইনবক্স**
❌ হোয়াটসঅ্যাপ সেশন **{{{name}}}** এই ChatWoot ইনবক্স থেকে সংযোগ বিচ্ছিন্ন করা হয়েছে।
❌ হোয়াটসঅ্যাপ সেশন **{{{name}}}** এই ইনবক্স থেকে সংযোগ বিচ্ছিন্ন করা হয়েছে।
আপনি আর হোয়াটসঅ্যাপ থেকে বার্তা পাঠাতে বা গ্রহণ করতে পারবেন না।
@@ -97,33 +122,155 @@ app.updated.message: |-
✅ হোয়াটসঅ্যাপ সেশন **{{{name}}}** এর সেটিংস আপডেট করা হয়েছে।
app.commands.list: |-
আপনি নিম্নলিখিত কমান্ডগুলির একটি পাঠিয়ে **হোয়াটসঅ্যাপ ইন্টিগ্রেশন** পরিচালনা করতে পারেন:
cli.usage.arguments: |-
[অ্যাকশন]
cli.usage.command: |-
[কমান্ড]
cli.usage.options: |-
[বিকল্প]
cli.cmd.disabled: ⛔ **`{{command}}` কমান্ডটি নিষ্ক্রিয় করা হয়েছে**।
অনুগ্রহ করে আপনার প্রশাসকের সঙ্গে যোগাযোগ করুন।
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
cli.cmd.root.description: |-
আপনার **হোয়াটসঅ্যাপ ইন্টিগ্রেশন** পরিচালনা করুন!
cli.cmd.root.sub.session: |-
🖥️ **সেশন**:
1. **status** - বর্তমান সেশনের স্ট্যাটাস পান।
2. **restart** - সেশন পুনরায় চালু করুন - এটি আপনার হোয়াটসঅ্যাপ অ্যাকাউন্ট পুনরায় সংযোগ করার চেষ্টা করবে।
3. **start** - সেশন শুরু করুন।
4. **stop** - সেশন বন্ধ করুন।
5. **logout** - সেশন থেকে লগআউট করুন, যাতে আপনি QR কোড স্ক্যান করতে পারেন এবং সেশন মেরামত করতে পারেন।
6. **qr** - সেশনের জন্য QR কোড পান।
7. **screenshot** - সেশনের স্ক্রিনশট পান (শুধুমাত্র **WEBJS** ইঞ্জিনে উপলব্ধ)।
8. **help** - উপলব্ধ কমান্ডগুলি।
{{#commands.server}}
⚙️ সার্ভার:
- **server status** - সার্ভারের সংস্করণ এবং স্ট্যাটাস তথ্য পান।
- **server reboot** - সার্ভার সুন্দরভাবে পুনরায় চালু করুন।
- **server reboot force** - সার্ভার জোর করে পুনরায় চালু করুন।
{{/commands.server}}
cli.cmd.root.sub.server: |-
🖥️ **সার্ভার**:
এই কথোপকথনে একটি **কমান্ড** (কেস সংবেদনশীল নয়) বা **কমান্ড নম্বর** পাঠান।
cli.cmd.root.sub.sync: |-
🔄️ **সিঙ্ক**:
app.commands.server.disabled: ⛔ **সার্ভার কমান্ড নিষ্ক্রিয় করা হয়েছে**।
cli.cmd.session.status.description: |-
বর্তমান সেশনের স্ট্যাটাস দেখুন।
দয়া করে আপনার প্রশাসকের সাথে যোগাযোগ করুন।
cli.cmd.session.restart.description: |-
সেশন পুনরায় চালু করুন।
উপলব্ধ কমান্ডের তালিকা পেতে **help** পাঠান।
cli.cmd.session.start.description: |-
সেশন শুরু করুন।
cli.cmd.session.stop.description: |-
সেশন বন্ধ করুন।
cli.cmd.session.logout.description: |-
সংযোগ পুনরুদ্ধার করতে নতুন QR কোড স্ক্যান করার জন্য সেশন থেকে লগ আউট করুন।
cli.cmd.session.qr.description: |-
সেশনের QR কোড দেখান।
cli.cmd.session.screenshot.description: |-
সেশনের স্ক্রিনশট নিন (শুধুমাত্র **WEBJS** ইঞ্জিনে উপলব্ধ)।
cli.cmd.contacts.summary: |-
হোয়াটসঅ্যাপ কনট্যাক্টস আনুন।
cli.cmd.contacts.description: |-
👤 সিঙ্ক শুরু করতে `contacts pull` চালান বা অগ্রগতি দেখতে `contacts status` চালান।
cli.cmd.contacts.action.description: |-
`start` দিয়ে কনট্যাক্ট টানা শুরু করুন বা `status` দিয়ে সক্রিয় কাজ পরীক্ষা করুন।
cli.cmd.contacts.pull.description: |-
হোয়াটসঅ্যাপ কনট্যাক্টস ইনবক্সে আনা শুরু করুন।
cli.cmd.contacts.pull.option.batch: |-
প্রতি ব্যাচে কত কনট্যাক্ট টানা হবে।
cli.cmd.contacts.pull.option.delay-contact: |-
প্রতিটি কনট্যাক্ট সিঙ্কের মাঝে বিলম্ব, যাতে অ্যাভাটার টানার সময় রেট লিমিট এড়ানো যায়।
cli.cmd.contacts.pull.option.delay-batch: |-
প্রতিটি ব্যাচের মাঝে বিলম্ব।
cli.cmd.contacts.pull.option.avatar: |-
কনট্যাক্টের অ্যাভাটার কীভাবে টানবেন নির্ধারণ করুন: `skip`, `if-missing`, অথবা `update`।
cli.cmd.contacts.pull.option.groups: |-
সিঙ্কে WhatsApp গ্রুপ কনট্যাক্ট অন্তর্ভুক্ত করুন।
cli.cmd.contacts.pull.option.no-attributes: |-
কনট্যাক্ট আগেই থাকলে তার অ্যাট্রিবিউট টানবেন না (এতে প্রক্রিয়া দ্রুত হয়)।
cli.cmd.contacts.pull.already-running: |-
⏳ **Pull Contacts** কাজটি ইতিমধ্যেই চলছে। এটি শেষ হওয়া পর্যন্ত অপেক্ষা করুন বা নতুন কাজ শুরু করার আগে থামান।
বিস্তারিত জানতে `contacts status` পাঠান।
cli.cmd.options.job.attempts: |-
কোনো কাজ ব্যর্থ ঘোষণা করার আগে কয়বার পুনরায় চেষ্টা করা হবে।
cli.cmd.options.job.timeout: |-
কাজ পুনরায় চেষ্টা করার আগে সর্বোচ্চ চালনার সময় (যেমন `30s`, `5m`, `1h`)।
cli.cmd.contacts.status.description: |-
চলমান টানার কাজের অবস্থা দেখান।
cli.cmd.contacts.status.not-found: |-
ℹ️ কোনো সক্রিয় **Pull Contacts** কাজ পাওয়া যায়নি। `contacts pull` দিয়ে একটি শুরু করুন।
cli.cmd.server.description: |-
সার্ভার পরিচালনা করুন।
cli.cmd.server.status.description: |-
সার্ভারের সংস্করণ এবং স্ট্যাটাস দেখান।
cli.cmd.server.reboot.description: |-
সার্ভার পুনরায় চালু করুন।
cli.cmd.server.reboot.option.force: |-
অবিলম্বে রিবুট করতে বাধ্য করুন।
cli.help.usage.title: |-
ব্যবহার: কথোপকথনে নীচের ফরম্যাটে একটি কমান্ড পাঠান:
cli.help.commands.defaultGroup: |-
**কমান্ডসমূহ:**
cli.help.arguments.title: |-
**আর্গুমেন্টসমূহ:**
cli.help.options.defaultGroup: |-
**বিকল্পসমূহ:**
cli.help.globalOptions.title: |-
**গ্লোবাল বিকল্পসমূহ:**
task.contacts.status: |-
👤 কনট্যাক্ট টানা: **{{state}}**
- ✅ সফল: {{{progress.ok}}} কনট্যাক্ট
- ⚪ এড়িয়ে গেছে: {{{progress.skipped}}} কনট্যাক্ট
- ⚠️ ত্রুটি: {{{progress.errors}}} কনট্যাক্ট
{{#job}}
⌛ সময়রেখা:
- কিউ হয়েছে: {{{timestamp}}}
{{#processedOn}}
- শুরু হয়েছে: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- শেষ হয়েছে: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
বিস্তারিত:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
কনট্যাক্ট টানা হচ্ছে...
task.contacts.progress: |-
কনট্যাক্ট টানা: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
app.help.reminder: |-
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
@@ -138,17 +285,17 @@ app.session.current.status: |-
📱 **{{{name}}} ({{{id}}})**
app.session.status.working: |-
✅ এই ChatWoot ইনবক্স **হোয়াটসঅ্যাপ অ্যাকাউন্টের** সাথে সংযুক্ত এবং বার্তা পাঠাতে ও গ্রহণ করতে প্রস্তুত!
✅ এই ইনবক্সটি আপনার **হোয়াটসঅ্যাপ অ্যাকাউন্টের** সাথে সংযুক্ত এবং বার্তা পাঠাতে ও গ্রহণ করতে প্রস্তুত!
📱 **{{{name}}} ({{{id}}})**
app.session.status.error: |-
⚠️ **এই ChatWoot ইনবক্স হোয়াটসঅ্যাপ অ্যাকাউন্টের সাথে সংযুক্ত কিন্তু বার্তা পাঠাতে ও গ্রহণ করতে প্রস্তুত নয়!**
⚠️ **এই ইনবক্সটি হোয়াটসঅ্যাপ অ্যাকাউন্টের সাথে সংযুক্ত কিন্তু বার্তা পাঠাতে ও গ্রহণ করতে প্রস্তুত নয়!**
অনুগ্রহ করে সেশনের স্ট্যাটাস পরীক্ষা করুন এবং এটি পুনরায় চালু করার চেষ্টা করুন অথবা লগআউট করুন এবং QR কোড আবার স্ক্যান করুন।
app.session.scan.qr.code: |-
⚠️ **এই ChatWoot ইনবক্সের সাথে হোয়াটসঅ্যাপ অ্যাকাউন্ট লিঙ্ক করতে আপনার ফোনে QR কোড স্ক্যান করুন!**
⚠️ **এই ইনবক্সের সাথে হোয়াটসঅ্যাপ অ্যাকাউন্ট লিঙ্ক করতে আপনার ফোনে QR কোড স্ক্যান করুন!**
app.server.version.and.status: |-
**সংস্করণ:**
@@ -216,7 +363,19 @@ whatsapp.to.chatwoot.message.location: |-
{{#locationMessage}}
📍 **শেয়ার করা লোকেশন**
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **ঠিকানা:** {{address}}
{{/address}}
{{#url}}
🌐 **ওয়েবসাইট:** {{url}}
{{/url}}
🔗 **মানচিত্রে দেখুন:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/locationMessage}}
@@ -224,13 +383,280 @@ whatsapp.to.chatwoot.message.location: |-
📍📡 **লাইভ লোকেশন শেয়ার করা হচ্ছে**
রিয়েল-টাইম আপডেট দেখতে **WhatsApp** খুলুন।
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **ঠিকানা:** {{address}}
{{/address}}
{{#url}}
🌐 **ওয়েবসাইট:** {{url}}
{{/url}}
🔗 **মানচিত্রে দেখুন:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/liveLocationMessage}}
{{/message}}
whatsapp.to.chatwoot.message.poll: |-
{{#poll}}
📊 **জরিপ**
**{{name}}**
{{#options}}
- {{optionName}}
{{/options}}
{{/poll}}
whatsapp.to.chatwoot.message.list: |-
{{#message.listMessage}}
{{#title}}
*{{{title}}}*
{{/title}}
{{#description}}
{{{description}}}
{{/description}}
{{#footerText}}
{{{footerText}}}
{{/footerText}}
{{#buttonText}}
{{{buttonText}}}
{{/buttonText}}
{{#sections}}
{{#title}}
{{{title}}}:
{{/title}}
{{#rows}}
{{#title}}
- {{{title}}}
{{/title}}
{{/rows}}
{{/sections}}
{{/message.listMessage}}
whatsapp.to.chatwoot.message.event: |-
{{#message}}
{{#eventMessage}}
📅 **ইভেন্ট**
**{{name}}**
{{#description}}
{{description}}
{{/description}}
{{#location.name}}
📍 **স্থান:** {{location.name}}
{{/location.name}}
{{#startTime}}
🕐 **শুরুর সময়:** {{startTime}}
{{/startTime}}
{{#endTime}}
🕐 **শেষের সময়:** {{endTime}}
{{/endTime}}
{{#joinLink}}
🔗 **যোগদানের লিঙ্ক:** [{{joinLink}}]({{joinLink}})
{{/joinLink}}
{{/eventMessage}}
{{/message}}
whatsapp.to.chatwoot.message.pix: |-
💳 **PIX - কপি এবং পেস্ট**
{{#pixData}}
{{#merchantName}}
**{{merchantName}}**
{{/merchantName}}
{{#key}}
**PIX কী:** {{key}}
{{/key}}
{{#keyType}}
**ধরন:** {{keyType}}
{{/keyType}}
{{#totalAmount}}
**পরিমাণ:** {{totalAmountFormatted}}
{{/totalAmount}}
{{#referenceId}}
**রেফারেন্স:** {{referenceId}}
{{/referenceId}}
💳 **PIX কপি এবং পেস্ট পাঠানো হয়েছে**
{{/pixData}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **এই ধরনের বার্তা ChatWoot-এ সমর্থিত নয়।**
⚠️ **এই ধরনের বার্তা এই ইনবক্সে সমর্থিত নয়।**
📱 এটি দেখতে অনুগ্রহ করে **WhatsApp** খুলুন।
বিস্তারিত: [{{{details.text}}}]({{{details.url}}})
whatsapp.to.chatwoot.message.album: |-
📷 **অ্যালবাম বার্তা প্রাপ্ত**
{{#totalCount}}
**{{totalCount}}** মিডিয়া ফাইল সহ একটি অ্যালবাম প্রাপ্ত হয়েছে।
{{#expectedImageCount}}
📸 ছবি: **{{expectedImageCount}}**
{{/expectedImageCount}}
{{#expectedVideoCount}}
🎥 ভিডিও: **{{expectedVideoCount}}**
{{/expectedVideoCount}}
মিডিয়া ফাইলগুলি পরবর্তী বার্তাগুলিতে সরবরাহ করা হবে।
{{/totalCount}}
whatsapp.to.chatwoot.message.facebook.ad: |-
📢 **ফেসবুক বিজ্ঞাপন**
**শিরোনাম:** {{{adData.title}}}
**বর্ণনা:**
{{{adData.body}}}
**বিজ্ঞাপনের লিংক:** [ফেসবুকে দেখুন]({{{adData.sourceUrl}}})
**বিজ্ঞাপনের আইডি:** {{{adData.sourceId}}}
---
💬 **গ্রাহকের বার্তা:**
{{{payload.body}}}
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
UNKNOWN: অজানা
ERROR: ত্রুটি
PENDING: পাঠানো হচ্ছে
SERVER: সার্ভার
DEVICE: ডিভাইস
READ: পড়া হয়েছে
PLAYED: চালানো হয়েছে
whatsapp.to.chatwoot.message.has.media.no.media: |-
{{#content}}
{{{content}}}
{{/content}}
🖼️⚠️ **বার্তায় মিডিয়া আছে, কিন্তু আমরা এটি ডাউনলোড করতে পারিনি**
📱 দেখার জন্য অনুগ্রহ করে **WhatsApp** খুলুন।
{{^content}}
বিস্তারিত: [{{{details.text}}}]({{{details.url}}})
{{/content}}
cli.cmd.root.sub.queue: '🧩 **কিউ**:'
cli.cmd.messages.summary: WhatsApp বার্তা টানুন।
cli.cmd.messages.description:
💬 বার্তা সিঙ্ক শুরু করতে `messages pull` চালান অথবা অগ্রগতি দেখতে `messages
status` চালান।
cli.cmd.messages.action.description:
বার্তা টানতে `pull` ব্যবহার করুন অথবা সক্রিয় কাজ দেখার জন্য `status`।
cli.cmd.messages.pull.option.chat:
নির্দিষ্ট WhatsApp চ্যাট আইডিতে টান সীমাবদ্ধ করুন।
cli.cmd.messages.pull.option.force:
WhatsApp বার্তা কথোপকথনের ম্যাপিংয়ে থাকলেও ইনবক্সকে বার্তা তৈরি করতে বাধ্য
করুন।
cli.cmd.messages.pull.option.batch: প্রতিটি ব্যাচে কতটি বার্তা টানবেন।
cli.cmd.messages.pull.option.progress:
প্রতি N বার্তায় অগ্রগতি জানায়। রিপোর্ট বন্ধ করতে 0 সেট করুন।
cli.cmd.messages.pull.option.no-dm: ইতিহাস টানার সময় সরাসরি বার্তা উপেক্ষা করুন।
cli.cmd.messages.pull.option.groups:
ইতিহাস টানার সময় WhatsApp গ্রুপ অন্তর্ভুক্ত করুন।
cli.cmd.messages.pull.option.channels:
ইতিহাস টানার সময় WhatsApp চ্যানেল অন্তর্ভুক্ত করুন।
cli.cmd.messages.pull.option.status:
ইতিহাস টানার সময় WhatsApp স্ট্যাটাস আপডেট অন্তর্ভুক্ত করুন।
cli.cmd.messages.pull.option.broadcast:
ইতিহাস টানার সময় ব্রডকাস্ট তালিকা অন্তর্ভুক্ত করুন।
cli.cmd.messages.pull.option.media:
ইতিহাস টানার সময় মিডিয়া (ছবি, ভিডিও, ফাইল) ডাউনলোড করুন।
cli.cmd.messages.pull.option.pause:
বার্তা টানার কাজটিকে বিরতি অবস্থায় কিউতে রাখে এবং শেষ না হওয়া পর্যন্ত নতুন
WhatsApp বার্তার প্রসেসিং বন্ধ রাখে।
cli.cmd.messages.pull.option.resolve-conversations:
সমস্ত ইতিহাস বার্তা প্রক্রিয়ার পর কথোপকথনগুলোকে সমাধান হয়েছে হিসেবে চিহ্নিত
করে।
cli.cmd.messages.pull.error.pause-no-queue:
⛔ `--pause` ব্যবহার করা যাবে না কারণ এই কনফিগারেশনে **queue** কমান্ড
নিষ্ক্রিয়।
cli.cmd.queue.description: কিউ পরিচালনা করুন।
cli.cmd.queue.summary: কিউ-তে কর্ম সম্পাদন করুন।
cli.cmd.queue.action.description: একটি কর্ম নির্বাচন করুন।
cli.cmd.queue.status.description: কিউ-র অবস্থা দেখুন।
cli.cmd.queue.argument.name: 'কিউ নাম: `inbox` বা `whatsapp` (ঐচ্ছিক)।'
cli.cmd.queue.start.description: নামের ভিত্তিতে কিউ শুরু করুন।
cli.cmd.queue.stop.description: নামের ভিত্তিতে কিউ বন্ধ করুন।
cli.cmd.queue.paused: ⚪🧩 কিউ বিরতিতে আছে। অবস্থা দেখতে `queue status` পাঠান।
cli.cmd.queue.resumed:
🟢🧩 কিউ আবার চালু হয়েছে। অবস্থা দেখতে `queue status` পাঠান।
cli.cmd.queue.status.result: >-
📊 কিউ অবস্থা:
{{#queues}}
- **{{name}}**: {{#locked}}🔒{{/locked}}{{#paused}}⚪
বিরতিতে{{/paused}}{{^paused}}🟢 চলমান{{/paused}}
{{/queues}}
cli.cmd.messages.pull.option.timeout-media:
প্রতিটি বার্তার জন্য মিডিয়া ডাউনলোডের সময়সীমা নির্ধারণ করুন।
cli.cmd.messages.pull.argument.end:
টানার উইন্ডো কোথায় শেষ হবে নির্ধারণ করুন; যেমন `2d` দুই দিন আগে থামে।
cli.cmd.messages.pull.argument.start:
টানার উইন্ডো কোথা থেকে শুরু হবে নির্ধারণ করুন; যেমন `1d` এক দিন আগে শুরু হয়
(ডিফল্ট `0d`, অর্থাৎ এখন)।
cli.cmd.messages.pull.already-running: >-
⏳ **Pull Messages** কাজটি ইতিমধ্যে **{{{chat}}}**-এর জন্য চলছে। শেষ হওয়া
পর্যন্ত অপেক্ষা করুন অথবা নতুন শুরু করার আগে এটি বন্ধ করুন।
বিস্তারিত জানার জন্য `messages status` পাঠান।
cli.cmd.messages.status.not-found:
ℹ️ কোনো সক্রিয় **Pull Messages** কাজ পাওয়া যায়নি। `messages pull` দিয়ে একটি
শুরু করুন।
cli.cmd.messages.pull.no-chats-found:
এই সময়সীমার জন্য কোনো WhatsApp চ্যাট পাওয়া যায়নি।
cli.cmd.contacts.pull.option.progress:
প্রতি N কন্টাক্টে অগ্রগতি জানান। রিপোর্ট বন্ধ করতে 0 সেট করুন।
cli.cmd.contacts.pull.option.lids:
সিঙ্কে WhatsApp LID (অনামা) কন্টাক্ট অন্তর্ভুক্ত করুন।
task.contacts.completed:
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ বার্তা টানা হচ্ছে 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: আরও জানতে "{{{prefix}}}messages status" পাঠান।
task.messages.progress:
'⏳ বার্তা {{{chat}}}: 🆕 {{progress.ok}}, 🟢 {{progress.exists}}, ⚪
{{progress.ignored}}, 🔴 {{progress.errors}}{{#messages.last}} | 📅
{{{messages.last}}}{{/messages.last}}'
task.messages.completed:
✅ বার্তা {{{chat}}} {{{period}}}-এ → 🆕 {{progress.ok}}, 🟢
{{progress.exists}}, ⚪ {{progress.ignored}}, 🔴 {{progress.errors}}
task.messages.status: |-
**{{{chat}}}**-এর বার্তা ⏱️ **{{{period}}}** সময়ে। ⚙️ কাজের অবস্থা: **{{state}}**.
🗨️ বার্তা:
- 🆕 তৈরি: **{{{progress.ok}}}**
- 🟢 আগেই ছিল: {{{progress.exists}}}
- ⚪ উপেক্ষা: {{{progress.ignored}}}
- 🔴 ত্রুটি: {{{progress.errors}}}
{{#messages.first}}
- 🗓️ প্রাচীনতম: **{{{messages.first}}}**
{{/messages.first}}
{{#messages.last}}
- 🗓️ সর্বশেষ: **{{{messages.last}}}**
{{/messages.last}}
💬 WhatsApp চ্যাট:
- 🆕 **{{{chats}}}** চ্যাটে নতুন বার্তা
{{#options.resolveConversations}}
🗣️ কথোপকথন:
- 🟢 সমাধান হয়েছে: **{{{progress.conversationResolve.success}}}**
{{#progress.conversationResolve.error}}
- 🔴 ত্রুটি: **{{{progress.conversationResolve.error}}}**
{{/progress.conversationResolve.error}}
{{/options.resolveConversations}}
বিস্তারিত:
[{{{details.text}}}]({{{details.url}}})
+482 -37
View File
@@ -1,8 +1,33 @@
# German translations for ChatWoot integration
# German translations for WhatsApp inbox integration
locale.name: '🇩🇪 Deutsch (German)'
app.inbox.contact.name: 'WhatsApp Integration (WAHA)'
# Basis-Gebietsschema-Code - muss eine gültige BCP 47 Locale sein
# https://en.wikipedia.org/wiki/IETF_language_tag
# WAHA nutzt ihn zur Darstellung von Zahlen, Währungen sowie Datum und Uhrzeit
locale.base: 'de-DE'
app.inbox.contact.name: 'WhatsApp Integration'
app.inbox.contact.avatar.url: 'https://raw.githubusercontent.com/devlikeapro/waha/core/logo.png'
# "Optionen" von date.toLocaleString
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options
datetime:
weekday: short
locales: de-DE
day: 2-digit
month: short
year: numeric
hour: 2-digit
minute: 2-digit
hour12: false
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#timezonename
timeZoneName: short
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# timeZone: 'Europe/Berlin'
Today: 'Heute'
Yesterday: 'Gestern'
job.report.error: |-
⚠️ **{{{header}}}**
{{#attempts.nextDelay}}
@@ -34,14 +59,14 @@ whatsapp.message.removing.error:
job.scheduled.error.header: Geplante Aufgabe konnte nicht ausgeführt werden
message.from.whatsapp: |-
{{{text}}}
*📱 Von WhatsApp gesendet*
📱 *Von WhatsApp gesendet*
{{{text}}}
message.from.api: |-
{{{text}}}
*📱 Über API gesendet*
📱 *Über API gesendet*
{{{text}}}
message.removed.in.whatsapp: |-
❌ *Diese Nachricht wurde in WhatsApp gelöscht*
@@ -69,11 +94,11 @@ whatsapp.contact.broadcast.suffix: 'Broadcast-Liste'
whatsapp.contact.status.name: 'Status'
app.connected.message: |-
🔗 **VERBUNDEN: WhatsApp-Sitzung mit ChatWoot Inbox**
🔗 **VERBUNDEN: WhatsApp-Sitzung mit der Inbox**
✅ WhatsApp-Sitzung **{{{name}}}** wurde erfolgreich mit dieser ChatWoot Inbox verbunden.
✅ WhatsApp-Sitzung **{{{name}}}** wurde erfolgreich mit dieser Inbox verbunden.
Diese Inbox ist jetzt mit Ihrem **WhatsApp-Konto** verknüpft. Sie können Nachrichten direkt von hier senden und empfangen — genau wie bei WhatsApp, aber innerhalb von ChatWoot.
Diese Inbox ist jetzt mit Ihrem **WhatsApp-Konto** verknüpft. Sie können Nachrichten direkt von hier senden und empfangen — genau wie bei WhatsApp, aber innerhalb Ihrer Arbeitsumgebung.
Sie können auch die mit dieser Inbox verknüpfte WhatsApp-Sitzung verwalten. Um verfügbare Aktionen anzuzeigen, senden Sie **help** an diesen Chat.
@@ -87,9 +112,9 @@ app.connected.message: |-
Lassen Sie uns beginnen! 🚀
app.disconnected.message: |-
🔌 **GETRENNT: WhatsApp-Sitzung von ChatWoot Inbox**
🔌 **GETRENNT: WhatsApp-Sitzung von der Inbox**
❌ WhatsApp-Sitzung **{{{name}}}** wurde von dieser ChatWoot Inbox getrennt.
❌ WhatsApp-Sitzung **{{{name}}}** wurde von dieser Inbox getrennt.
Sie können keine Nachrichten mehr von WhatsApp senden oder empfangen.
@@ -100,34 +125,156 @@ app.updated.message: |-
✅ Einstellungen für WhatsApp-Sitzung **{{{name}}}** wurden aktualisiert.
app.commands.list: |-
Sie können die **WhatsApp Integration** verwalten, indem Sie einen der folgenden Befehle senden:
cli.usage.arguments: |-
[Aktion]
🖥️ **Sitzung**:
1. **status** - den aktuellen Sitzungsstatus abrufen.
2. **restart** - die Sitzung neu starten - dies versucht, Ihr WhatsApp-Konto neu zu verbinden.
3. **start** - die Sitzung starten.
4. **stop** - die Sitzung stoppen.
5. **logout** - von der Sitzung abmelden, damit Sie den QR-Code scannen und die Sitzung reparieren können.
6. **qr** - den QR-Code für die Sitzung abrufen.
7. **screenshot** - einen Screenshot der Sitzung erhalten (nur in der **WEBJS**-Engine verfügbar).
8. **help** - verfügbare Befehle.
cli.usage.command: |-
[Befehl]
{{#commands.server}}
⚙️ Server:
- **server status** - Serverversion und Statusinformationen abrufen.
- **server reboot** - Server ordnungsgemäß neu starten.
- **server reboot force** - Server erzwungen neu starten.
{{/commands.server}}
cli.usage.options: |-
[Optionen]
Senden Sie einen **Befehl** (Groß-/Kleinschreibung wird nicht beachtet) oder eine **Befehlsnummer** an diese Konversation.
app.commands.server.disabled: ⛔ **Serverbefehle sind deaktiviert**.
cli.cmd.disabled: ⛔ **Der Befehl `{{command}}` ist deaktiviert**.
Bitte wenden Sie sich an Ihren Administrator.
Senden Sie **help**, um die Liste der verfügbaren Befehle zu erhalten.
cli.cmd.root.description: |-
Verwalten Sie Ihre **WhatsApp-Integration**!
cli.cmd.root.sub.session: |-
🖥️ **Sitzung**:
cli.cmd.root.sub.server: |-
🖥️ **Server**:
cli.cmd.root.sub.sync: |-
🔄️ **Synchronisierung**:
cli.cmd.session.status.description: |-
Aktuellen Sitzungsstatus abrufen.
cli.cmd.session.restart.description: |-
Sitzung neu starten.
cli.cmd.session.start.description: |-
Sitzung starten.
cli.cmd.session.stop.description: |-
Sitzung stoppen.
cli.cmd.session.logout.description: |-
Melden Sie die Sitzung ab, um einen neuen QR-Code zu scannen und die Verbindung wiederherzustellen.
cli.cmd.session.qr.description: |-
QR-Code der Sitzung anzeigen.
cli.cmd.session.screenshot.description: |-
Einen Screenshot der Sitzung aufnehmen (nur mit der **WEBJS**-Engine verfügbar).
cli.cmd.contacts.summary: |-
WhatsApp-Kontakte abrufen.
cli.cmd.contacts.description: |-
👤 Führen Sie `contacts pull` aus, um eine Synchronisierung zu starten, oder `contacts status`, um den Fortschritt zu prüfen.
cli.cmd.contacts.action.description: |-
Mit `start` den Abruf beginnen oder mit `status` den aktiven Auftrag prüfen.
cli.cmd.contacts.pull.description: |-
Beginnen Sie, WhatsApp-Kontakte in die Inbox zu importieren.
cli.cmd.contacts.pull.option.batch: |-
Anzahl der Kontakte pro Batch.
cli.cmd.contacts.pull.option.delay-contact: |-
Verzögerung zwischen einzelnen Kontakten, um beim Laden von Avataren Rate Limits zu vermeiden.
cli.cmd.contacts.pull.option.delay-batch: |-
Verzögerung zwischen Kontakt-Batches.
cli.cmd.contacts.pull.option.avatar: |-
Legen Sie fest, wie Avatare geladen werden: `skip`, `if-missing` oder `update`.
cli.cmd.contacts.pull.option.groups: |-
WhatsApp-Gruppenkontakte in die Synchronisierung einbeziehen.
cli.cmd.contacts.pull.option.no-attributes: |-
Keine Kontaktattribute laden, wenn der Kontakt bereits existiert (beschleunigt den Vorgang).
cli.cmd.contacts.pull.already-running: |-
⏳ Der Auftrag **Pull Contacts** läuft bereits. Warten Sie, bis er abgeschlossen ist, oder stoppen Sie ihn, bevor Sie einen neuen starten.
Senden Sie `contacts status` für Details.
cli.cmd.options.job.attempts: |-
Anzahl der erneuten Versuche, bevor der Auftrag als fehlgeschlagen markiert wird.
cli.cmd.options.job.timeout: |-
Maximale Laufzeit, bevor der Auftrag erneut versucht wird (unterstützt Werte wie `30s`, `5m`, `1h`).
cli.cmd.contacts.status.description: |-
Status des aktuellen Abrufauftrags anzeigen.
cli.cmd.contacts.status.not-found: |-
ℹ️ Kein aktiver **Pull Contacts** Auftrag gefunden. Starten Sie einen mit `contacts pull`.
cli.cmd.server.description: |-
Server verwalten.
cli.cmd.server.status.description: |-
Serverversion und -status anzeigen.
cli.cmd.server.reboot.description: |-
Server neu starten.
cli.cmd.server.reboot.option.force: |-
Neustart sofort erzwingen.
cli.help.usage.title: |-
Verwendung: Senden Sie einen Befehl im folgenden Format an die Konversation:
cli.help.commands.defaultGroup: |-
**Befehle:**
cli.help.arguments.title: |-
**Argumente:**
cli.help.options.defaultGroup: |-
**Optionen:**
cli.help.globalOptions.title: |-
**Globale Optionen:**
task.contacts.status: |-
👤 Kontakte abrufen: **{{state}}**
- ✅ Erfolgreich: {{{progress.ok}}} Kontakte
- ⚪ Übersprungen: {{{progress.skipped}}} Kontakte
- ⚠️ Fehler: {{{progress.errors}}} Kontakte
{{#job}}
⌛ Zeitplan:
- Eingereiht: {{{timestamp}}}
{{#processedOn}}
- Start: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Ende: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Details:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Kontakte werden abgerufen...
task.contacts.progress: |-
Kontakte abrufen: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
app.help.reminder: |-
Senden Sie **help**, um die Liste der verfügbaren Befehle zu erhalten.
@@ -141,17 +288,17 @@ app.session.current.status: |-
📱 **{{{name}}} ({{{id}}})**
app.session.status.working: |-
✅ Diese ChatWoot Inbox ist mit dem **WhatsApp-Konto** verknüpft und bereit zum Senden und Empfangen von Nachrichten!
✅ Diese Inbox ist mit Ihrem **WhatsApp-Konto** verknüpft und bereit zum Senden und Empfangen von Nachrichten!
📱 **{{{name}}} ({{{id}}})**
app.session.status.error: |-
⚠️ **Diese ChatWoot Inbox ist mit dem WhatsApp-Konto verknüpft, aber NICHT bereit zum Senden und Empfangen von Nachrichten!**
⚠️ **Diese Inbox ist mit dem WhatsApp-Konto verknüpft, aber NICHT bereit zum Senden und Empfangen von Nachrichten!**
Bitte überprüfen Sie den Sitzungsstatus und versuchen Sie, sie neu zu starten oder melden Sie sich ab und scannen Sie den QR-Code erneut.
app.session.scan.qr.code: |-
⚠️ **Scannen Sie den QR-Code auf Ihrem Telefon, um das WhatsApp-Konto mit dieser ChatWoot Inbox zu verknüpfen!**
⚠️ **Scannen Sie den QR-Code auf Ihrem Telefon, um das WhatsApp-Konto mit dieser Inbox zu verknüpfen!**
app.server.version.and.status: |-
**Version:**
@@ -219,7 +366,19 @@ whatsapp.to.chatwoot.message.location: |-
{{#locationMessage}}
📍 **Geteilter Standort**
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **Adresse:** {{address}}
{{/address}}
{{#url}}
🌐 **Webseite:** {{url}}
{{/url}}
🔗 **Auf Karte ansehen:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/locationMessage}}
@@ -227,13 +386,299 @@ whatsapp.to.chatwoot.message.location: |-
📍📡 **Live-Standort wird geteilt**
Öffne **WhatsApp**, um Aktualisierungen in Echtzeit zu sehen.
https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#name}}
**{{name}}**
{{/name}}
{{#address}}
📍 **Adresse:** {{address}}
{{/address}}
{{#url}}
🌐 **Webseite:** {{url}}
{{/url}}
🔗 **Auf Karte ansehen:** https://www.google.com/maps?q={{degreesLatitude}},{{degreesLongitude}}
{{#caption}}{{caption}}{{/caption}}
{{/liveLocationMessage}}
{{/message}}
whatsapp.to.chatwoot.message.poll: |-
{{#poll}}
📊 **Umfrage**
**{{name}}**
{{#options}}
- {{optionName}}
{{/options}}
{{/poll}}
whatsapp.to.chatwoot.message.list: |-
{{#message.listMessage}}
{{#title}}
*{{{title}}}*
{{/title}}
{{#description}}
{{{description}}}
{{/description}}
{{#footerText}}
{{{footerText}}}
{{/footerText}}
{{#buttonText}}
{{{buttonText}}}
{{/buttonText}}
{{#sections}}
{{#title}}
{{{title}}}:
{{/title}}
{{#rows}}
{{#title}}
- {{{title}}}
{{/title}}
{{/rows}}
{{/sections}}
{{/message.listMessage}}
whatsapp.to.chatwoot.message.event: |-
{{#message}}
{{#eventMessage}}
📅 **Veranstaltung**
**{{name}}**
{{#description}}
{{description}}
{{/description}}
{{#location.name}}
📍 **Ort:** {{location.name}}
{{/location.name}}
{{#startTime}}
🕐 **Beginn:** {{startTime}}
{{/startTime}}
{{#endTime}}
🕐 **Ende:** {{endTime}}
{{/endTime}}
{{#joinLink}}
🔗 **Teilnahmelink:** [{{joinLink}}]({{joinLink}})
{{/joinLink}}
{{/eventMessage}}
{{/message}}
whatsapp.to.chatwoot.message.pix: |-
💳 **PIX - Kopieren und Einfügen**
{{#pixData}}
{{#merchantName}}
**{{merchantName}}**
{{/merchantName}}
{{#key}}
**PIX-Schlüssel:** {{key}}
{{/key}}
{{#keyType}}
**Typ:** {{keyType}}
{{/keyType}}
{{#totalAmount}}
**Betrag:** {{totalAmountFormatted}}
{{/totalAmount}}
{{#referenceId}}
**Referenz:** {{referenceId}}
{{/referenceId}}
💳 **PIX Kopieren und Einfügen gesendet**
{{/pixData}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Dieser Nachrichtentyp wird in ChatWoot nicht unterstützt.**
⚠️ **Dieser Nachrichtentyp wird in dieser Inbox nicht unterstützt.**
📱 Bitte öffne **WhatsApp**, um ihn anzusehen.
Details: [{{{details.text}}}]({{{details.url}}})
whatsapp.to.chatwoot.message.album: |-
📷 **Album-Nachricht Empfangen**
{{#totalCount}}
Ein Album mit **{{totalCount}}** Mediendatei(en) erhalten.
{{#expectedImageCount}}
📸 Bilder: **{{expectedImageCount}}**
{{/expectedImageCount}}
{{#expectedVideoCount}}
🎥 Videos: **{{expectedVideoCount}}**
{{/expectedVideoCount}}
Die Mediendateien werden in den folgenden Nachrichten geliefert.
{{/totalCount}}
whatsapp.to.chatwoot.message.facebook.ad: |-
📢 **FACEBOOK-ANZEIGE**
**Titel:** {{{adData.title}}}
**Beschreibung:**
{{{adData.body}}}
**Anzeigenlink:** [Auf Facebook ansehen]({{{adData.sourceUrl}}})
**Anzeigen-ID:** {{{adData.sourceId}}}
---
💬 **Kundennachricht:**
{{{payload.body}}}
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
UNKNOWN: Unbekannt
ERROR: Fehler
PENDING: Wird gesendet
SERVER: Server
DEVICE: Gerät
READ: Gelesen
PLAYED: Abgespielt
whatsapp.to.chatwoot.message.has.media.no.media: >-
{{#content}}
{{{content}}}
{{/content}}
🖼️⚠️ **Diese Nachricht enthält Medien, konnte aber nicht heruntergeladen
werden**
📱 Bitte öffnen Sie **WhatsApp**, um sie anzusehen.
{{^content}}
Details: [{{{details.text}}}]({{{details.url}}})
{{/content}}
cli.cmd.root.sub.queue: '🧩 **Warteschlange**:'
cli.cmd.messages.summary: WhatsApp-Nachrichten abrufen.
cli.cmd.messages.description:
💬 Führen Sie `messages pull` aus, um eine Synchronisierung zu starten, oder
`messages status`, um den Fortschritt zu prüfen.
cli.cmd.messages.action.description:
'`pull`, um das Abrufen zu starten, oder `status`, um den aktuellen Job zu
prüfen.'
cli.cmd.messages.pull.option.chat:
Beschränkt das Abrufen auf eine bestimmte WhatsApp-Chat-ID.
cli.cmd.messages.pull.option.force:
Erzwingt, dass die Inbox Nachrichten erstellt, auch wenn die
WhatsApp-Nachricht bereits im Gesprächs-Mapping vorhanden ist.
cli.cmd.messages.pull.option.batch:
Anzahl der Nachrichten, die pro Paket abgerufen werden.
cli.cmd.messages.pull.option.progress:
Meldet den Fortschritt nach N Nachrichten. 0 deaktiviert die
Fortschrittsmeldungen.
cli.cmd.messages.pull.option.no-dm:
Ignoriert Direktnachrichten beim Abrufen des Verlaufs.
cli.cmd.messages.pull.option.groups:
Bezieht WhatsApp-Gruppen beim Abrufen mit ein.
cli.cmd.messages.pull.option.channels:
Bezieht WhatsApp-Kanäle beim Abrufen mit ein.
cli.cmd.messages.pull.option.status:
Bezieht WhatsApp-Statusmeldungen beim Abrufen mit ein.
cli.cmd.messages.pull.option.broadcast:
Bezieht Broadcast-Listen beim Abrufen mit ein.
cli.cmd.messages.pull.option.media:
Lädt Medienanhänge (Bilder, Videos, Dateien) beim Abrufen herunter.
cli.cmd.messages.pull.option.pause:
Stellt den Nachrichtenabruf in der Warteschlange auf Pause und stoppt neue
WhatsApp-Verarbeitung bis zum Abschluss.
cli.cmd.messages.pull.option.resolve-conversations:
Markiert die Gespräche nach Abschluss aller historischen Nachrichten als
erledigt.
cli.cmd.messages.pull.error.pause-no-queue:
⛔ `--pause` kann nicht verwendet werden, weil der **queue**-Befehl in dieser
Konfiguration deaktiviert ist.
cli.cmd.queue.description: Warteschlangen verwalten.
cli.cmd.queue.summary: Aktionen für Warteschlangen durchführen.
cli.cmd.queue.action.description: Wählen Sie eine Aktion.
cli.cmd.queue.status.description: Zeigt den Status einer Warteschlange an.
cli.cmd.queue.argument.name:
'Name der Warteschlange: `inbox` oder `whatsapp` (optional).'
cli.cmd.queue.start.description: Startet eine Warteschlange anhand des Namens.
cli.cmd.queue.stop.description: Stoppt eine Warteschlange anhand des Namens.
cli.cmd.queue.paused:
⚪🧩 Die Warteschlangen wurden pausiert. Senden Sie `queue status`, um den
Status zu prüfen.
cli.cmd.queue.resumed:
🟢🧩 Die Warteschlangen wurden fortgesetzt. Senden Sie `queue status`, um den
Status zu prüfen.
cli.cmd.queue.status.result: >-
📊 Warteschlangenstatus:
{{#queues}}
- **{{name}}**: {{#locked}}🔒{{/locked}}{{#paused}}⚪
Pausiert{{/paused}}{{^paused}}🟢 Aktiv{{/paused}}
{{/queues}}
cli.cmd.messages.pull.option.timeout-media:
Legt fest, wie lange versucht wird, Medien je Nachricht herunterzuladen.
cli.cmd.messages.pull.argument.end:
Legt fest, wo das Abruffenster endet; `2d` bedeutet vor zwei Tagen.
cli.cmd.messages.pull.argument.start:
Legt fest, wo das Abruffenster beginnt; `1d` bedeutet vor einem Tag (Standard
`0d`, also jetzt).
cli.cmd.messages.pull.already-running: >-
⏳ Der Job **Pull Messages** läuft bereits für **{{{chat}}}**. Warten Sie bis
zum Abschluss oder stoppen Sie ihn, bevor Sie einen neuen starten.
Senden Sie `messages status` für Details.
cli.cmd.messages.status.not-found:
ℹ️ Kein aktiver **Pull Messages**-Job gefunden. Starten Sie einen mit
`messages pull`.
cli.cmd.messages.pull.no-chats-found:
Keine WhatsApp-Chats für diesen Zeitraum gefunden.
cli.cmd.contacts.pull.option.progress:
Meldet den Fortschritt nach N Kontakten. 0 deaktiviert die Meldungen.
cli.cmd.contacts.pull.option.lids:
Bezieht WhatsApp-LID-Kontakte (anonyme Kontakte) in die Synchronisierung ein.
task.contacts.completed:
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄
{{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.messages.started:
⏳ Nachrichten werden abgerufen 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Senden Sie "{{{prefix}}}messages status", um weitere Details zu sehen.
task.messages.progress:
'⏳ Nachrichten {{{chat}}}: 🆕 {{progress.ok}}, 🟢 {{progress.exists}}, ⚪
{{progress.ignored}}, 🔴 {{progress.errors}}{{#messages.last}} | 📅
{{{messages.last}}}{{/messages.last}}'
task.messages.completed:
✅ Nachrichten {{{chat}}} in {{{period}}} → 🆕 {{progress.ok}}, 🟢
{{progress.exists}}, ⚪ {{progress.ignored}}, 🔴 {{progress.errors}}
task.messages.status: |-
Nachrichten **{{{chat}}}** im Zeitraum ⏱️ **{{{period}}}**. ⚙️ Jobstatus: **{{state}}**.
🗨️ Nachrichten:
- 🆕 Erstellt: **{{{progress.ok}}}**
- 🟢 Bereits vorhanden: {{{progress.exists}}}
- ⚪ Ignoriert: {{{progress.ignored}}}
- 🔴 Fehler: {{{progress.errors}}}
{{#messages.first}}
- 🗓️ Älteste: **{{{messages.first}}}**
{{/messages.first}}
{{#messages.last}}
- 🗓️ Neueste: **{{{messages.last}}}**
{{/messages.last}}
💬 WhatsApp-Chats:
- 🆕 Neue Nachrichten in **{{{chats}}}** Chats
{{#options.resolveConversations}}
🗣️ Konversationen:
- 🟢 Aufgelöst: **{{{progress.conversationResolve.success}}}**
{{#progress.conversationResolve.error}}
- 🔴 Fehler: **{{{progress.conversationResolve.error}}}**
{{/progress.conversationResolve.error}}
{{/options.resolveConversations}}
Details:
[{{{details.text}}}]({{{details.url}}})
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more