Compare commits

...
192 Commits
Author SHA1 Message Date
devlikepro ba976138b7 [core] 2026.4.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-04-15 13:05:24 +07:00
devlikepro bb3328f5a3 [core] Up WEBJS - fix window is not defined - fix #1990 2026-04-15 13:05:24 +07:00
devlikepro 6db12d7913 [core] Up Dashboard - Chat UI media, message history, show more chats in overview 2026-04-15 13:05:23 +07:00
devlikepro d968fa90f1 [core] Correct WHATSAPP_FILES_LIFETIME behavior
fix #2018 - setTimeout 32-bit overflow deletes files immediately when WHATSAPP_FILES_LIFETIME > ~24.8 days
closes #2019 - prevent setTimeout overflow for large WHATSAPP_FILES_LIFETIME values
2026-04-15 13:05:23 +07:00
devlikepro 06c874fea5 [core] GOWS - await grp response when starting a new session
fix #2012
2026-04-15 13:05:23 +07:00
Berg Pinheiro d2b7d94607 [core] Chatwoot - fix LID contact sending failure due to device part in JID (#42)
Normalize LID JIDs using normalizeJid() before passing to LidContactInfo
so that the device part (e.g. :11 in 183...977:11@lid) is stripped before
being persisted as WA_CHAT_ID in Chatwoot custom attributes.

Without this fix, GOWS rejects sendText calls with the error
"message recipient must be a user JID with no device part" because
FindChatID returns the raw LID (with :11) that was stored during the
incoming message flow.

* [PLUS] fix Chatwoot normalize LID device part on read to unblock existing contacts

Introduce normalizeChatId() helper in ids.ts that strips the device part
(e.g. :11) from LID JIDs using normalizeJid(). Apply it in FindChatID and
GetAllChatIDs so that contacts already persisted with a raw LID such as
183...977:11@lid are transparently normalized at read time.

This covers the send path for contacts created before the WhatsAppContactInfo
factory fix, preventing GOWS from rejecting sendText calls with
"message recipient must be a user JID with no device part".
2026-04-15 13:05:23 +07:00
devlikepro c8f067229b [core] WEBJS - ignore 2 days or older reactions
mention #494
2026-04-15 13:05:23 +07:00
devlikepro 798d782869 [core] Add replyTo.media if possible - all engines 2026-04-15 13:05:23 +07:00
devlikepro f70bcd0b53 [core] ChatWoot - use media from replyTo.media if provided 2026-04-15 13:05:22 +07:00
devlikepro 17a7764b97 [core] Up ChatWoot to 4.12.1 2026-04-15 13:05:22 +07:00
devlikepro 58affc9fe2 [core] Up NOWEB - proto to 2.3000.1035920091 2026-04-15 13:05:22 +07:00
devlikepro 3678cb4392 [core] WEBJS - fix loading messages - fix #2005, fix #2013 2026-04-15 13:05:22 +07:00
devlikepro 6eda96908f [core] Calls App - add wait before decline and response 2026-04-15 13:05:22 +07:00
devlikepro ddf161bb2b [core] Up WEBJS - fix call.received event - fix #2014 2026-04-15 13:05:22 +07:00
devlikepro 5659dc7296 [core] WEBJS - use webjs exposeFunctionIfAbsent 2026-04-15 13:05:22 +07:00
devlikepro bca90199f1 [core] Apps - allow app to be disabled when app disabled in runtime 2026-04-15 13:05:21 +07:00
devlikepro bd263b071c [core] WPP - type fix 2026-04-15 13:05:21 +07:00
devlikepro e54603085c [core] Up WPP 2026-04-15 13:05:21 +07:00
devlikepro 7f7857141e [core] make gows fix 2026-04-15 13:05:21 +07:00
devlikepro 3b19dc3a61 [core] autodetect os and browser 2026-04-15 13:05:21 +07:00
devlikepro 06e5951c68 [core] links, commands 2026-04-15 13:05:21 +07:00
devlikepro bcf4ed07d8 [core] format .yarnrc.yml 2026-04-15 13:05:21 +07:00
Berg Pinheiro f711fcca98 [core] add Chatwoot support for WhatsApp status replies
Detect WhatsApp status replies in incoming messages, append localized status context to Chatwoot content, and attach quoted status media when available while preserving current non-status behavior.

closes #1995 - Reply message to WhatsApp Status Context in Chatwoot Messages
fixes #1991 - Include WhatsApp Status Context in Chatwoot Messages
2026-04-15 13:05:20 +07:00
devlikepro 32e57cab98 [core] NOWEB - fallback to message id if no found by jid and id 2026-04-15 13:05:20 +07:00
devlikepro b092d2bfe1 [core] WEBJS - try both fromMe: true|false for dm chat when getting a message by id 2026-04-15 13:05:20 +07:00
devlikepro f655f715d0 [core] WEBJS - treat as "from me" messages when requesting status messages by id 2026-04-15 13:05:20 +07:00
devlikepro e5c77c60f2 [core] WEBJS - populate replyTo with more fields 2026-04-15 13:05:20 +07:00
devlikepro a38d4145c2 [core] Increase timeout for media resolving to ~12s total 2026-04-15 13:05:20 +07:00
devlikepro 2c47b784b3 [core] Up GOWS
fix #1998 - GOWS - Messages sent via POST /api/sendFile not returned by GET messages API
fix #2009 - GOWS - /chats/overview "message": "2 UNKNOWN: no such column: jid"
2026-04-15 13:05:20 +07:00
devlikepro 724dc61717 [core] Up Dashboard - handle empty sender name for automated messages - fix #1983 2026-04-15 13:05:20 +07:00
devlikepro 9c44256209 [core] make up-dashboard 2026-04-15 13:05:20 +07:00
Berg Pinheiro 1fb82c7678 [core] ChatWoot: conditional agent name prefix in WA to WhatsApp templates (#1988)
Fix empty **: prefix when Add Agent Name is on but chatwoot.sender.name is empty (e.g. system/automated messages). Applies Mustache conditionals to default i18n for chatwoot.to.whatsapp.message.text and chatwoot.to.whatsapp.message.media.caption across all locales (fixes devlikeapro/waha#1983).

fixes #1983
fixes #1737
2026-04-06 09:54:18 +07:00
Daniel (Danai) Rudaev 55f7d4dac7 [core] NOWEB - use stream mode for media download to fix 0-byte audio files (#1997)
The 'buffer' mode in Baileys' downloadMediaMessage() silently returns
an empty buffer for audio/voice messages (PTT, OGG Opus), while images
download correctly. Switching to 'stream' mode and collecting chunks
manually resolves the issue.

Tested in production (WAHA Plus 2026.3.4, NOWEB engine, S3 storage):
- Before: voice messages saved as 0 bytes in S3
- After: voice messages saved with correct size (58,005 bytes confirmed)

Fixes #1996
2026-04-06 09:51:28 +07:00
Ali Kemal Özdemir 10e8869903 [core] exclude /jobs from global swagger basic auth (#1981) (fix #1679) 2026-03-27 18:46:20 +07:00
devlikepro 554d2bc6bc [core] 2026.3.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
2026-03-27 18:39:04 +07:00
devlikepro 99aabb0957 Merge remote-tracking branch 'core/core' into core
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 / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-03-25 10:27:41 +07:00
devlikepro 055b014c41 [core] 2026.3.3 2026-03-25 10:24:58 +07:00
devlikepro 172676ae6f [core] Up NOWEB 2026-03-25 10:24:58 +07:00
devlikepro 0056951485 [core] Add view once messages in "message" event
fix #1972
2026-03-25 10:24:57 +07:00
devlikepro 8c91792473 [core] Up WPP 2026-03-25 10:24:57 +07:00
devlikepro a26e89a2b7 [core] GOWS v1.0.36
- Add image sizes - fix #1402, fix #901
- Add WAHA_GOWS_DEVICE_* env variables - configuration to limit historical message sync depth  fix #1963
2026-03-25 10:24:57 +07:00
devlikepro 7674c4ca67 [core] GOWS - add displayName to sendContactVcard
fix #1978
2026-03-25 10:24:57 +07:00
devlikepro cc7c11e2fd [core] GOWS - no device part
fix #1977
2026-03-25 10:24:56 +07:00
devlikepro 9f3c28b6ac [core] Fix "Cannot read properties of undefined (reading 'isAdmin')"
fix #1969
2026-03-25 10:24:56 +07:00
devlikepro 3ca9545a58 [core] Up WEBJS - fix profile picture and channels list
fix #1707
fix #1947
fix #1959
2026-03-25 10:24:56 +07:00
devlikepro 4d08cffeab [core] NOWEB - optimization for CPU spikes (lid/c.us merge)
fix #1955
2026-03-25 10:24:56 +07:00
devlikepro 241340e0a0 [core] GOWS v1.0.35
GOWS - fix CPU Spikes in PostgreSQL - fix #1955
2026-03-25 10:24:56 +07:00
devlikepro 556f0fa775 [core] yarn - support all cpu/os in supportedArchitectures
"Unsupported CPU: Prebuilt binaries for linux-x64 require v2 microarchitecture" - fix #1952
2026-03-25 10:24:56 +07:00
devlikepro 975cf2f580 [core] Up "sharp" 2026-03-25 10:24:55 +07:00
Berg Pinheiro 90795a7eaf [core] Fix BR landline normalization in PhoneJidNormalizer (#1976)
Prevent unconditional insertion of digit 9 for Brazilian numbers with 8-digit local parts by preserving landline patterns (2..5) and keeping add-9 behavior for mobile/other cases.

fix #1974
2026-03-25 10:24:45 +07:00
Berg Pinheiro e64b60ef3a [core] Fix BR landline normalization in PhoneJidNormalizer (#1976)
Prevent unconditional insertion of digit 9 for Brazilian numbers with 8-digit local parts by preserving landline patterns (2..5) and keeping add-9 behavior for mobile/other cases.

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

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

Fix #1926

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

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

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

Closes #1833
Mentions chatwoot/chatwoot#13348

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

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

Fix Send message to channel - fix #1863

Fix contact update - fix #1839

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

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

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)",
"Bash(pre-commit run:*)",
"Bash(yarn test:*)"
]
}
}
+3
View File
@@ -21,7 +21,10 @@ WHATSAPP_SWAGGER_ENABLED=True
# ==================
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
WHATSAPP_DEFAULT_ENGINE=WEBJS
# So you don't have to create sessions/apps when switching between engines
WAHA_NAMESPACE=all
# "Firefox (YourApp)" in Linked Devices
# WAHA_CLIENT_DEVICE_NAME=YourApp
# Base URL for the API (used for webhooks, file URLs, etc.)
+54 -9
View File
@@ -12,10 +12,11 @@ jobs:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
${{ matrix.tag }}
strategy:
fail-fast: false
matrix:
include:
# Chromium - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'latest'
platform: 'amd64'
browser: 'chromium'
@@ -23,7 +24,7 @@ jobs:
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'arm'
platform: 'linux/arm64'
browser: 'chromium'
@@ -31,7 +32,7 @@ jobs:
goss: 'goss-linux-arm'
# Chrome - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'chrome'
platform: 'amd64'
browser: 'chrome'
@@ -39,14 +40,14 @@ jobs:
goss: 'goss-linux-amd64'
# Chrome - ARM (Chrome is not available for ARM)
#- runner: "buildjet-4vcpu-ubuntu-2204-arm"
#- runner: "ubuntu-24.04-arm"
# tag: "chrome-arm"
# platform: "linux/arm64"
# browser: "chrome"
# goss: 'goss-linux-arm'
# NOWEB - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'noweb'
platform: 'amd64'
browser: 'none'
@@ -54,7 +55,7 @@ jobs:
goss: 'goss-linux-amd64'
# NOWEB - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'noweb-arm'
platform: 'linux/arm64'
browser: 'none'
@@ -62,7 +63,7 @@ jobs:
goss: 'goss-linux-arm'
# GOWS - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'gows'
platform: 'amd64'
browser: 'none'
@@ -70,7 +71,7 @@ jobs:
goss: 'goss-linux-amd64'
# No browser - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'gows-arm'
platform: 'linux/arm64'
browser: 'none'
@@ -100,7 +101,51 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
- name: Build (attempt 1)
id: build1
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if: steps.build1.outcome == 'failure'
run: |
sleep 10
- name: Build (attempt 2)
id: build2
if: steps.build1.outcome == 'failure'
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if: steps.build2.outcome == 'failure'
run: |
sleep 10
- name: Build (attempt 3)
if: steps.build2.outcome == 'failure'
uses: docker/build-push-action@v4
with:
context: .
+95 -3
View File
@@ -6,8 +6,37 @@ on:
workflow_dispatch:
jobs:
# unit-tests:
# if: github.repository == 'devlikeapro/waha-plus'
# runs-on: ubuntu-22.04
# name: Unit tests
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
#
# - name: Set up Node
# uses: actions/setup-node@v4
# with:
# node-version: 22
#
# - name: Enable Corepack
# run: |
# corepack enable
# corepack prepare yarn@4.9.2 --activate
#
# - name: Install dependencies
# run: |
# unset GIT_CONFIG_PARAMETERS
# yarn install --immutable
#
# - name: Run unit tests
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
@@ -15,13 +44,24 @@ jobs:
strategy:
matrix:
include:
- runner: 'buildjet-4vcpu-ubuntu-2204'
# Chromium - x86
- runner: 'ubuntu-latest'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
# Chrome - x86
- runner: 'ubuntu-latest'
tag: 'dev-chrome'
platform: 'amd64'
browser: 'chrome'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'ubuntu-24.04-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
browser: 'chromium'
@@ -71,8 +111,60 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
- name: Build (attempt 1)
id: build1
if: ${{ steps.recent.outputs.has_recent == 'true' }}
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
== 'failure' }}
run: |
sleep 10
- name: Build (attempt 2)
id: build2
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
== 'failure' }}
continue-on-error: true
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
USE_BROWSER=${{ matrix.browser }}
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Wait to retry build
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
== 'failure' }}
run: |
sleep 10
- name: Build (attempt 3)
if:
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
== 'failure' }}
uses: docker/build-push-action@v4
with:
context: .
+20
View File
@@ -3,3 +3,23 @@ compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
supportedArchitectures:
cpu:
- arm
- arm64
- ia32
- loong64
- mips
- mipsel
- ppc64
- riscv64
- s390
- s390x
- x64
- wasm32
os:
- current
- darwin
- linux
- win32
+80 -4
View File
@@ -3,6 +3,11 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
@@ -70,8 +75,14 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Prefer named function declarations over `const` arrow functions when possible.
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- Do not write verbose ternaries like
`condition !== undefined ? condition : default`; use idiomatic helpers such as
`??` (nullish coalescing) or existing boolean parsers so flags stay readable.
- Do not place `await` or other async calls inside ternary expressions (`?:`);
use explicit `if/else` blocks instead.
- For configs, prefer runtime configurability over constants. Environment keys
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
`session.config.*` for per-session overrides. If both env and config are
@@ -102,8 +113,7 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
3. After edits run:
- `pre-commit run --all-files`
- `yarn build`
- `yarn test` Use Node 22. Address lint or formatting issues before
proceeding.
- `yarn test --watchman=false`
4. Do **not** start the application yourself; ask the user to run it if runtime
validation is required.
5. Capture any assumptions or open questions for the user, especially when
@@ -120,14 +130,80 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## Yes No
Always define key for objects (and in return too)
```js
// NO
const variable = 123;
const b = { variable };
// YES
const variable = 123;
const b = { variable: variable };
```
Use the three-line comment style for section headers inside files:
```ts
// NO
// ─── Section name ─────────────────────────────────────────────────────────────
// YES
//
// Section name
//
```
## Related Sources Code
You can find related source code in the following paths:
You can find and read related source code in the following paths:
- WEBJS: `../whatsapp-web.js`
- NOWEB: `../WhiskeySockets-Baileys`
- GOWS: `../gows` + `../whatsmeow`
- whatsapp-rust-bridge - `../whatsapp-rust-bridge`
- GOWS: `../gows`
- whatsmeow - `../whatsmeow`
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
- ChatWoot: `../chatwoot`
Following this playbook keeps contributions aligned with WAHA’s structure,
automation hooks, and release process.
## How to run API
You can run the project outside of sandbox using the below command, then run
queries against `default` session (if not asked to do something different) using
`curl` and `X-Api-Key: 666` header.
```bash
export DEBUG=1
export WAHA_API_KEY=666
export WAHA_DASHBOARD_PASSWORD=666
export WAHA_DASHBOARD_USERNAME=admin
export WWHATSAPP_SWAGGER_USERNAME=admin
export WHATSAPP_SWAGGER_PASSWORD=666
export WHATSAPP_DEFAULT_ENGINE={WEBJS|WPP|NOWEB|GOWS}
export WAHA_DEBUG_MODE=True
export WAHA_HTTP_STRICT_MODE=1
export WAHA_MEDIA_STORAGE=LOCAL
export WHATSAPP_FILES_FOLDER=./.media
npm run start
```
- Ask user before running the server.
- Before executing some queries make sure the session is in `WORKING` status.
- If it's `FAILED` or `SCAN_QR_CODE` ask user to scan QR code or fix failed
session.
## Code
### @Activity Decorator and Presence Tracking
Add `@Activity()` (from `src/core/abc/activity.ts`) to every engine method that
makes a network call to WhatsApp servers. It triggers `maintainPresenceOnline()`
before the method runs, keeping the session ONLINE during API activity and
scheduling an OFFLINE transition after an idle period. Skip it on methods that
only throw `NotImplementedByEngineError` / `AvailableInPlusVersion`.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+3 -1
View File
@@ -8,7 +8,9 @@ FROM node:${NODE_IMAGE_TAG} AS build
ENV PUPPETEER_SKIP_DOWNLOAD=True
# git + build toolchain for git deps
RUN apt-get update && apt-get install -y git python3 build-essential && rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y --no-install-recommends git python3 build-essential curl ca-certificates unzip && \
rm -rf /var/lib/apt/lists/*
# npm packages
WORKDIR /git
+14 -5
View File
@@ -32,16 +32,23 @@ push:
docker push devlikeapro/waha
for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
export WHATSAPP_SWAGGER_CONFIG_ADVANCED=true && export WHATSAPP_SWAGGER_PASSWORD=666 && yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-02-11
up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18
up-wpp:
yarn up @wppconnect-team/wppconnect
yarn up @wppconnect/wa-js
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
@@ -51,10 +58,12 @@ proto-gows:
gows:
cd ../gows && \
export PATH=${HOME}/go/bin:${PATH} && \
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
make all
up-dashboard:
node scripts/up-dashboard.js
copy-dashboard:
cd ../waha-hub/ui && \
make copy-waha
+12 -1
View File
@@ -147,7 +147,18 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
## Start the project
1. Clone the repository
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
3. Run the following commands:
3. Install the **whatsapp-rust-bridge prerequisites**:
```bash
# Bun runtime used by whatsapp-rust-bridge prepare scripts
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.9
# Rust nightly toolchain used for whatsapp-rust-bridge
curl -fsSL https://sh.rustup.rs | bash -s -- -y --default-toolchain nightly-2026-01-30
rustup target add wasm32-unknown-unknown
cargo install wasm-pack --vers 0.14.0 --locked
```
4. Run the following commands:
```bash
# Install dependencies
yarn install
+1 -2
View File
@@ -24,7 +24,7 @@ services:
- 8.8.8.8
base: &base
image: chatwoot/chatwoot:v4.7.0
image: chatwoot/chatwoot:v4.12.1
env_file: .chatwoot.env ## Change this file for customized env variables
volumes:
- chatwoot_storage:/app/storage
@@ -75,7 +75,6 @@ services:
- POSTGRES_USER=postgres
# Please provide your own password.
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
redis:
image: redis:alpine
+59 -24
View File
@@ -16,23 +16,27 @@
"start:prod-exit": "node dist/main",
"lint": "oxlint --deny-warnings",
"lint-fix": "oxlint --fix --deny-warnings",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test": "yarn test:unit",
"test:watch": "jest --selectProjects unit --watch",
"test:cov": "jest --selectProjects unit --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
"test:unit": "jest --selectProjects unit",
"test:e2e": "jest --selectProjects e2e",
"test:e8e": "yarn test:e2e",
"test:all": "yarn test:unit && yarn test:e8e",
"gows:proto:fetch": "node scripts/gows-proto.js fetch",
"gows:proto:build": "node scripts/gows-proto.js build",
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-02-11",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
"@bull-board/api": "^6.9.1",
"@bull-board/express": "^6.9.1",
"@bull-board/nestjs": "^6.9.1",
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@nestjs/axios": "^3.0.2",
@@ -53,6 +57,8 @@
"@types/passport": "^1.0.17",
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^1.41.1",
"@wppconnect/wa-js": "^3.23.3",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
@@ -100,14 +106,15 @@
"reflect-metadata": "^0.1.13",
"rimraf": "^4.3.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"sqlite3": "^5.1.7",
"swagger-ui-express": "^4.1.4",
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17",
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
},
@@ -120,7 +127,8 @@
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"whatwg-url": "13.0.0",
"axios": "^1.9.0"
"axios": "^1.9.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
},
"devDependencies": {
"@grpc/grpc-js": "^1.14.1",
@@ -134,6 +142,7 @@
"@types/node-fetch": "^2.6.13",
"@types/semver": "^7.7.0",
"@types/supertest": "^2.0.8",
"@types/user-agents": "^1",
"dotenv": "^17.2.3",
"grpc-tools": "^1.13.0",
"jest": "^29.7.0",
@@ -148,21 +157,47 @@
"typescript": "5.9.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"projects": [
{
"displayName": "unit",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"testPathIgnorePatterns": [
"/__tests__/e2e/"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
{
"displayName": "e2e",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": "__tests__/e2e/.*\\.test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
]
},
"packageManager": "yarn@4.9.2"
}
+17 -3
View File
@@ -76,17 +76,31 @@ const updatedContent = exampleContent
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
const generatedEnvValues = [
`WAHA_API_KEY=${apiKey}`,
`WAHA_API_KEY_PLAIN=${apiKey}`,
'WAHA_DASHBOARD_USERNAME=admin',
`WAHA_DASHBOARD_PASSWORD=${adminPassword}`,
'WHATSAPP_SWAGGER_USERNAME=admin',
`WHATSAPP_SWAGGER_PASSWORD=${adminPassword}`,
];
const lines = [
'Credentials generated.',
'',
'Dashboard and Swagger:',
'Generated env values:',
...generatedEnvValues.map((line) => ` - ${line}`),
'',
'Use these credentials to login in Dashboard or Swagger:',
' - Username: admin',
` - Password: ${adminPassword}`,
'',
'API key: ',
'Use this API key in the x-api-key header:',
` - ${apiKey}`,
'',
'Use it in the Dashboard connection flow: https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
'Read more:',
' - https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
' - https://waha.devlike.pro/docs/how-to/security/',
];
console.log(lines.join('\n'));
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
'use strict';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { execSync } = require('child_process');
const CONFIG_FILE = 'waha.config.json';
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const { repo } = config.waha.dashboard;
if (!repo) {
console.error(`Missing dashboard.repo in ${CONFIG_FILE}`);
process.exit(1);
}
const branch = 'gh-pages';
console.log(`Fetching latest commit for ${repo}@${branch}...`);
const output = execSync(
`git ls-remote https://github.com/${repo} refs/heads/${branch}`,
{ encoding: 'utf8' },
);
const sha = output.split('\t')[0].trim();
if (!sha) {
console.error(`Could not resolve ref ${branch} for ${repo}`);
process.exit(1);
}
config.waha.dashboard.ref = sha;
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf8');
console.log(`Updated ${CONFIG_FILE}: dashboard.ref = ${sha}`);
+391
View File
@@ -0,0 +1,391 @@
import axios from 'axios';
import WebSocket = require('ws');
const VALID_API_KEY = '666';
const VALID_USERNAME = 'admin';
const VALID_PASSWORD = '666';
const VALID_SESSION_API_KEY = 'key_0fQfI3n3rFVsczBbBfknLXKUreY2my6J';
const BASE_URL = (process.env.WAHA_BASE_URL ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
const WS_BASE_URL = BASE_URL.replace(/^http/, 'ws');
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_UNPROCESSABLE = 422;
const WS_POLICY_VIOLATION = 1008;
const WS_OK_CODES = [1000, 1005];
describe('admin - GET /api/sessions/{name}', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('admin - POST /api/sessions', () => {
test('admin key can create a session without name and remove it', async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{},
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(createResponse.status).toBe(HTTP_CREATED);
expect(createResponse.data?.name).toBeTruthy();
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/${createResponse.data.name}`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(deleteResponse.status).toBe(HTTP_OK);
});
});
describe('GET /api/sessions?all=true', () => {
beforeAll(async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'another' },
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_CREATED, HTTP_UNPROCESSABLE].includes(createResponse.status)) {
throw new Error(
`Unexpected status for creating "another" session: ${createResponse.status}`,
);
}
});
afterAll(async () => {
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/another`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_OK, HTTP_NOT_FOUND].includes(deleteResponse.status)) {
throw new Error(
`Unexpected status for deleting "another" session: ${deleteResponse.status}`,
);
}
});
test('admin key returns two sessions', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(2);
expect(response.data.map((session) => session.name).sort()).toEqual([
'another',
'default',
]);
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('session key returns one session', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(1);
expect(response.data[0]?.name).toBe('default');
});
});
describe('GET /api/server/version', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
});
describe('GET /api/server/environment', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
describe('admin - GET /health', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('/ws', () => {
const buildWsUrl = (apiKey?: string) => {
const wsUrl = new URL('/ws', WS_BASE_URL);
wsUrl.searchParams.set('session', '*');
wsUrl.searchParams.set('events', '*');
if (apiKey) {
wsUrl.searchParams.set('x-api-key', apiKey);
}
return wsUrl.toString();
};
const waitForOpen = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket open timeout'));
}, timeoutMs);
socket.once('open', () => {
clearTimeout(timeout);
resolve();
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
const waitForClose = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<{ code: number; reason: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket close timeout'));
}, timeoutMs);
socket.once('close', (code, reason) => {
clearTimeout(timeout);
resolve({ code, reason: reason.toString() });
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
test('no api key is unauthorized', async () => {
const socket = new WebSocket(buildWsUrl());
const { code } = await waitForClose(socket);
expect(code).toBe(WS_POLICY_VIOLATION);
});
test('admin api key is ok', async () => {
const socket = new WebSocket(buildWsUrl(VALID_API_KEY));
await waitForOpen(socket);
socket.close();
const { code } = await waitForClose(socket);
expect(WS_OK_CODES).toContain(code);
});
});
describe('GET /api/files/test.txt', () => {
// create "test.txt" in current dir/.media/test.txt
let file = null;
beforeAll(() => {
const fs = require('fs');
const path = require('path');
const mediaDir = path.resolve('./.media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir);
}
const filePath = path.join(mediaDir, 'test.txt');
fs.writeFileSync(filePath, 'This is a test file.');
file = filePath;
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /dashboard', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('session key - GET /api/sessions/{name}', () => {
test('default key - default session - is authorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('default key - create a new sessions - forbidden', async () => {
const response = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'newone' },
{
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
},
);
expect(response.status).toBe(HTTP_FORBIDDEN);
});
test('another key - default session - unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': 'key_another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('default key - another session - forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/another`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
@ApiSecurity('api_key')
@Controller('api/keys')
@ApiTags('🔑 Api Keys')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ApiKeysController {
constructor(private manager: SessionManager) {}
@Post('/')
@ApiOperation({ summary: 'Create a new API key' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${body.session}" does not exist`,
);
}
}
let apikey: ApiKey | null = null;
// Try 5 times to check there's no conflict on id and key
for (let i = 0; i < 5; i++) {
apikey = {
id: generatePrefixedId('key_id'),
key: `key_${generateSecret(32)}`,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
rules: null,
};
const idExists = await this.manager.apiKeyRepository.getById(apikey.id);
if (idExists) {
continue;
}
const keyExists = await this.manager.apiKeyRepository.getByKey(
apikey.key,
);
if (keyExists) {
continue;
}
break;
}
if (!apikey) {
throw new UnprocessableEntityException(
`Failed to generate API key, try again`,
);
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Get('/')
@ApiOperation({ summary: 'Get all API keys' })
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.manager.apiKeyRepository.list();
return keys.map((key) => ApiKeyToDTO(key));
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@Body() body: ApiKeyRequest,
): Promise<ApiKeyDTO> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
};
CheckInvariant(apikey);
if (apikey.session) {
const exists = await this.manager.exists(apikey.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${apikey.session}" does not exist`,
);
}
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an API key' })
async delete(@Param('id') id: string): Promise<{ result: true }> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
await this.manager.apiKeyRepository.deleteById(id);
return { result: true };
}
}
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
key: apikey.key,
isActive: apikey.isActive,
isAdmin: apikey.isAdmin,
session: apikey.session,
};
}
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Post,
Query,
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
@@ -24,10 +25,17 @@ import {
RequestCodeRequest,
} from '../structures/auth.dto';
import { Base64File } from '../structures/files.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/auth')
@ApiTags('🔑 Auth')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class AuthController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -14,10 +15,17 @@ import {
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class CallsController {
constructor(private manager: SessionManager) {}
+13 -4
View File
@@ -8,6 +8,7 @@ import {
Param,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -39,10 +40,17 @@ import {
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ChannelsController {
constructor(
private manager: SessionManager,
@@ -123,14 +131,15 @@ export class ChannelsController {
})
async previewChannelMessages(
@WorkingSessionParam session: WhatsappSession,
@Param('id') code: string,
@Param('id') codeOrId: string,
@Query() query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
if (isJidNewsletter(code)) {
const channel = await session.channelsGetChannel(code);
let code = null;
if (isJidNewsletter(codeOrId)) {
const channel = await session.channelsGetChannel(codeOrId);
code = parseChannelInviteLink(channel.invite);
}
const inviteCode = parseChannelInviteLink(code);
const inviteCode = parseChannelInviteLink(code || codeOrId);
return session.previewChannelMessages(inviteCode, query);
}
+12 -4
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -24,15 +25,15 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ChatPictureQuery,
ChatPictureResponse,
ChatsPaginationParams,
ChatSummary,
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
MessageSortField,
OverviewBodyRequest,
OverviewFilter,
OverviewPaginationParams,
PinMessageRequest,
ReadChatMessagesQuery,
ReadChatMessagesResponse,
@@ -40,11 +41,18 @@ import {
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/chats')
@ApiTags('💬 Chats')
@UsePipes(new ValidationPipe({ transform: true }))
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class ChatsController {
constructor(private manager: SessionManager) {}
@@ -53,7 +61,7 @@ class ChatsController {
@ApiOperation({ summary: 'Get chats' })
getChats(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: ChatsPaginationParams,
@Query() pagination: GetChatsParams,
) {
return session.getChats(pagination);
}
@@ -67,7 +75,7 @@ class ChatsController {
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
getChatsOverview(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: OverviewPaginationParams,
@Query() pagination: GetChatsOverviewParams,
@Query() filter: OverviewFilter,
): Promise<ChatSummary[]> {
return session.getChatsOverview(pagination, filter);
+31
View File
@@ -5,6 +5,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -48,15 +49,22 @@ import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('📤 Chatting')
@UseGuards(PoliciesGuard)
export class ChattingController {
constructor(private manager: SessionManager) {}
@Post('/sendText')
@ApiOperation({ summary: 'Send a text message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -72,6 +80,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -87,6 +96,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -102,6 +112,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVoice(@Body() request: MessageVoiceRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendVoice(request);
@@ -113,6 +124,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -128,6 +140,7 @@ export class ChattingController {
description:
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendLinkCustomPreview(
@Body() request: MessageLinkCustomPreviewRequest,
@@ -147,6 +160,7 @@ export class ChattingController {
description: 'Send Buttons',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendButtons(@Body() request: SendButtonsRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -158,6 +172,7 @@ export class ChattingController {
summary: 'Send a list message (interactive)',
description: 'Send a List message with sections and rows',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendList(@Body() request: SendListRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -165,6 +180,7 @@ export class ChattingController {
}
@Post('/forwardMessage')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async forwardMessage(
@Body() request: MessageForwardRequest,
): Promise<WAMessage> {
@@ -173,6 +189,7 @@ export class ChattingController {
}
@Post('/sendSeen')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
@@ -188,6 +205,7 @@ export class ChattingController {
}
@Post('/startTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async startTyping(@Body() chat: ChatRequest) {
// It's infinitive action
const whatsapp = await this.manager.getWorkingSession(chat.session);
@@ -196,6 +214,7 @@ export class ChattingController {
}
@Post('/stopTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async stopTyping(@Body() chat: ChatRequest) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
await whatsapp.stopTyping(chat);
@@ -204,6 +223,7 @@ export class ChattingController {
@Put('/reaction')
@ApiOperation({ summary: 'React to a message with an emoji' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setReaction(@Body() request: MessageReactionRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.setReaction(request);
@@ -211,6 +231,7 @@ export class ChattingController {
@Put('/star')
@ApiOperation({ summary: 'Star or unstar a message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setStar(@Body() request: MessageStarRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
await whatsapp.setStar(request);
@@ -222,6 +243,7 @@ export class ChattingController {
summary: 'Send a poll with options',
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
@@ -232,6 +254,7 @@ export class ChattingController {
summary: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPollVote(@Body() request: MessagePollVoteRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -239,12 +262,14 @@ export class ChattingController {
}
@Post('/sendLocation')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLocation(@Body() request: MessageLocationRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLocation(request);
}
@Post('/sendContactVcard')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendContactVCard(request);
@@ -254,6 +279,7 @@ export class ChattingController {
@ApiOperation({
summary: 'Reply on a button message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendButtonsReply(@Body() request: MessageButtonReply) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -262,6 +288,7 @@ export class ChattingController {
@Get('/sendText')
@ApiOperation({ summary: 'Send a text message', deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async sendTextGet(@Query() query: MessageTextQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
const msg = new MessageTextRequest();
@@ -276,6 +303,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Query() query: GetMessageQuery,
@@ -292,6 +320,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async DEPRECATED_checkNumberStatus(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -305,6 +334,7 @@ export class ChattingController {
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async reply(@Body() request: MessageReplyRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.reply(request);
@@ -312,6 +342,7 @@ export class ChattingController {
@Post('/sendLinkPreview')
@ApiOperation({ deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLinkPreview(request);
+14
View File
@@ -4,6 +4,7 @@ import {
Get,
Post,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -21,15 +22,22 @@ import {
ContactRequest,
ContactsPaginationParams,
} from '../structures/contacts.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
export class ContactsController {
constructor(private manager: SessionManager) {}
@Get('/all')
@ApiOperation({ summary: 'Get all contacts' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@Query() query: SessionQuery,
@@ -45,6 +53,7 @@ export class ContactsController {
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async get(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContact(query);
@@ -52,6 +61,7 @@ export class ContactsController {
@Get('/check-exists')
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async checkExists(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -65,6 +75,7 @@ export class ContactsController {
description:
'Returns null if you do not have permission to read their status.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async getAbout(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContactAbout(query);
@@ -76,6 +87,7 @@ export class ContactsController {
description:
'If privacy settings do not allow to get the picture, the method will return null.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
@@ -88,6 +100,7 @@ export class ContactsController {
@Post('/block')
@ApiOperation({ summary: 'Block contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async block(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.blockContact(request);
@@ -95,6 +108,7 @@ export class ContactsController {
@Post('/unblock')
@ApiOperation({ summary: 'Unblock contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async unblock(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.unblockContact(request);
+73
View File
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Get,
Param,
Put,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { SessionManager } from '../core/abc/manager.abc';
import { Result } from '../structures/base.dto';
import { ContactUpdateBody } from '../structures/contacts.dto';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
@Get('/:id')
@SessionApiParam
@ApiParam({
name: 'id',
required: true,
type: 'string',
description: 'Contact ID',
example: '123456789@c.us',
})
@ApiOperation({
summary: 'Get contact basic info',
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
async get(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
) {
return session.getContact({ session: session.name, contactId: id });
}
@Put('/:chatId')
@SessionApiParam
@ChatIdApiParam
@ApiOperation({
summary: 'Create or update contact',
description:
'Create or update contact on the phone address book. May not work if you have installed many WhatsApp apps on the same phone',
})
@UsePipes(new WAHAValidationPipe())
async put(
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
@Body() body: ContactUpdateBody,
): Promise<Result> {
await session.upsertContact(chatId, body);
return { success: true };
}
}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Param,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -19,10 +20,17 @@ import {
EventMessageRequest,
} from '../structures/events.dto';
import { WAMessage } from '../structures/responses.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/events')
@ApiTags('📅 Events')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class EventsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -9,6 +9,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,10 +41,17 @@ import {
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/groups')
@ApiTags('👥 Groups')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class GroupsController {
constructor(private manager: SessionManager) {}
+8 -1
View File
@@ -1,12 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HealthCheck } from '@nestjs/terminus';
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('health')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class HealthController {
constructor(private wahaHealth: WAHAHealthCheckService) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -26,10 +27,17 @@ import {
import * as lodash from 'lodash';
import { SessionManager } from '../core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/labels')
@ApiTags('🏷️ Labels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LabelsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -4,6 +4,7 @@ import {
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isLidUser } from '@waha/core/utils/jids';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LidsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Post,
UnprocessableEntityException,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -21,10 +22,17 @@ import {
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class MediaController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
@@ -20,10 +21,17 @@ import {
WAHAChatPresences,
WAHASessionPresence,
} from '../structures/presence.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/presence')
@ApiTags('✅ Presence')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class PresenceController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,17 @@ import {
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ProfileController {
constructor(private manager: SessionManager) {}
+14 -2
View File
@@ -4,23 +4,35 @@ import {
Query,
Res,
StreamableFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import { Response } from 'express';
import { SessionManager } from '../core/abc/manager.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { SessionQuery } from '../structures/base.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('🖼️ Screenshot')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
export class ScreenshotController {
constructor(private manager: SessionManager) {}
@Get('/screenshot')
@ApiOperation({
summary:
'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)',
})
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
async screenshot(
+11
View File
@@ -7,6 +7,7 @@ import {
Logger,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,16 @@ import {
import { sleep } from '@waha/utils/promiseTimeout';
import { VERSION } from '@waha/version';
import * as lodash from 'lodash';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
export class ServerController {
private logger: Logger;
@@ -35,12 +42,14 @@ export class ServerController {
@Get('version')
@ApiOperation({ summary: 'Get the version of the server' })
@CheckPolicies(CanServer(Action.Read))
get(): WAHAEnvironment {
return VERSION;
}
@Get('environment')
@ApiOperation({ summary: 'Get the server environment' })
@CheckPolicies(CanServer(Action.Manage))
environment(
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -67,6 +76,7 @@ export class ServerController {
@Get('status')
@ApiOperation({ summary: 'Get the server status' })
@CheckPolicies(CanServer(Action.Read))
async status(): Promise<ServerStatusResponse> {
const now = Date.now();
const uptime = Math.floor(process.uptime() * 1000);
@@ -87,6 +97,7 @@ export class ServerController {
"If you're using docker, after calling this endpoint Docker will start a new container, " +
'so you can use this endpoint to restart the server',
})
@CheckPolicies(CanServer(Action.Manage))
@UsePipes(new WAHAValidationPipe())
async stop(@Body() request: StopRequest): Promise<StopResponse> {
const timeout = 1_000;
+8
View File
@@ -9,6 +9,7 @@ import {
NotFoundException,
Query,
StreamableFile,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -25,10 +26,17 @@ import {
CpuProfileQuery,
} from '@waha/structures/server.debug.dto';
import { createReadStream } from 'fs';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server/debug')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ServerDebugController {
private logger: Logger;
private readonly enabled: boolean;
+39 -5
View File
@@ -9,10 +9,18 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOAuth2,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
@@ -34,18 +42,24 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
import { FilterSessions } from '../core/auth/casl.ability';
import { CheckPolicies } from '../core/auth/policies.decorator';
import { PoliciesGuard } from '../core/auth/policies.guard';
import { CanSession, FromBody, FromParam } from '../core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
@UseGuards(PoliciesGuard)
class SessionsController {
constructor(
private manager: SessionManager,
@@ -57,11 +71,19 @@ class SessionsController {
}
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
@ApiOperation({
summary: 'List all sessions',
})
@CheckPolicies(CanSession(Action.List))
@ApiOAuth2(['read:items'])
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
@Req() req,
): Promise<SessionInfo[]> {
const sessions = await this.manager.getSessions(query.all);
let sessions = await this.manager.getSessions(query.all);
if (!req.user?.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
@@ -73,6 +95,7 @@ class SessionsController {
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async get(
@Param('session') name: string,
@@ -91,6 +114,7 @@ class SessionsController {
@Get(':session/me')
@SessionApiParam
@ApiOperation({ summary: 'Get information about the authenticated account' })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
return session.getSessionMeInfo();
}
@@ -102,6 +126,7 @@ class SessionsController {
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Create))
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -139,6 +164,7 @@ class SessionsController {
})
@SessionApiParam
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@@ -177,6 +203,7 @@ class SessionsController {
description:
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('session') name: string): Promise<void> {
await this.withLock(name, async () => {
@@ -195,6 +222,7 @@ class SessionsController {
description:
'Start the session with the given name. The session must exist. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async start(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -214,6 +242,7 @@ class SessionsController {
summary: 'Stop the session',
description: 'Stop the session with the given name. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stop(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -229,6 +258,7 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -253,6 +283,7 @@ class SessionsController {
summary: 'Restart the session',
description: 'Restart the session with the given name.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async restart(@Param('session') name: string): Promise<SessionDTO> {
await this.manager.restart(name);
@@ -266,6 +297,7 @@ class SessionsController {
'Create session (if not exists) or update a config (if exists) and start it.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRACATED_start(
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
@@ -292,6 +324,7 @@ class SessionsController {
description: 'Stop session and Logout by default.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
@@ -323,6 +356,7 @@ class SessionsController {
description: 'Stop, Logout and Delete session.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
+8 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
@@ -15,10 +15,17 @@ import {
VideoStatus,
VoiceStatus,
} from '../structures/status.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/status')
@ApiTags('🟢 Status')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class StatusController {
constructor(private manager: SessionManager) {}
+9 -7
View File
@@ -1,17 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import {
ApiExtraModels,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAEnvironment } from '../structures/environment.dto';
import { VERSION } from '../version';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/version')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Read))
export class VersionController {
@Get('')
@ApiOperation({
+42 -19
View File
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
import { EventWildUnmask } from '@waha/utils/events';
import { generatePrefixedId } from '@waha/utils/ids';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { URL } from 'url';
import { Server } from 'ws';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
export enum WebSocketCloseCode {
NORMAL = 1000,
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
constructor(
private manager: SessionManager,
private auth: WebSocketAuth,
private readonly casl: CaslAbilityFactory,
) {
this.logger = new Logger('WebsocketGateway');
this.heartbeat = new WebsocketHeartbeatJob(
@@ -62,11 +65,16 @@ export class WebsocketGatewayCore
);
}
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
async handleConnection(
socket: WebSocket,
request: IncomingMessage,
...args
): Promise<any> {
// wsc - websocket client
socket.id = generatePrefixedId('wsc');
if (!this.auth.validateRequest(request)) {
const user = await this.auth.validateRequest(request);
if (!user) {
// Not authorized - close connection
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
this.logger.debug(
@@ -75,9 +83,20 @@ export class WebsocketGatewayCore
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const params = this.getParams(request);
const session: string = params.session;
let session: string = params.session;
const ability = this.casl.createForUser(user);
if (session == '*' && !ability.can(Action.Use, 'all')) {
// Limit user to listen only the session events
session = user.session;
}
if (!ability.can(Action.Use, new SessionName(session))) {
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
@@ -86,12 +105,17 @@ export class WebsocketGatewayCore
const sub = this.manager
.getSessionEvents(session, events)
.subscribe((data) => {
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
setImmediate(() => {
this.logger.debug(
`Sending data to client, event.id: ${data.id}`,
data,
);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
});
});
});
socket.on('close', () => {
@@ -101,14 +125,13 @@ export class WebsocketGatewayCore
}
private getParams(request: IncomingMessage) {
const query = url.parse(request.url, true).query;
const session = (query.session as string) || '*';
let paramsEvents = (query.events as string[]) || '*';
// if params events string - split by ","
if (typeof paramsEvents === 'string') {
paramsEvents = paramsEvents.split(',');
}
const events = this.eventUnmask.unmask(paramsEvents);
// We need only search params, so localhost is fine here
const query = new URL(request.url, 'http://localhost').searchParams;
const session = query.get('session') || '*';
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
}
+1 -1
View File
@@ -40,7 +40,7 @@ export class AxiosLogging {
// Log debug for 2xx/3xx responses
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
if (type.startsWith('application/json')) {
if (type?.startsWith('application/json')) {
const data = JSON.stringify(response.data);
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
} else {
+51 -4
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Inject,
NotFoundException,
@@ -9,6 +10,8 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -17,6 +20,15 @@ import {
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import {
CanServer,
CanSession,
FromBody,
FromQuery,
} from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { App } from '../dto/app.dto';
@@ -25,6 +37,7 @@ import { ListAppsQuery } from '../dto/query.dto';
@ApiSecurity('api_key')
@Controller('api/apps')
@ApiTags('🧩 Apps')
@UseGuards(PoliciesGuard)
export class AppsController {
constructor(
@Inject(AppsService)
@@ -34,6 +47,7 @@ export class AppsController {
@Get('/')
@ApiOperation({ summary: 'List all apps for a session' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new WAHAValidationPipe())
async list(
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
@@ -43,6 +57,7 @@ export class AppsController {
@Post('/')
@ApiOperation({ summary: 'Create a new app' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
@@ -55,15 +70,39 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string): Promise<App> {
return await this.appsService.get(this.manager, id);
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
if (!app) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
return app;
}
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
async update(
@Param('id') id: string,
@Body() app: App,
@Req() req: any,
): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (existing) {
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
} else {
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
}
if (!app.id) {
app.id = id;
} else if (app.id !== id) {
@@ -82,8 +121,16 @@ export class AppsController {
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
+12 -7
View File
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
export const JobRemoveOptions = jobRemoveOptions();
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
};
function exponentialRetriesJobOptions(): DefaultJobOptions {
return {
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
backoff: {
type: process.env.WAHA_APPS_JOBS_BACKOFF_TYPE || 'exponential',
delay: parseInt(process.env.WAHA_APPS_JOBS_BACKOFF_DELAY) || 1000,
},
};
}
export const ExponentialRetriesJobOptions = exponentialRetriesJobOptions();
export const NoRetriesJobOptions: DefaultJobOptions = {
attempts: 1,
+33 -5
View File
@@ -1,4 +1,10 @@
import { parseBool } from '@waha/helpers';
import { APPS } from '@waha/apps/app_sdk/apps/definition';
// Apps that don't require queue (Redis) - derived from AppDefinition
const IN_MEMORY_APPS = Object.values(APPS)
.filter((app) => !app.queue)
.map((app) => app.name);
function parseCommaSeparatedList(value: string | undefined): string[] {
if (!value) {
@@ -7,8 +13,30 @@ function parseCommaSeparatedList(value: string | undefined): string[] {
return value.split(',').map((item) => item.trim());
}
export const AppEnv = {
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
};
function isMongoDB(): boolean {
return !!process.env.WHATSAPP_SESSIONS_MONGO_URL;
}
function buildAppEnv() {
const enabled = process.env.WAHA_APPS_ENABLED;
const on = process.env.WAHA_APPS_ON;
const off = process.env.WAHA_APPS_OFF;
// Default if not mongodb
const isUserConfigured = enabled !== undefined || on !== undefined;
if (!isUserConfigured && !isMongoDB()) {
return {
enabled: true,
on: IN_MEMORY_APPS,
off: parseCommaSeparatedList(off),
};
}
return {
enabled: parseBool(enabled),
on: parseCommaSeparatedList(on),
off: parseCommaSeparatedList(off),
};
}
export const AppEnv = buildAppEnv();
@@ -21,7 +21,7 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
throw new AppsIsDisabledError();
}
@@ -90,10 +90,10 @@ export class AppsEnabledService implements IAppsService {
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
service?.validate(app);
// Only run beforeCreated when app is enabled (default true if omitted)
if (app.enabled !== false) {
await service.beforeCreated(app);
@@ -104,12 +104,12 @@ export class AppsEnabledService implements IAppsService {
return result;
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
if (!app) {
throw new NotFoundException(`App '${appId}' not found`);
return null;
}
delete (app as any).pk;
return app;
@@ -147,21 +147,21 @@ export class AppsEnabledService implements IAppsService {
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
service?.validate(app);
const hasEnabledChange = savedApp.enabled !== app.enabled;
if (hasEnabledChange) {
if (app.enabled) {
await service.beforeEnabled(savedApp, app);
await service?.beforeEnabled(savedApp, app);
} else {
await service.beforeDisabled(savedApp, app);
await service?.beforeDisabled(savedApp, app);
}
} else {
await service.beforeUpdated(savedApp, app);
await service?.beforeUpdated(savedApp, app);
}
await repo.update(app.id, app);
const updated = await repo.getById(app.id);
+1 -1
View File
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
export interface IAppsService {
list(manager: SessionManager, session: string): Promise<App[]>;
get(manager: SessionManager, appId: string): Promise<App>;
get(manager: SessionManager, appId: string): Promise<App | null>;
create(manager: SessionManager, app: App): Promise<App>;
+19
View File
@@ -146,6 +146,17 @@ export class WAHASelf {
.then((response) => response.data);
}
async getGroupParticipants(
session: string,
groupId: string,
opts?: RequestOptions,
) {
const url = `/api/${session}/groups/${groupId}/participants/v2`;
return await this.client
.get(url, { signal: opts?.signal })
.then((response) => response.data);
}
async getChannel(
session: string,
channelId: string,
@@ -371,6 +382,10 @@ export class WAHASessionAPI {
return this.api.getGroup(this.session, groupId, opts);
}
getGroupParticipants(groupId: string, opts?: RequestOptions): Promise<any> {
return this.api.getGroupParticipants(this.session, groupId, opts);
}
getChannel(channelId: string, opts?: RequestOptions): Promise<Channel> {
return this.api.getChannel(this.session, channelId, opts);
}
@@ -459,4 +474,8 @@ export class WAHASessionAPI {
findLIDByPN(pn: string, opts?: RequestOptions) {
return this.api.findLIDByPN(this.session, pn, opts);
}
getSessionInfo(opts?: RequestOptions) {
return this.api.get(this.session, opts);
}
}
+22
View File
@@ -2,8 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
@@ -23,6 +25,26 @@ export class CallsAppChannelConfig {
@IsOptional()
@IsString()
message?: string;
@ApiProperty({
description:
'Seconds to wait before declining the call. If not set or undefined, the call is declined immediately (0 seconds).',
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
waitBeforeDecline?: number;
@ApiProperty({
description:
'Seconds to wait before sending the auto-reply message. If not set or undefined, the message is sent immediately (0 seconds).',
required: false,
})
@IsOptional()
@IsNumber()
@Min(0)
waitBeforeResponse?: number;
}
export class CallsAppConfig {
+4
View File
@@ -92,10 +92,14 @@ export class CallsListener {
}
if (shouldReject) {
const waitBeforeDeclineMs = (config.waitBeforeDecline ?? 0) * 1000;
await sleep(waitBeforeDeclineMs);
await this.rejectCall(call);
}
if (shouldMessage) {
const waitBeforeResponseMs = (config.waitBeforeResponse ?? 0) * 1000;
await sleep(waitBeforeResponseMs);
await this.replyWithTyping(call.from, message);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
* Parse human string into milliseconds
*/
export function ParseMS(value: string) {
const duration = ms(value);
const duration = ms(value as ms.StringValue);
if (duration == null) throw new Error(`Invalid duration: "${value}"`);
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
return duration;
@@ -19,6 +19,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { CacheForConfig } from '../cache/ConversationCache';
import { IConversationCache } from '../cache/IConversationCache';
import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
@@ -63,6 +64,8 @@ export class ContactConversationService {
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
);
const attributes = await contactInfo.Attributes();
// Keep the current chat id in sync to reply using the latest address.
attributes[AttributeKey.WA_CHAT_ID] = chatId;
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
+5 -12
View File
@@ -2,7 +2,7 @@ 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';
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
export function GetJID(contact: any): string | null {
return contact?.custom_attributes?.[AttributeKey.WA_JID];
@@ -23,20 +23,13 @@ export function GetAllChatIDs(contact: any): Array<string> {
attrs[AttributeKey.WA_JID],
attrs[AttributeKey.WA_LID],
];
return lodash.uniq(ids.filter(Boolean));
return lodash.uniq(ids.filter(Boolean).map(normalizeJid));
}
export function FindChatID(contact: any): string | null {
if (GetJID(contact)) {
return GetJID(contact);
}
if (GetLID(contact)) {
return GetLID(contact);
}
if (GetChatID(contact)) {
return GetChatID(contact);
}
return null;
const chatId =
GetChatID(contact) ?? GetJID(contact) ?? GetLID(contact) ?? null;
return chatId ? normalizeJid(chatId) : null;
}
export function SerializeWhatsAppKey(message: WhatsAppMessage): string {
+10 -1
View File
@@ -8,6 +8,7 @@ import { InboxData } from '@waha/apps/chatwoot/consumers/types';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import {
ChatIDNotFoundForContactError,
ChatWootAppNotFoundError,
PhoneNumberNotFoundInWhatsAppError,
} from '@waha/apps/chatwoot/errors';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
@@ -43,7 +44,11 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -86,6 +91,10 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
await this.ReportErrorRecovered(job, body);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForMessage(job, err, body);
throw err;
}
@@ -16,6 +16,7 @@ import {
MessageMappingService,
} from '@waha/apps/chatwoot/storage';
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/messages/to/whatsapp/markdown';
import { parseMentionsFromText } from '@waha/apps/chatwoot/messages/to/whatsapp/mentions';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import {
@@ -36,6 +37,7 @@ import {
LinkPreview,
} from '@waha/apps/chatwoot/dto/config.dto';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { isJidGroup } from '@waha/core/utils/jids';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
@@ -90,7 +92,16 @@ export class MessageHandler {
return;
}
const content = MarkdownToWhatsApp(message.content);
let content: string = message.content;
let mentions: null | string[] = null;
if (isJidGroup(chatId)) {
// Parse any mention in the text and get a new clean text if required
const parsed = parseMentionsFromText(message.content);
content = parsed.text;
mentions = parsed.mentions;
}
content = MarkdownToWhatsApp(content);
const results = [];
let part = 0; // Start from 0 and increment before each send/check
const replyTo = await this.getReplyTo(message).catch((err) => {
@@ -116,7 +127,7 @@ export class MessageHandler {
content: content,
chatwoot: body,
});
const msg = await this.sendTextMessage(chatId, text, replyTo);
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
results.push(msg);
await this.saveMapping(message, msg, part);
this.logger.info(`Text message sent: ${msg.id}`);
@@ -139,7 +150,7 @@ export class MessageHandler {
chatwoot: body,
singleAttachment: attachments.length == 1,
});
const msg = await this.sendFile(chatId, caption, file, replyTo);
const msg = await this.sendFile(chatId, caption, replyTo, mentions, file);
this.logger.info(
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
);
@@ -196,6 +207,7 @@ export class MessageHandler {
chatId: string,
content: string,
replyTo: string,
mentions: string[] | null,
) {
const request: MessageTextRequest = {
session: '',
@@ -206,6 +218,7 @@ export class MessageHandler {
this.config.linkPreview,
),
linkPreviewHighQuality: this.config.linkPreview == LinkPreview.HQ,
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
@@ -219,8 +232,9 @@ export class MessageHandler {
private async sendFile(
chatId: string,
content: string,
file: MessageAttachment,
replyTo: string,
mentions: string[] | null,
file: MessageAttachment,
) {
const url = file.data_url;
let filename = url.split('/').pop();
@@ -245,6 +259,7 @@ export class MessageHandler {
url: url,
mimetype: mimetype,
},
mentions: mentions,
};
return session.sendImage(imageRequest);
case 'video':
@@ -262,6 +277,7 @@ export class MessageHandler {
filename: filename,
},
convert: true,
mentions: mentions,
};
return session.sendVideo(videoRequest);
case 'audio':
@@ -288,6 +304,7 @@ export class MessageHandler {
url: url,
mimetype: mimetype,
},
mentions: mentions,
};
return session.sendFile(fileRequest);
}
+10 -1
View File
@@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
/**
* Base class for ChatWoot scheduled consumers
@@ -35,7 +36,11 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -62,6 +67,10 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
await this.ReportErrorRecovered(job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForJob(job, err);
throw err;
}
+10 -1
View File
@@ -13,6 +13,7 @@ import { AppRepository } from '../../storage';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { SignalRace } from '@waha/utils/abortable';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
/**
* Base class for ChatWoot background task consumers
@@ -36,7 +37,11 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -68,6 +73,10 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
await this.ReportErrorRecovered(job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
this.ReportErrorForJob(job, err).catch((exc) =>
this.logger.error(`Failed to report error for job ${job.id}: ${exc}`),
);
+11 -2
View File
@@ -12,6 +12,7 @@ import { WhatsAppChatIdKey } from '@waha/apps/chatwoot/consumers/mutex';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import {
@@ -83,6 +84,10 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
await this.ReportErrorRecovered(errorReportInfo, job);
return result;
} catch (err) {
if (err instanceof ChatWootAppNotFoundError) {
this.logger.warn(err.message);
throw err;
}
await this.ReportErrorForMessage(errorReportInfo, job, err);
throw err;
}
@@ -95,7 +100,11 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
const knex = this.manager.store.getWAHADatabase();
this.appRepository = new AppRepository(knex);
const logger = new JobLoggerWrapper(job, this.logger);
const app = await this.appRepository.getById(appId);
const app = await this.appRepository.findEnabledAppById(appId);
if (!app) {
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
throw new ChatWootAppNotFoundError(appId);
}
return new DIContainer(app.pk, app.config, logger, knex);
}
@@ -338,7 +347,7 @@ export abstract class MessageBaseHandler<
chat_id: toCusFormat(key.remoteJid),
message_id: key.id,
from_me: key.fromMe,
participant: null,
participant: isJidGroup(key.remoteJid) ? key.participant : null,
};
await this.mappingService.map(chatwoot, whatsapp, 1);
return;
@@ -26,6 +26,7 @@ import {
PollMessage,
ListMessage,
ShareContactMessage,
StatusReplyMessage,
TextMessage,
UnsupportedMessage,
AlbumMessage,
@@ -86,6 +87,18 @@ export class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
return msg;
}
converter = new StatusReplyMessage(
this.l,
this.logger,
this.waha,
this.job,
this.session,
);
msg = await converter.convert(payload, protoMessage);
if (msg) {
return msg;
}
converter = new TextMessage(this.l, this.logger, this.waha, this.job);
msg = await converter.convert(payload, null);
if (msg) {
@@ -32,7 +32,10 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
}
GetChatId(event: WAHAWebhookMessageReaction): string {
if (event.environment?.engine == WAHAEngine.WEBJS) {
if (
event.environment?.engine == WAHAEngine.WEBJS ||
event.environment?.engine == WAHAEngine.WPP
) {
// chat in "to" field for WEBJS for message.reaction
// probably we need to set it to "from"
// but for backward compatability we do this
@@ -13,6 +13,7 @@ import {
isJidStatusBroadcast,
isLidUser,
isPnUser,
normalizeJid,
} from '@waha/core/utils/jids';
import { UnknownJIDFormat } from '@waha/apps/chatwoot/errors';
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
@@ -270,7 +271,7 @@ export function WhatsAppContactInfo(
} else if (isJidBroadcast(chatId)) {
return new BroadcastContactInfo(session, chatId, locale);
} else if (isLidUser(chatId)) {
return new LidContactInfo(session, chatId, locale);
return new LidContactInfo(session, normalizeJid(chatId), locale);
} else if (isPnUser(chatId)) {
return new JidContactInfo(session, chatId, locale);
} else {
@@ -45,6 +45,12 @@ export class ChatWootErrorReporter {
// There's retries more left - ignore it for now
return;
}
if (!conversation) {
this.logger.error(
'Chatwoot error report skipped: conversation not ready',
);
return;
}
const attempts = {
current: this.job.attemptsMade + 1,
max: this.job.opts?.attempts || 1,
@@ -87,6 +93,12 @@ export class ChatWootErrorReporter {
// https://github.com/devlikeapro/waha/issues/1395
return null;
if (!conversation) {
this.logger.warn(
'Chatwoot success report skipped: conversation not ready',
);
return;
}
const template = this.l.key(TKey.JOB_REPORT_SUCCEEDED);
const attempts = {
current: this.job.attemptsMade + 1,
+6
View File
@@ -15,3 +15,9 @@ export class UnknownJIDFormat extends Error {
super(`WhatsApp Chat ID is not recognized: ${jid}`);
}
}
export class ChatWootAppNotFoundError extends Error {
constructor(public appId: string) {
super(`Chatwoot app not found or disabled: ${appId}`);
}
}
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 ادعمنا**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **تم إرسال PIX نسخ ولصق**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **رد على حالة واتساب**
🏷️ **النوع:** {{type}}
{{#quotedText}}
📝 **نص الحالة:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **هذا النوع من الرسائل غير مدعوم في هذا الصندوق الوارد.**
📱 يرجى فتح **WhatsApp** لعرضه.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 আমাদের সমর্থন করুন**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX কপি এবং পেস্ট পাঠানো হয়েছে**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **WhatsApp স্ট্যাটাসের জবাব**
🏷️ **ধরন:** {{type}}
{{#quotedText}}
📝 **স্ট্যাটাসের টেক্সট:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **এই ধরনের বার্তা এই ইনবক্সে সমর্থিত নয়।**
📱 এটি দেখতে অনুগ্রহ করে **WhatsApp** খুলুন।
+13 -2
View File
@@ -338,12 +338,14 @@ waha.core.version.used: |-
[**🎁 Unterstützen Sie uns**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -490,6 +492,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Kopieren und Einfügen gesendet**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Antwort auf WhatsApp-Status**
🏷️ **Typ:** {{type}}
{{#quotedText}}
📝 **Status-Text:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Dieser Nachrichtentyp wird in dieser Inbox nicht unterstützt.**
📱 Bitte öffne **WhatsApp**, um ihn anzusehen.
+13 -2
View File
@@ -116,12 +116,14 @@ whatsapp.contact.broadcast.suffix: 'Broadcast List'
whatsapp.contact.status.name: 'Status'
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -268,6 +270,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Copy and Paste sent**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Reply to WhatsApp Status**
🏷️ **Type:** {{type}}
{{#quotedText}}
📝 **Status Text:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.has.media.no.media: |-
{{#content}}
{{{content}}}
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Apóyanos**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Copiar y Pegar enviado**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Respuesta al estado de WhatsApp**
🏷️ **Tipo:** {{type}}
{{#quotedText}}
📝 **Texto del Estado:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Este tipo de mensaje no es compatible con esta bandeja de entrada.**
📱 Por favor, abre **WhatsApp** para verlo.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 از ما حمایت کنید**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX کپی و چسباندن ارسال شد**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **پاسخ به وضعیت واتساپ**
🏷️ **نوع:** {{type}}
{{#quotedText}}
📝 **متن وضعیت:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **این نوع پیام در این صندوق ورودی پشتیبانی نمی‌شود.**
📱 لطفاً برای مشاهده آن **WhatsApp** را باز کنید.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Soutenez-nous**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Copier et Coller envoyé**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Réponse au statut WhatsApp**
🏷️ **Type :** {{type}}
{{#quotedText}}
📝 **Texte du Statut :** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Ce type de message n’est pas pris en charge dans cette boîte de réception.**
📱 Veuillez ouvrir **WhatsApp** pour l’afficher.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 תמוך בנו**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX העתק והדבק נשלח**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **תגובה לסטטוס ב-WhatsApp**
🏷️ **סוג:** {{type}}
{{#quotedText}}
📝 **טקסט הסטטוס:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **סוג הודעה זה אינו נתמך בתיבה זו.**
📱 אנא פתח/י את **WhatsApp** כדי לצפות בזה.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 हमें समर्थन दें**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX कॉपी और पेस्ट भेजा गया**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **WhatsApp स्टेटस का जवाब**
🏷️ **प्रकार:** {{type}}
{{#quotedText}}
📝 **स्टेटस टेक्स्ट:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **इस प्रकार का संदेश इस इनबॉक्स में समर्थित नहीं है।**
📱 इसे देखने के लिए कृपया **WhatsApp** खोलें।
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Dukung Kami**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Salin dan Tempel terkirim**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Balasan ke Status WhatsApp**
🏷️ **Tipe:** {{type}}
{{#quotedText}}
📝 **Teks Status:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Jenis pesan ini tidak didukung di kotak masuk ini.**
📱 Silakan buka **WhatsApp** untuk melihatnya.
+13 -2
View File
@@ -116,12 +116,14 @@ whatsapp.contact.broadcast.suffix: 'Lista Broadcast'
whatsapp.contact.status.name: 'Stato'
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -268,6 +270,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Copia e Incolla inviato**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Risposta allo stato di WhatsApp**
🏷️ **Tipo:** {{type}}
{{#quotedText}}
📝 **Testo dello Stato:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.has.media.no.media: |-
{{#content}}
{{{content}}}
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 ਸਾਡਾ ਸਮਰਥਨ ਕਰੋ**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX ਕਾਪੀ ਅਤੇ ਪੇਸਟ ਭੇਜਿਆ ਗਿਆ**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **WhatsApp ਸਟੇਟਸ ਦਾ ਜਵਾਬ**
🏷️ **ਕਿਸਮ:** {{type}}
{{#quotedText}}
📝 **ਸਟੇਟਸ ਲਿਖਤ:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **ਇਹ ਸੁਨੇਹੇ ਦੀ ਕਿਸਮ ਇਸ ਇਨਬਾਕਸ ਵਿੱਚ ਸਮਰਥਿਤ ਨਹੀਂ ਹੈ।**
📱 ਇਸਨੂੰ ਦੇਖਣ ਲਈ ਕਿਰਪਾ ਕਰਕੇ **WhatsApp** ਖੋਲ੍ਹੋ।
+13 -2
View File
@@ -336,12 +336,14 @@ waha.core.version.used: |-
[**🎁 Apoie-nos**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -492,6 +494,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Copia e Cola enviado**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Resposta a Status do WhatsApp**
🏷️ **Tipo:** {{type}}
{{#quotedText}}
📝 **Texto do Status:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Esse tipo de mensagem não é suportado nesta caixa de entrada.**
📱 Por favor, abra o **WhatsApp** para visualizá-la.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Поддержать нас**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Копировать и вставить отправлен**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Ответ на статус WhatsApp**
🏷️ **Тип:** {{type}}
{{#quotedText}}
📝 **Текст Статуса:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Этот тип сообщения не поддерживается в этом Inbox.**
📱 Пожалуйста, откройте **WhatsApp**, чтобы просмотреть.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Bizi Destekleyin**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Kopyala ve Yapıştır gönderildi**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **WhatsApp Durumuna Yanıt**
🏷️ **Tür:** {{type}}
{{#quotedText}}
📝 **Durum Metni:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Bu mesaj türü bu gelen kutusunda desteklenmiyor.**
📱 Görüntülemek için lütfen **WhatsApp’ı** açın.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 Підтримайте нас**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX Копіювати та Вставити надіслано**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **Відповідь на статус WhatsApp**
🏷️ **Тип:** {{type}}
{{#quotedText}}
📝 **Текст Статусу:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **Цей тип повідомлення не підтримується в цих вхідних.**
📱 Будь ласка, відкрийте **WhatsApp**, щоб переглянути.
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 ہماری حمایت کریں**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX کاپی اور پیسٹ بھیج دیا گیا**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **WhatsApp اسٹیٹس کا جواب**
🏷️ **قسم:** {{type}}
{{#quotedText}}
📝 **اسٹیٹس کا متن:** {{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **اس قسم کا پیغام اس انبکس میں معاونت یافتہ نہیں ہے۔**
📱 اسے دیکھنے کے لیے براہ کرم **WhatsApp** کھولیں۔
+13 -2
View File
@@ -335,12 +335,14 @@ waha.core.version.used: |-
[**🎁 支持我们**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{{ content }}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
💳 **PIX 复制粘贴已发送**
{{/pixData}}
whatsapp.to.chatwoot.message.status.reply: |-
📣 **回复 WhatsApp 状态**
🏷️ **类型:**{{type}}
{{#quotedText}}
📝 **状态文本:**{{quotedText}}
{{/quotedText}}
{{content}}
whatsapp.to.chatwoot.message.unsupported: |-
⚠️ **此收件箱不支持此消息类型。**
📱 请打开 **WhatsApp** 查看。
+6
View File
@@ -54,6 +54,7 @@ export enum TKey {
WA_TO_CW_MESSAGE_POLL = 'whatsapp.to.chatwoot.message.poll',
WA_TO_CW_MESSAGE_EVENT = 'whatsapp.to.chatwoot.message.event',
WA_TO_CW_MESSAGE_PIX = 'whatsapp.to.chatwoot.message.pix',
WA_TO_CW_MESSAGE_STATUS_REPLY = 'whatsapp.to.chatwoot.message.status.reply',
WA_TO_CW_MESSAGE_UNSUPPORTED = 'whatsapp.to.chatwoot.message.unsupported',
WA_TO_CW_MESSAGE_FACEBOOK_AD = 'whatsapp.to.chatwoot.message.facebook.ad',
WA_TO_CW_MESSAGE_LIST = 'whatsapp.to.chatwoot.message.list',
@@ -189,6 +190,11 @@ export type TemplatePayloads = {
message: proto.Message | null;
pixData: PixTemplatePayload;
};
[TKey.WA_TO_CW_MESSAGE_STATUS_REPLY]: {
type: string;
quotedText?: string;
content: string;
};
[TKey.WA_TO_CW_MESSAGE_UNSUPPORTED]: { details: Link };
[TKey.WA_TO_CW_MESSAGE_FACEBOOK_AD]: {
payload: WAMessage;
@@ -0,0 +1,231 @@
import type { proto } from '@adiwajshing/baileys';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { ChatWootMessagePartial } from '@waha/apps/chatwoot/consumers/waha/base';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
import { EngineHelper, QuotedMedia } from '@waha/apps/chatwoot/waha';
import { Jid } from '@waha/core/engines/const';
import { WAMessage } from '@waha/structures/responses.dto';
import { Job } from 'bullmq';
import { TextMessage } from './TextMessage';
import { isEmptyString } from './utils/text';
import { SendAttachment } from '@waha/apps/chatwoot/client/types';
import esm from '@waha/vendor/esm';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
type StatusReplyType = 'text' | 'image' | 'audio' | 'video' | 'unknown';
interface StatusQuotedMessageInfo {
data?: any;
id: string;
type: StatusReplyType;
text?: string;
}
export class StatusReplyMessage extends TextMessage {
constructor(
locale: Locale,
logger: ILogger,
waha: WAHASelf,
job: Job,
private readonly session: WAHASessionAPI,
) {
super(locale, logger, waha, job);
}
async convert(
payload: WAMessage,
protoMessage: proto.Message | null,
): Promise<ChatWootMessagePartial | null> {
if (!payload.replyTo?.id) {
return null;
}
if (!EngineHelper.IsReplyToStatus(payload, protoMessage)) {
return null;
}
let attachments: SendAttachment[] = [];
const quoted = this.getStatusQuotedMessageInfo(payload);
let wamessage = null; // aka "quoted from store"
// Media - Use provided replyTo.media if present
if (attachments.length === 0 && payload.replyTo.media) {
const extension = mime.extension(payload.replyTo.media.mimetype);
payload.replyTo.media.filename = `status-message.${extension}`;
const quotedAttachments = await super.getAttachments(payload.replyTo);
attachments = [...attachments, ...quotedAttachments];
}
// Media - fetch message by id from store and use it's "media"
if (attachments.length == 0) {
wamessage = await this.fetchStatusMessage(quoted.id);
if (wamessage?.media?.url) {
const extension = mime.extension(wamessage.media.mimetype);
wamessage.media.filename = `status-message.${extension}`;
const quotedAttachments = await super.getAttachments(wamessage);
attachments = [...attachments, ...quotedAttachments];
}
}
// Media - get media url, key and try to decrypt it
if (attachments.length == 0) {
const replyData = payload.replyTo._data;
const quotedMedia = EngineHelper.ExtractQuotedMedia(replyData);
if (quotedMedia) {
const attachment =
await this.downloadQuotedMediaAttachment(quotedMedia);
if (attachment) {
attachments.push(attachment);
}
}
}
// Act as regular text message, but we'll adjust some properties
const msg = await super.convert(payload, protoMessage);
// First goes Quoted attachments, then what sender sent
msg.attachments = [...attachments, ...msg.attachments];
// Wrap content with additional info
const quotedText = quoted.text ?? wamessage?.body ?? payload.replyTo?.body;
if (
isEmptyString(quotedText) &&
isEmptyString(msg.content) &&
attachments.length === 0
) {
// Nothing to send.
// Must check msg.content before wrapping it in status reply
return null;
}
msg.content = this.locale.r(TKey.WA_TO_CW_MESSAGE_STATUS_REPLY, {
type: quoted.type,
quotedText: WhatsappToMarkdown(quotedText),
content: msg.content,
});
return msg;
}
private getStatusQuotedMessageInfo(
payload: WAMessage,
): StatusQuotedMessageInfo {
const replyData = payload.replyTo._data;
const imageMessage =
replyData?.imageMessage || this.getWebjsMediaData(replyData, 'image');
if (imageMessage) {
return {
data: replyData,
id: payload.replyTo.id,
type: 'image',
text: imageMessage.caption ?? payload.replyTo.body ?? replyData?.body,
};
}
const videoMessage =
replyData?.videoMessage || this.getWebjsMediaData(replyData, 'video');
if (videoMessage) {
return {
data: replyData,
id: payload.replyTo.id,
type: 'video',
text: videoMessage.caption ?? payload.replyTo.body ?? replyData?.body,
};
}
const audioMessage =
replyData?.audioMessage || this.getWebjsMediaData(replyData, 'audio');
if (audioMessage) {
return {
data: replyData,
id: payload.replyTo.id,
type: 'audio',
text: audioMessage.caption ?? payload.replyTo.body ?? replyData?.body,
};
}
const quotedText =
replyData?.extendedTextMessage?.text ??
replyData?.conversation ??
payload.replyTo.body ??
replyData?.body;
return {
data: replyData,
id: payload.replyTo.id,
type: quotedText ? 'text' : 'unknown',
text: quotedText,
};
}
private getWebjsMediaData(
replyData: any,
expectedKind: 'image' | 'video' | 'audio',
): any | null {
if (!replyData) {
return null;
}
const kind = replyData.kind ?? replyData.type;
if (kind !== expectedKind) {
return null;
}
return replyData;
}
private async downloadQuotedMediaAttachment(
quotedMedia: QuotedMedia,
): Promise<SendAttachment | null> {
try {
const decrypt = !!quotedMedia.mediaKey;
const mediaKeyBuffer = decrypt
? Buffer.from(quotedMedia.mediaKey, 'base64')
: undefined;
const stream = await esm.b.downloadContentFromMessage(
{
mediaKey: mediaKeyBuffer,
directPath: quotedMedia.directPath,
url: quotedMedia.url,
},
quotedMedia.mediaType as any,
{},
decrypt,
);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const extension = mime.extension(quotedMedia.mimetype);
const filename = `status-message.${extension}`;
return {
content: buffer.toString('base64'),
filename: filename,
encoding: 'base64',
};
} catch (error) {
this.logger.warn('Failed to download quoted status media directly');
this.logger.debug(error);
return null;
}
}
private async fetchStatusMessage(
replyMessageId: string,
): Promise<WAMessage | null> {
try {
const quotedMessage = await this.session.getMessageById(
Jid.BROADCAST,
replyMessageId,
true,
);
return quotedMessage || null;
} catch (error) {
this.logger.warn(
`Failed to fetch original status message '${replyMessageId}'`,
);
this.logger.debug(error);
return null;
}
}
}
@@ -11,16 +11,17 @@ import { MessageToChatWootConverter } from '@waha/apps/chatwoot/messages/to/chat
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
import { Job } from 'bullmq';
import { WAMedia } from '@waha/structures/media.dto';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
export class TextMessage implements MessageToChatWootConverter {
constructor(
private readonly locale: Locale,
private readonly logger: ILogger,
private readonly waha: WAHASelf,
private readonly job: Job,
protected readonly locale: Locale,
protected readonly logger: ILogger,
protected readonly waha: WAHASelf,
protected readonly job: Job,
) {}
async convert(
@@ -52,12 +53,14 @@ export class TextMessage implements MessageToChatWootConverter {
}
return {
content: WhatsappToMarkdown(content),
attachments,
attachments: attachments,
private: undefined,
};
}
private async getAttachments(payload: WAMessage): Promise<SendAttachment[]> {
protected async getAttachments(payload: {
media?: WAMedia;
}): Promise<SendAttachment[]> {
const hasMedia = payload.media?.url;
if (!hasMedia) {
return [];
@@ -13,6 +13,7 @@ export { EventMessage } from './EventMessage';
export { PixMessage } from './PixMessage';
export { ListMessage } from './ListMessage';
export { AlbumMessage } from './AlbumMessage';
export { StatusReplyMessage } from './StatusReplyMessage';
export type Awaitable<T> = T | Promise<T>;
@@ -15,6 +15,14 @@ describe('MarkdownToWhatsApp', () => {
expect(MarkdownToWhatsApp(input)).toBe(expected);
});
// ChatWoot v4.10.1+ escapes newlines with trailing backslash
it('strips trailing backslash before newlines', () => {
const input = 'Hi \\\ntext message \\\nwith line \\\nbreak';
expect(MarkdownToWhatsApp(input)).toBe(
'Hi \ntext message \nwith line \nbreak',
);
});
// Tests for URL protection
it('preserves URLs with multiple underscores', () => {
const input = 'https://example.com/page__name__test';
@@ -4,6 +4,8 @@ export function MarkdownToWhatsApp(text: string): string {
}
return (
text
// Remove trailing backslash before newlines (ChatWoot v4.10.1+ escaping)
.replace(/\\\n/g, '\n')
// Triple-backtick blocks → WhatsApp monospace blocks (same syntax)
.replace(/```([\s\S]*?)```/g, '```$1```')
// Italic **first**, but only single‐star or single‐underscore
@@ -0,0 +1,130 @@
import { parseMentionsFromText } from './mentions';
describe('parseMentionsFromText', () => {
it('returns null mentions when none exist', () => {
const input = 'Hello there';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello there',
mentions: null,
});
});
it('handles empty content safely', () => {
expect(parseMentionsFromText(null)).toEqual({
text: null,
mentions: null,
});
});
it('parses @all and removes it from the text', () => {
const input = 'Hello @all';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello',
mentions: ['all'],
});
});
it('parses @ALL case-insensitively', () => {
const input = 'Hello @ALL';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hello',
mentions: ['all'],
});
});
it('uses a fallback text when only @all is present', () => {
const input = '@all';
expect(parseMentionsFromText(input)).toEqual({
text: ' ',
mentions: ['all'],
});
});
it('parses phone number mentions with a suffix', () => {
const input = 'Ping @558591203123';
expect(parseMentionsFromText(input)).toEqual({
text: 'Ping @558591203123',
mentions: ['558591203123@c.us'],
});
});
it('deduplicates phone number mentions', () => {
const input = 'Hi @1111111 and @1111111';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hi @1111111 and @1111111',
mentions: ['1111111@c.us'],
});
});
it('handles @all alongside phone mentions', () => {
const input = '@all Hi @558591203123';
expect(parseMentionsFromText(input)).toEqual({
text: 'Hi @558591203123',
mentions: ['all', '558591203123@c.us'],
});
});
it('ignores quoted @all text', () => {
const input = "You can send '@all' to chat";
expect(parseMentionsFromText(input)).toEqual({
text: "You can send '@all' to chat",
mentions: null,
});
});
it('ignores email-like mentions', () => {
const input = 'Reach us at email@12341domain.com';
expect(parseMentionsFromText(input)).toEqual({
text: 'Reach us at email@12341domain.com',
mentions: null,
});
});
it('ignores @all when followed by letters', () => {
const input = 'Please ping @allx';
expect(parseMentionsFromText(input)).toEqual({
text: 'Please ping @allx',
mentions: null,
});
});
it('ignores phone mentions followed by letters', () => {
const input = 'Call @1234567abc';
expect(parseMentionsFromText(input)).toEqual({
text: 'Call @1234567abc',
mentions: null,
});
});
it('ignores @all inside email-like text', () => {
const input = 'Contact email@alldomain.com for details';
expect(parseMentionsFromText(input)).toEqual({
text: 'Contact email@alldomain.com for details',
mentions: null,
});
});
it('accepts phone mentions with trailing punctuation', () => {
const input = 'Ping @1231234, then @5555555.';
expect(parseMentionsFromText(input)).toEqual({
text: 'Ping @1231234, then @5555555.',
mentions: ['1231234@c.us', '5555555@c.us'],
});
});
it('parses @lid mentions without c.us suffix', () => {
const input = 'Notify @123123@lid now';
expect(parseMentionsFromText(input)).toEqual({
text: 'Notify @123123 now',
mentions: ['123123@lid'],
});
});
it('replaces @lid suffix with plain ids', () => {
const input = '@123456@lid please ping @654321@lid.';
expect(parseMentionsFromText(input)).toEqual({
text: '@123456 please ping @654321.',
mentions: ['123456@lid', '654321@lid'],
});
});
});
@@ -0,0 +1,91 @@
import { ensureSuffix } from '@waha/core/abc/session.abc';
import { isJidGroup } from '@waha/core/utils/jids';
export interface ParsedMentions {
text: string;
mentions?: string[];
}
/**
* Parse mentions from text message
* Supports:
* - @all - mentions all group participants
* - @558591203123 - mentions specific phone number
*
* @param text - The message text
* @returns Parsed mentions with cleaned text
*/
export function parseMentionsFromText(
text: string | null | undefined,
): ParsedMentions {
if (!text) {
return {
text: text,
mentions: null,
};
}
const mentions: string[] = [];
let content = text;
// Regex to match @all (case-insensitive, word boundary)
// Use separate regex instances to avoid lastIndex issues with global flag
const allRegexTest = /(^|\s)@all\b/gi;
if (allRegexTest.test(text)) {
mentions.push('all');
// Remove @all from text - WAHA endpoint doesn't display it, only mentions all participants
// Create a new regex instance for replace to avoid lastIndex issues
const allRegexReplace = /(^|\s)@all\b/gi;
content = content.replace(allRegexReplace, '$1').trim();
}
const lidRegex = /(^|\s)@(\d{6,15})@lid\b/g;
const lidMatches = text.matchAll(lidRegex);
for (const match of lidMatches) {
const lidNumber = match[2];
const formattedLid = `${lidNumber}@lid`;
if (!mentions.includes(formattedLid)) {
mentions.push(formattedLid);
}
}
if (mentions.length > 0) {
const lidRegexReplace = /(^|\s)@(\d{6,15})@lid\b/g;
content = content.replace(lidRegexReplace, '$1@$2').trim();
}
// Regex to match @ followed by phone number
// Matches: @558591203123, @5585991203123, etc.
// Phone numbers: 7-15 digits (international format)
const phoneRegex = /(^|\s)@(\d{7,15})(?!@lid)\b/g;
const phoneMatches = text.matchAll(phoneRegex);
for (const match of phoneMatches) {
const phoneNumber = match[2];
// Format phone number to @c.us format
const formattedJid = ensureSuffix(phoneNumber);
if (!mentions.includes(formattedJid)) {
mentions.push(formattedJid);
}
}
if (mentions.length == 0) {
// Nothing found
return {
text: text,
mentions: null,
};
}
// Ensure text is not empty when there are mentions
// If text becomes empty after removing mentions, use a default message
content = content.trim();
if (content === '') {
// When only mentions are present, use a minimal text to satisfy DTO requirements
// The mentions array will handle the actual mentions
content = ' ';
}
return {
text: content,
mentions: mentions,
};
}
+201 -14
View File
@@ -1,17 +1,34 @@
import * as lodash from 'lodash';
import type { proto } from '@adiwajshing/baileys';
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
import { WAHAEngine } from '@waha/structures/enums.dto';
import { getEngineName } from '@waha/version';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { Jid } from '@waha/core/engines/const';
import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { WAMessage } from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
import { getContextInfo } from '@waha/core/utils/pwa';
export interface QuotedMedia {
url?: string;
directPath: string;
mediaKey?: string;
mimetype: string;
mediaType: string;
}
interface IEngineHelper {
ChatID(message: WAMessage | any): string;
CallChatID(call: CallData | any): string;
IsReplyToStatus(
message: WAMessage,
protoMessage: proto.Message | null,
): boolean;
WhatsAppMessageKeys(message: any): WhatsAppMessage;
IterateMessages<T extends { timestamp: number }>(
@@ -23,6 +40,8 @@ interface IEngineHelper {
FilterChatIdsForMessages(chats: string[]): string[];
SupportsAllChatForMessage(): boolean;
ExtractQuotedMedia(replyData: any): QuotedMedia | null;
}
class NOWEBHelper implements IEngineHelper {
@@ -34,6 +53,14 @@ class NOWEBHelper implements IEngineHelper {
return call.from;
}
IsReplyToStatus(
message: WAMessage,
protoMessage: proto.Message | null,
): boolean {
void message;
return getContextInfo(protoMessage)?.remoteJid === Jid.BROADCAST;
}
WhatsAppMessageKeys(message: any): WhatsAppMessage {
const timestamp = parseInt(message.messageTimestamp) * 1000;
return {
@@ -52,7 +79,7 @@ class NOWEBHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
return chats;
return preferPnChats(chats);
}
ContactIsMy(contact) {
@@ -62,6 +89,10 @@ class NOWEBHelper implements IEngineHelper {
SupportsAllChatForMessage(): boolean {
return true;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractProtoQuotedMedia(replyData);
}
}
class GOWSHelper implements IEngineHelper {
@@ -73,6 +104,14 @@ class GOWSHelper implements IEngineHelper {
return call._data?.CallCreator || call.from;
}
IsReplyToStatus(
message: WAMessage,
protoMessage: proto.Message | null,
): boolean {
void message;
return getContextInfo(protoMessage)?.remoteJid === Jid.BROADCAST;
}
/**
* Parse API response and get the data
* API Response depends on engine right now
@@ -96,7 +135,7 @@ class GOWSHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
return chats;
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
@@ -106,6 +145,10 @@ class GOWSHelper implements IEngineHelper {
ContactIsMy(contact) {
return true;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractProtoQuotedMedia(replyData, true);
}
}
class WEBJSHelper implements IEngineHelper {
@@ -117,6 +160,14 @@ class WEBJSHelper implements IEngineHelper {
return call.from;
}
IsReplyToStatus(
message: WAMessage,
protoMessage: proto.Message | null,
): boolean {
void protoMessage;
return message._data?.quotedRemoteJid === Jid.BROADCAST;
}
/**
* Parse API response and get the data for WEBJS engine
*/
@@ -124,7 +175,7 @@ class WEBJSHelper implements IEngineHelper {
return {
timestamp: new Date(message.timestamp * 1000),
from_me: message.fromMe,
chat_id: message.from,
chat_id: message.id.remote,
message_id: message.id.id,
participant: message.author || null,
};
@@ -150,17 +201,7 @@ class WEBJSHelper implements IEngineHelper {
}
FilterChatIdsForMessages(chats: string[]): string[] {
if (chats.length == 2) {
const lidChat = chats.find(isLidUser);
const cusChat = chats.find(isPnUser);
if (lidChat && cusChat) {
return [lidChat];
}
// WEBJS engine merges messages for @lid and @c.us
// into single chat, so it's fine to pull only from one
}
// Otherwise - return the original
return chats;
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
@@ -170,6 +211,78 @@ class WEBJSHelper implements IEngineHelper {
ContactIsMy(contact) {
return contact.isMyContact;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractBrowserQuotedMedia(replyData);
}
}
class WPPHelper implements IEngineHelper {
ChatID(message: WAMessage): string {
return toCusFormat(parseMessageIdSerialized(message.id as any).remoteJid);
}
CallChatID(call: CallData): string {
return call.from;
}
IsReplyToStatus(
message: WAMessage,
protoMessage: proto.Message | null,
): boolean {
void protoMessage;
return message._data?.quotedRemoteJid === Jid.BROADCAST;
}
/**
* Parse API response and get the data for WPP engine.
* WPP returns message.id as a composite string "fromMe_chatId_msgId".
*/
WhatsAppMessageKeys(message: any): WhatsAppMessage {
const parsed = parseMessageIdSerialized(message.id);
return {
timestamp: new Date(message.timestamp * 1000),
from_me: parsed.fromMe,
chat_id: toCusFormat(parsed.remoteJid),
message_id: parsed.id,
participant: message.author || null,
};
}
/**
* WPP API lacks server-side sorting hooks, so we buffer and sort by the unix timestamp in memory.
*/
async *IterateMessages<T extends { timestamp: number }>(
messages: AsyncGenerator<T>,
): AsyncGenerator<T> {
const buffer: T[] = [];
for await (const message of messages) {
buffer.push(message);
}
const sorted = lodash.sortBy(buffer, (item) => item.timestamp);
for (const message of sorted) {
yield message;
}
}
FilterChatIdsForMessages(chats: string[]): string[] {
return preferPnChats(chats);
}
SupportsAllChatForMessage(): boolean {
return false;
}
ContactIsMy(contact) {
return contact.isMyContact;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractBrowserQuotedMedia(replyData);
}
}
// Choose the right EngineHelper based on getEngineName() function
@@ -185,8 +298,82 @@ switch (getEngineName()) {
case WAHAEngine.WEBJS:
engineHelper = new WEBJSHelper();
break;
case WAHAEngine.WPP:
engineHelper = new WPPHelper();
break;
default:
engineHelper = new WEBJSHelper(); // Default to WEBJS as it's the default engine
}
export const EngineHelper = engineHelper;
//
// Quoted media extraction helpers
//
function extractProtoQuotedMedia(
replyData: any,
capitalUrl = false,
): QuotedMedia | null {
if (!replyData) {
return null;
}
const mediaTypes: Array<[string, string]> = [
['imageMessage', 'image'],
['videoMessage', 'video'],
['audioMessage', 'audio'],
];
for (const [key, mediaType] of mediaTypes) {
const msg = replyData[key];
if (!msg) {
continue;
}
const url: string | undefined = capitalUrl ? msg.URL ?? msg.url : msg.url;
const directPath: string = msg.directPath;
const mimetype: string = msg.mimetype;
if (!directPath || !mimetype) {
continue;
}
return {
url: url,
directPath: directPath,
mediaKey: msg.mediaKey,
mimetype: mimetype,
mediaType: mediaType,
};
}
return null;
}
function extractBrowserQuotedMedia(replyData: any): QuotedMedia | null {
if (!replyData) {
return null;
}
const kind: string = replyData.kind ?? replyData.type;
if (kind !== 'image' && kind !== 'video' && kind !== 'audio') {
return null;
}
const directPath: string = replyData.directPath;
const mimetype: string = replyData.mimetype;
if (!directPath || !mimetype) {
return null;
}
return {
url: replyData.deprecatedMms3Url,
directPath: directPath,
mediaKey: replyData.mediaKey,
mimetype: mimetype,
mediaType: kind,
};
}
function preferPnChats(chats: string[]): string[] {
const unique = lodash.uniq(chats ?? []);
const hasPn = unique.some(isPnUser);
const hasLid = unique.some(isLidUser);
if (hasPn && hasLid) {
// Prefer @c.us / phone chats when both formats exist to avoid duplicate fetches.
return unique.filter(isPnUser);
}
return unique;
}
+2
View File
@@ -1,3 +1,5 @@
import { EngineHelper } from './engines';
import type { QuotedMedia } from './engines';
export { EngineHelper };
export type { QuotedMedia };
+12
View File
@@ -5,3 +5,15 @@ export function getEngineName(): string {
// If not set - use WEBJS
return process.env.WHATSAPP_DEFAULT_ENGINE || WAHAEngine.WEBJS;
}
export function getNamespace(): string {
// Controls the prefix used for the main database and folder (non-session data)
// If not set - falls back to engine name for backward compatibility
return (process.env.WAHA_NAMESPACE || getEngineName()).toLowerCase();
}
export function getSessionNamespace(): string {
// Controls the prefix used for session databases and folders
// If not set - falls back to engine name for backward compatibility
return (process.env.WAHA_SESSION_NAMESPACE || getEngineName()).toLowerCase();
}
-38
View File
@@ -1,38 +0,0 @@
import { Body, Controller, Delete, Param, Put, UsePipes } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { SessionManager } from './core/abc/manager.abc';
import { Result } from './structures/base.dto';
import { ContactUpdateBody } from './structures/contacts.dto';
@ApiSecurity('api_key')
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
@Put('/:chatId')
@SessionApiParam
@ChatIdApiParam
@ApiOperation({
summary: 'Create or update contact',
description:
'Create or update contact on the phone address book. May not work if you have installed many WhatsApp apps on the same phone',
})
@UsePipes(new WAHAValidationPipe())
async put(
@WorkingSessionParam session: WhatsappSession,
@Param('chatId') chatId: string,
@Body() body: ContactUpdateBody,
): Promise<Result> {
await session.upsertContact(chatId, body);
return { success: true };
}
}
+7 -5
View File
@@ -74,15 +74,14 @@ export class SwaggerConfiguratorCore {
.setExternalDoc(this.title, this.externalDocUrl)
.setVersion(VERSION.version)
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔑 Auth', 'Authentication')
.addTag('📱 Pairing', 'Pair a session with WhatsApp on your phone.')
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📞 Calls', 'Call handling methods')
.addTag('✅ Presence', `Presence information`)
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
.addTag('🔑 Api Keys', 'API Keys management')
.addTag(
'👤 Contacts',
`Contacts methods.<br>
@@ -90,13 +89,14 @@ export class SwaggerConfiguratorCore {
'E.g: \`12312312310\` OR \`12312312310@c.us\`<br>`,
)
.addTag('👥 Groups', `Groups methods.<br>`)
.addTag('✅ Presence', `Presence information`)
.addTag('📞 Calls', 'Call handling methods')
.addTag('📅 Events', `Event Message`)
.addTag(
'🏷️ Labels',
'Labels - available only for WhatsApp Business accounts',
)
.addTag('🖼️ Media', 'Media methods')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔍 Observability', 'Other methods')
.addTag('🗄️ Storage', 'Storage methods')
.addApiKey({
@@ -195,6 +195,8 @@ export class SwaggerConfiguratorCore {
'/ping',
'/ws',
'/webhooks/',
'/jobs',
'/jobs/',
...config.getExcludedFullPaths(),
]);
+2
View File
@@ -36,6 +36,7 @@ import {
import { ISessionAuthRepository } from '../storage/ISessionAuthRepository';
import { ISessionConfigRepository } from '../storage/ISessionConfigRepository';
import { WhatsappSession } from './session.abc';
import { IApiKeyRepository } from '@waha/core/storage/IApiKeyRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -48,6 +49,7 @@ export abstract class SessionManager
public sessionConfigRepository: ISessionConfigRepository;
protected sessionMeRepository: ISessionMeRepository;
protected sessionWorkerRepository: ISessionWorkerRepository;
public apiKeyRepository: IApiKeyRepository;
private lock: any;
WAIT_SESSION_RUNNING_INTERVAL = 500;
+7 -12
View File
@@ -1,3 +1,6 @@
import {
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
} from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
@@ -50,7 +53,6 @@ import * as lodash from 'lodash';
import * as NodeCache from 'node-cache';
import { Logger } from 'pino';
import {
BehaviorSubject,
catchError,
delay,
filter,
@@ -143,15 +145,6 @@ const qrcode = require('qrcode-terminal');
axiosRetry(axios, { retries: 3 });
const CHROME_PATH = '/usr/bin/google-chrome-stable';
const CHROMIUM_PATH = '/usr/bin/chromium';
export function getBrowserExecutablePath() {
if (fs.existsSync(CHROME_PATH)) {
return CHROME_PATH;
}
return CHROMIUM_PATH;
}
export function ensureSuffix(phone) {
const suffix = '@c.us';
@@ -228,7 +221,8 @@ export abstract class WhatsappSession {
engineConfig,
ignore,
}: SessionParams) {
this.status$ = new BehaviorSubject(WAHASessionStatus.STOPPED);
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<WAHASessionStatus>();
this.name = name;
this.proxyConfig = proxyConfig;
@@ -360,7 +354,7 @@ export abstract class WhatsappSession {
}
getBrowserExecutablePath() {
return getBrowserExecutablePath();
return getBrowserExecutablePathAutodetect();
}
getBrowserArgsForPuppeteer() {
@@ -381,6 +375,7 @@ export abstract class WhatsappSession {
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-metrics',
// '--disable-features=site-per-process', // COMMENTED to test WEBJS stability
'--disable-gpu', // COMMENTED to test WEBJS stability
'--disable-offer-store-unmasked-wallet-cards',
+42
View File
@@ -0,0 +1,42 @@
import * as fs from 'fs';
//
// Browser executable path detection
//
// Ordered: Chrome paths first, then Chromium.
// Windows: only global Program Files paths (no per-user %LOCALAPPDATA%).
const BROWSER_PATHS: Record<string, string[]> = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Chromium\\Application\\chrome.exe',
],
linux: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
],
};
export function getBrowserExecutablePath(): string {
const paths = BROWSER_PATHS[process.platform] ?? BROWSER_PATHS['linux'];
for (const p of paths) {
if (fs.existsSync(p)) {
return p;
}
}
return paths[0];
}
// A path belongs to Google Chrome when it contains "google" in any casing.
// All Chrome paths include "Google" (macOS/Windows) or "google-chrome" (Linux),
// while Chromium paths never do.
export function isChromeExecutablePath(executablePath: string): boolean {
return executablePath.toLowerCase().includes('google');
}
+18 -4
View File
@@ -9,18 +9,20 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { LidsController } from '@waha/api/lids.controller';
import { ApiKeysController } from '@waha/api/apikeys.controller';
import { ProfileController } from '@waha/api/profile.controller';
import { ServerController } from '@waha/api/server.controller';
import { ServerDebugController } from '@waha/api/server.debug.controller';
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
import { AppsModuleExports } from '@waha/apps/apps.module';
import { ContactsSessionController } from '@waha/contacts.session.controller';
import { ContactsSessionController } from '@waha/api/contacts.session.controller';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
@@ -64,6 +66,9 @@ import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore';
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
@@ -140,6 +145,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
ApiKeysController,
SessionsController,
ProfileController,
ChattingController,
@@ -171,6 +177,7 @@ export const PROVIDERS_BASE: Provider[] = [
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
WhatsappConfigService,
EngineConfigService,
@@ -178,6 +185,9 @@ export const PROVIDERS_BASE: Provider[] = [
MediaLocalStorageConfig,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
CaslAbilityFactory,
PoliciesGuard,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -234,11 +244,15 @@ export class AppModuleCore {
}
configure(consumer: MiddlewareConsumer) {
// Because we use ServeStaticModule, we need to inject a middleware
// ServeStaticModule does not support @UseGuards
const exclude = this.config.getExcludedPaths();
consumer
.apply(AuthMiddleware)
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'ws');
.forRoutes('api', 'health');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { User } from '@waha/core/auth/apiKey.strategy';
import { SessionManager } from '@waha/core/abc/manager.abc';
@Injectable()
export class ApiKeyService {
constructor(private manager: SessionManager) {}
async get(apikey: string): Promise<User | null> {
if (!apikey) {
return null;
}
const key = await this.manager.apiKeyRepository.getActiveByKey(apikey);
if (!key) {
return null;
}
return {
isAdmin: key.isAdmin,
session: key.session,
};
}
}
+20 -22
View File
@@ -1,34 +1,32 @@
import { Injectable } from '@nestjs/common';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { IApiKeyAuth } from './auth';
import { URL } from 'url';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
@Injectable()
export class WebSocketAuth {
constructor(private auth: IApiKeyAuth) {}
constructor(private strategy: ApiKeyStrategy) {}
validateRequest(request: IncomingMessage) {
if (this.auth.skipAuth()) {
return true;
}
const provided = this.getKeyFromQueryParams(request);
return this.auth.isValid(provided);
async validateRequest(request: IncomingMessage) {
const apikey = this.getKeyFromQueryParams('x-api-key', request);
return await this.strategy.user(apikey);
}
private getKeyFromQueryParams(request: IncomingMessage) {
let query = url.parse(request.url, true).query;
// case-insensitive query params
query = Object.keys(query).reduce((acc, key) => {
acc[key.toLowerCase()] = query[key];
return acc;
}, {});
private getKeyFromQueryParams(name: string, request: IncomingMessage) {
// Case-insensitive
name = name.toLowerCase();
const query = new URL(request.url || '', 'http://localhost').searchParams;
const matches: string[] = [];
const provided = query['x-api-key'];
// Check if it's array - return first
if (Array.isArray(provided)) {
return provided[0];
for (const [key, value] of query.entries()) {
if (key.toLowerCase() === name) {
matches.push(value);
}
}
return provided;
if (matches.length === 0) {
return undefined;
}
return matches[0];
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard('headerapikey') {}
+37
View File
@@ -0,0 +1,37 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import { IApiKeyAuth, NoAuth } from '@waha/core/auth/auth';
import * as passport from 'passport';
@Injectable()
export class ApiKeyAuthMiddleware implements NestMiddleware {
constructor(private auth: IApiKeyAuth) {}
use(req: any, res: any, next: () => void) {
if (this.auth instanceof NoAuth) {
next();
return;
}
passport.authenticate('headerapikey', { session: false }, (err, user?) => {
if (err) {
const exception =
err instanceof UnauthorizedException
? err
: new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
if (!user) {
const exception = new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
req.user = user;
next();
})(req, res, next);
}
}
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more