Compare commits

...
116 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
119 changed files with 7114 additions and 520 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:*)"
]
}
}
+2
View File
@@ -21,6 +21,8 @@ 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
+9 -8
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'
+31 -31
View File
@@ -6,37 +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
# 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
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
@@ -45,7 +45,7 @@ jobs:
matrix:
include:
# Chromium - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
@@ -53,7 +53,7 @@ jobs:
goss: 'goss-linux-amd64'
# Chrome - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'dev-chrome'
platform: 'amd64'
browser: 'chrome'
@@ -61,7 +61,7 @@ jobs:
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
browser: 'chromium'
+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
+74 -4
View File
@@ -78,6 +78,11 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- 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
@@ -108,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
@@ -126,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
+13 -4
View File
@@ -35,13 +35,20 @@ for-swagger:
export WHATSAPP_SWAGGER_CONFIG_ADVANCED=true && export WHATSAPP_SWAGGER_PASSWORD=666 && yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-01-25
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-2026-02-01
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 -1
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
+9 -4
View File
@@ -29,7 +29,7 @@
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-01-25",
"@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",
@@ -57,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",
@@ -104,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-2026-02-01",
"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"
},
@@ -124,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",
@@ -138,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",
+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}`);
+5 -4
View File
@@ -131,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);
}
+4 -4
View File
@@ -25,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,
@@ -61,7 +61,7 @@ class ChatsController {
@ApiOperation({ summary: 'Get chats' })
getChats(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: ChatsPaginationParams,
@Query() pagination: GetChatsParams,
) {
return session.getChats(pagination);
}
@@ -75,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);
+23 -1
View File
@@ -1,12 +1,13 @@
import {
Body,
Controller,
Get,
Param,
Put,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
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';
@@ -31,6 +32,27 @@ import { Action } from '@waha/core/auth/casl.types';
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
+1 -1
View File
@@ -31,7 +31,7 @@ export class ScreenshotController {
@Get('/screenshot')
@ApiOperation({
summary:
'Get a screenshot of the current WhatsApp session (**WEBJS** only)',
'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)',
})
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
+1 -1
View File
@@ -81,7 +81,7 @@ class SessionsController {
@Req() req,
): Promise<SessionInfo[]> {
let sessions = await this.manager.getSessions(query.all);
if (!req.user.isAdmin) {
if (!req.user?.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
+1
View File
@@ -93,6 +93,7 @@ export class WebsocketGatewayCore
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}`);
+9 -1
View File
@@ -22,7 +22,12 @@ import {
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 { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
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';
@@ -65,6 +70,7 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
@@ -79,6 +85,7 @@ export class AppsController {
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@@ -114,6 +121,7 @@ export class AppsController {
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@CheckPolicies(CanServer(Action.Read))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
+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();
@@ -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);
@@ -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);
+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;
+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 (GetChatID(contact)) {
return GetChatID(contact);
}
if (GetJID(contact)) {
return GetJID(contact);
}
if (GetLID(contact)) {
return GetLID(contact);
}
return null;
const chatId =
GetChatID(contact) ?? GetJID(contact) ?? GetLID(contact) ?? null;
return chatId ? normalizeJid(chatId) : null;
}
export function SerializeWhatsAppKey(message: WhatsAppMessage): string {
+1 -1
View File
@@ -347,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 {
+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
+200 -13
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
*/
@@ -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();
}
+2
View File
@@ -195,6 +195,8 @@ export class SwaggerConfiguratorCore {
'/ping',
'/ws',
'/webhooks/',
'/jobs',
'/jobs/',
...config.getExcludedFullPaths(),
]);
+4 -10
View File
@@ -1,3 +1,6 @@
import {
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
} from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
@@ -142,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';
@@ -360,7 +354,7 @@ export abstract class WhatsappSession {
}
getBrowserExecutablePath() {
return getBrowserExecutablePath();
return getBrowserExecutablePathAutodetect();
}
getBrowserArgsForPuppeteer() {
+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');
}
+2
View File
@@ -22,6 +22,7 @@ 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';
@@ -176,6 +177,7 @@ export const PROVIDERS_BASE: Provider[] = [
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
WhatsappConfigService,
EngineConfigService,
+8 -3
View File
@@ -1,22 +1,27 @@
import {
Injectable,
InternalServerErrorException,
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() {}
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 InternalServerErrorException();
: new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
+4
View File
@@ -26,6 +26,10 @@ export class PoliciesGuard implements CanActivate {
context.getClass(),
]) || [];
if (handlers.length === 0) {
throw new ForbiddenException();
}
const req = context.switchToHttp().getRequest();
const user = req.user;
const ability = this.caslAbilityFactory.createForUser(user);
+36
View File
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WPPConfig } from '@waha/core/engines/wpp/WppConfig';
@Injectable()
export class WPPEngineConfigService {
constructor(protected configService: ConfigService) {}
getConfig(): WPPConfig {
let webVersion = this.configService.get<string>(
'WAHA_WPP_WEB_VERSION',
undefined,
);
if (!webVersion) {
webVersion = this.configService.get<string>(
'WAHA_WEBJS_WEB_VERSION',
undefined,
);
}
return {
webVersion: webVersion,
puppeteerArgs: this.getPuppeterArgs(),
};
}
getPuppeterArgs(): string[] {
let args = this.configService.get<string>('WAHA_WPP_PUPPETER_ARGS', '');
if (!args) {
args = this.configService.get<string>('WAHA_WEBJS_PUPPETER_ARGS', '');
}
return args
.split(' ')
.map((arg) => arg.trim())
.filter(Boolean);
}
}
+145
View File
@@ -2996,6 +2996,73 @@ export namespace messages {
return AudioInfo.deserialize(bytes);
}
}
export class VideoInfo extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
duration?: number;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("duration" in data && data.duration != undefined) {
this.duration = data.duration;
}
}
}
get duration() {
return pb_1.Message.getFieldWithDefault(this, 1, 0) as number;
}
set duration(value: number) {
pb_1.Message.setField(this, 1, value);
}
static fromObject(data: {
duration?: number;
}): VideoInfo {
const message = new VideoInfo({});
if (data.duration != null) {
message.duration = data.duration;
}
return message;
}
toObject() {
const data: {
duration?: number;
} = {};
if (this.duration != null) {
data.duration = this.duration;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.duration != 0)
writer.writeFloat(1, this.duration);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): VideoInfo {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new VideoInfo();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.duration = reader.readFloat();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): VideoInfo {
return VideoInfo.deserialize(bytes);
}
}
export class Media extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -3005,6 +3072,7 @@ export namespace messages {
audio?: AudioInfo;
filename?: string;
contentPath?: string;
video?: VideoInfo;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -3027,6 +3095,9 @@ export namespace messages {
if ("contentPath" in data && data.contentPath != undefined) {
this.contentPath = data.contentPath;
}
if ("video" in data && data.video != undefined) {
this.video = data.video;
}
}
}
get content() {
@@ -3068,6 +3139,15 @@ export namespace messages {
set contentPath(value: string) {
pb_1.Message.setField(this, 6, value);
}
get video() {
return pb_1.Message.getWrapperField(this, VideoInfo, 7) as VideoInfo;
}
set video(value: VideoInfo) {
pb_1.Message.setWrapperField(this, 7, value);
}
get has_video() {
return pb_1.Message.getField(this, 7) != null;
}
static fromObject(data: {
content?: Uint8Array;
type?: MediaType;
@@ -3075,6 +3155,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
}): Media {
const message = new Media({});
if (data.content != null) {
@@ -3095,6 +3176,9 @@ export namespace messages {
if (data.contentPath != null) {
message.contentPath = data.contentPath;
}
if (data.video != null) {
message.video = VideoInfo.fromObject(data.video);
}
return message;
}
toObject() {
@@ -3105,6 +3189,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
} = {};
if (this.content != null) {
data.content = this.content;
@@ -3124,6 +3209,9 @@ export namespace messages {
if (this.contentPath != null) {
data.contentPath = this.contentPath;
}
if (this.video != null) {
data.video = this.video.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -3142,6 +3230,8 @@ export namespace messages {
writer.writeString(5, this.filename);
if (this.contentPath.length)
writer.writeString(6, this.contentPath);
if (this.has_video)
writer.writeMessage(7, this.video, () => this.video.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -3169,6 +3259,9 @@ export namespace messages {
case 6:
message.contentPath = reader.readString();
break;
case 7:
reader.readMessage(message.video, () => message.video = VideoInfo.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -9128,6 +9221,7 @@ export namespace messages {
filters?: MessageFilters;
pagination?: Pagination;
sortBy?: SortBy;
merge?: OptionalBool;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -9144,6 +9238,9 @@ export namespace messages {
if ("sortBy" in data && data.sortBy != undefined) {
this.sortBy = data.sortBy;
}
if ("merge" in data && data.merge != undefined) {
this.merge = data.merge;
}
}
}
get session() {
@@ -9182,11 +9279,21 @@ export namespace messages {
get has_sortBy() {
return pb_1.Message.getField(this, 4) != null;
}
get merge() {
return pb_1.Message.getWrapperField(this, OptionalBool, 5) as OptionalBool;
}
set merge(value: OptionalBool) {
pb_1.Message.setWrapperField(this, 5, value);
}
get has_merge() {
return pb_1.Message.getField(this, 5) != null;
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
filters?: ReturnType<typeof MessageFilters.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
}): GetMessagesRequest {
const message = new GetMessagesRequest({});
if (data.session != null) {
@@ -9201,6 +9308,9 @@ export namespace messages {
if (data.sortBy != null) {
message.sortBy = SortBy.fromObject(data.sortBy);
}
if (data.merge != null) {
message.merge = OptionalBool.fromObject(data.merge);
}
return message;
}
toObject() {
@@ -9209,6 +9319,7 @@ export namespace messages {
filters?: ReturnType<typeof MessageFilters.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
@@ -9222,6 +9333,9 @@ export namespace messages {
if (this.sortBy != null) {
data.sortBy = this.sortBy.toObject();
}
if (this.merge != null) {
data.merge = this.merge.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -9236,6 +9350,8 @@ export namespace messages {
writer.writeMessage(3, this.pagination, () => this.pagination.serialize(writer));
if (this.has_sortBy)
writer.writeMessage(4, this.sortBy, () => this.sortBy.serialize(writer));
if (this.has_merge)
writer.writeMessage(5, this.merge, () => this.merge.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -9257,6 +9373,9 @@ export namespace messages {
case 4:
reader.readMessage(message.sortBy, () => message.sortBy = SortBy.deserialize(reader));
break;
case 5:
reader.readMessage(message.merge, () => message.merge = OptionalBool.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -9604,6 +9723,7 @@ export namespace messages {
sortBy?: SortBy;
pagination?: Pagination;
filter?: ChatFilter;
merge?: OptionalBool;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -9620,6 +9740,9 @@ export namespace messages {
if ("filter" in data && data.filter != undefined) {
this.filter = data.filter;
}
if ("merge" in data && data.merge != undefined) {
this.merge = data.merge;
}
}
}
get session() {
@@ -9658,11 +9781,21 @@ export namespace messages {
get has_filter() {
return pb_1.Message.getField(this, 4) != null;
}
get merge() {
return pb_1.Message.getWrapperField(this, OptionalBool, 5) as OptionalBool;
}
set merge(value: OptionalBool) {
pb_1.Message.setWrapperField(this, 5, value);
}
get has_merge() {
return pb_1.Message.getField(this, 5) != null;
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
filter?: ReturnType<typeof ChatFilter.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
}): GetChatsRequest {
const message = new GetChatsRequest({});
if (data.session != null) {
@@ -9677,6 +9810,9 @@ export namespace messages {
if (data.filter != null) {
message.filter = ChatFilter.fromObject(data.filter);
}
if (data.merge != null) {
message.merge = OptionalBool.fromObject(data.merge);
}
return message;
}
toObject() {
@@ -9685,6 +9821,7 @@ export namespace messages {
sortBy?: ReturnType<typeof SortBy.prototype.toObject>;
pagination?: ReturnType<typeof Pagination.prototype.toObject>;
filter?: ReturnType<typeof ChatFilter.prototype.toObject>;
merge?: ReturnType<typeof OptionalBool.prototype.toObject>;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
@@ -9698,6 +9835,9 @@ export namespace messages {
if (this.filter != null) {
data.filter = this.filter.toObject();
}
if (this.merge != null) {
data.merge = this.merge.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -9712,6 +9852,8 @@ export namespace messages {
writer.writeMessage(3, this.pagination, () => this.pagination.serialize(writer));
if (this.has_filter)
writer.writeMessage(4, this.filter, () => this.filter.serialize(writer));
if (this.has_merge)
writer.writeMessage(5, this.merge, () => this.merge.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -9733,6 +9875,9 @@ export namespace messages {
case 4:
reader.readMessage(message.filter, () => message.filter = ChatFilter.deserialize(reader));
break;
case 5:
reader.readMessage(message.merge, () => message.merge = OptionalBool.deserialize(reader));
break;
default: reader.skipField();
}
}
+308 -3
View File
@@ -120,6 +120,7 @@ goog.exportSymbol('proto.messages.SubscribePresenceRequest', null, global);
goog.exportSymbol('proto.messages.UpdateContactRequest', null, global);
goog.exportSymbol('proto.messages.UpdateParticipantsRequest', null, global);
goog.exportSymbol('proto.messages.UpsertLabelRequest', null, global);
goog.exportSymbol('proto.messages.VideoInfo', null, global);
goog.exportSymbol('proto.messages.vCardContact', null, global);
/**
* Generated by JsPbCodeGenerator.
@@ -772,6 +773,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.AudioInfo.displayName = 'proto.messages.AudioInfo';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.VideoInfo = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.VideoInfo, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.VideoInfo.displayName = 'proto.messages.VideoInfo';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -7698,6 +7720,136 @@ proto.messages.AudioInfo.prototype.setWaveform = function(value) {
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.VideoInfo.prototype.toObject = function(opt_includeInstance) {
return proto.messages.VideoInfo.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.VideoInfo} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.VideoInfo.toObject = function(includeInstance, msg) {
var f, obj = {
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0)
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.VideoInfo}
*/
proto.messages.VideoInfo.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.VideoInfo;
return proto.messages.VideoInfo.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.VideoInfo} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.VideoInfo}
*/
proto.messages.VideoInfo.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = /** @type {number} */ (reader.readFloat());
msg.setDuration(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.VideoInfo.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.VideoInfo.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.VideoInfo} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.VideoInfo.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getDuration();
if (f !== 0.0) {
writer.writeFloat(
1,
f
);
}
};
/**
* optional float duration = 1;
* @return {number}
*/
proto.messages.VideoInfo.prototype.getDuration = function() {
return /** @type {number} */ (jspb.Message.getFloatingPointFieldWithDefault(this, 1, 0.0));
};
/**
* @param {number} value
* @return {!proto.messages.VideoInfo} returns this
*/
proto.messages.VideoInfo.prototype.setDuration = function(value) {
return jspb.Message.setProto3FloatField(this, 1, value);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
@@ -7732,7 +7884,8 @@ proto.messages.Media.toObject = function(includeInstance, msg) {
mimetype: jspb.Message.getFieldWithDefault(msg, 3, ""),
audio: (f = msg.getAudio()) && proto.messages.AudioInfo.toObject(includeInstance, f),
filename: jspb.Message.getFieldWithDefault(msg, 5, ""),
contentpath: jspb.Message.getFieldWithDefault(msg, 6, "")
contentpath: jspb.Message.getFieldWithDefault(msg, 6, ""),
video: (f = msg.getVideo()) && proto.messages.VideoInfo.toObject(includeInstance, f)
};
if (includeInstance) {
@@ -7794,6 +7947,11 @@ proto.messages.Media.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {string} */ (reader.readString());
msg.setContentpath(value);
break;
case 7:
var value = new proto.messages.VideoInfo;
reader.readMessage(value,proto.messages.VideoInfo.deserializeBinaryFromReader);
msg.setVideo(value);
break;
default:
reader.skipField();
break;
@@ -7866,6 +8024,14 @@ proto.messages.Media.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getVideo();
if (f != null) {
writer.writeMessage(
7,
f,
proto.messages.VideoInfo.serializeBinaryToWriter
);
}
};
@@ -8020,6 +8186,43 @@ proto.messages.Media.prototype.setContentpath = function(value) {
};
/**
* optional VideoInfo video = 7;
* @return {?proto.messages.VideoInfo}
*/
proto.messages.Media.prototype.getVideo = function() {
return /** @type{?proto.messages.VideoInfo} */ (
jspb.Message.getWrapperField(this, proto.messages.VideoInfo, 7));
};
/**
* @param {?proto.messages.VideoInfo|undefined} value
* @return {!proto.messages.Media} returns this
*/
proto.messages.Media.prototype.setVideo = function(value) {
return jspb.Message.setWrapperField(this, 7, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.Media} returns this
*/
proto.messages.Media.prototype.clearVideo = function() {
return this.setVideo(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.Media.prototype.hasVideo = function() {
return jspb.Message.getField(this, 7) != null;
};
@@ -18928,7 +19131,8 @@ proto.messages.GetMessagesRequest.toObject = function(includeInstance, msg) {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
filters: (f = msg.getFilters()) && proto.messages.MessageFilters.toObject(includeInstance, f),
pagination: (f = msg.getPagination()) && proto.messages.Pagination.toObject(includeInstance, f),
sortby: (f = msg.getSortby()) && proto.messages.SortBy.toObject(includeInstance, f)
sortby: (f = msg.getSortby()) && proto.messages.SortBy.toObject(includeInstance, f),
merge: (f = msg.getMerge()) && proto.messages.OptionalBool.toObject(includeInstance, f)
};
if (includeInstance) {
@@ -18985,6 +19189,11 @@ proto.messages.GetMessagesRequest.deserializeBinaryFromReader = function(msg, re
reader.readMessage(value,proto.messages.SortBy.deserializeBinaryFromReader);
msg.setSortby(value);
break;
case 5:
var value = new proto.messages.OptionalBool;
reader.readMessage(value,proto.messages.OptionalBool.deserializeBinaryFromReader);
msg.setMerge(value);
break;
default:
reader.skipField();
break;
@@ -19046,6 +19255,14 @@ proto.messages.GetMessagesRequest.serializeBinaryToWriter = function(message, wr
proto.messages.SortBy.serializeBinaryToWriter
);
}
f = message.getMerge();
if (f != null) {
writer.writeMessage(
5,
f,
proto.messages.OptionalBool.serializeBinaryToWriter
);
}
};
@@ -19197,6 +19414,43 @@ proto.messages.GetMessagesRequest.prototype.hasSortby = function() {
};
/**
* optional OptionalBool merge = 5;
* @return {?proto.messages.OptionalBool}
*/
proto.messages.GetMessagesRequest.prototype.getMerge = function() {
return /** @type{?proto.messages.OptionalBool} */ (
jspb.Message.getWrapperField(this, proto.messages.OptionalBool, 5));
};
/**
* @param {?proto.messages.OptionalBool|undefined} value
* @return {!proto.messages.GetMessagesRequest} returns this
*/
proto.messages.GetMessagesRequest.prototype.setMerge = function(value) {
return jspb.Message.setWrapperField(this, 5, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.GetMessagesRequest} returns this
*/
proto.messages.GetMessagesRequest.prototype.clearMerge = function() {
return this.setMerge(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.GetMessagesRequest.prototype.hasMerge = function() {
return jspb.Message.getField(this, 5) != null;
};
@@ -19882,7 +20136,8 @@ proto.messages.GetChatsRequest.toObject = function(includeInstance, msg) {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
sortby: (f = msg.getSortby()) && proto.messages.SortBy.toObject(includeInstance, f),
pagination: (f = msg.getPagination()) && proto.messages.Pagination.toObject(includeInstance, f),
filter: (f = msg.getFilter()) && proto.messages.ChatFilter.toObject(includeInstance, f)
filter: (f = msg.getFilter()) && proto.messages.ChatFilter.toObject(includeInstance, f),
merge: (f = msg.getMerge()) && proto.messages.OptionalBool.toObject(includeInstance, f)
};
if (includeInstance) {
@@ -19939,6 +20194,11 @@ proto.messages.GetChatsRequest.deserializeBinaryFromReader = function(msg, reade
reader.readMessage(value,proto.messages.ChatFilter.deserializeBinaryFromReader);
msg.setFilter(value);
break;
case 5:
var value = new proto.messages.OptionalBool;
reader.readMessage(value,proto.messages.OptionalBool.deserializeBinaryFromReader);
msg.setMerge(value);
break;
default:
reader.skipField();
break;
@@ -20000,6 +20260,14 @@ proto.messages.GetChatsRequest.serializeBinaryToWriter = function(message, write
proto.messages.ChatFilter.serializeBinaryToWriter
);
}
f = message.getMerge();
if (f != null) {
writer.writeMessage(
5,
f,
proto.messages.OptionalBool.serializeBinaryToWriter
);
}
};
@@ -20151,6 +20419,43 @@ proto.messages.GetChatsRequest.prototype.hasFilter = function() {
};
/**
* optional OptionalBool merge = 5;
* @return {?proto.messages.OptionalBool}
*/
proto.messages.GetChatsRequest.prototype.getMerge = function() {
return /** @type{?proto.messages.OptionalBool} */ (
jspb.Message.getWrapperField(this, proto.messages.OptionalBool, 5));
};
/**
* @param {?proto.messages.OptionalBool|undefined} value
* @return {!proto.messages.GetChatsRequest} returns this
*/
proto.messages.GetChatsRequest.prototype.setMerge = function(value) {
return jspb.Message.setWrapperField(this, 5, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.GetChatsRequest} returns this
*/
proto.messages.GetChatsRequest.prototype.clearMerge = function() {
return this.setMerge(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.GetChatsRequest.prototype.hasMerge = function() {
return jspb.Message.getField(this, 5) != null;
};
+66 -34
View File
@@ -45,6 +45,7 @@ import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import {
isJidBroadcast,
isJidGroup,
normalizeJid,
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
@@ -65,6 +66,8 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
MessageSortField,
OverviewFilter,
ReadChatMessagesQuery,
@@ -162,7 +165,7 @@ import { promisify } from 'util';
import * as gows from './types';
import { MessageStatus } from './types';
import { isFromFullSync } from '@waha/core/engines/gows/appstate';
import { toVcardV3 } from '@waha/core/vcard';
import { parseVCardV3, toVcardV3 } from '@waha/core/vcard';
import { AckToStatus } from '@waha/core/utils/acks';
import { ParseEventResponseType } from '@waha/core/utils/events';
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
@@ -311,7 +314,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
grpc.credentials.createInsecure(),
);
promisify(this.client.StartSession)(request).catch((err) => {
await promisify(this.client.StartSession)(request).catch((err) => {
this.logger.error('Failed to start the client');
this.logger.error(err, err.stack);
this.status = WAHASessionStatus.FAILED;
@@ -782,7 +785,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async fetchContactProfilePicture(id: string): Promise<string> {
const jid = toJID(this.ensureSuffix(id));
const jid = normalizeJid(toJID(this.ensureSuffix(id)));
const request = new messages.ProfilePictureRequest({
jid: jid,
session: this.session,
@@ -914,10 +917,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return data.id;
}
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const request = new messages.RejectCallRequest({
session: this.session,
from: toJID(this.ensureSuffix(from)),
from: normalizeJid(toJID(this.ensureSuffix(from))),
id: id,
});
await promisify(this.client.RejectCall)(request);
@@ -925,7 +929,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendText(request: MessageTextRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.MessageRequest({
jid: jid,
text: request.text,
@@ -933,7 +937,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
linkPreview: request.linkPreview ?? true,
linkPreviewHighQuality: request.linkPreviewHighQuality,
replyTo: getMessageIdFromSerialized(request.reply_to),
mentions: request.mentions?.map((mention) => toJID(mention)),
mentions: request.mentions?.map((mention) =>
normalizeJid(toJID(mention)),
),
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
@@ -946,7 +952,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
messageId: string,
request: EditMessageRequest,
) {
const jid = toJID(this.ensureSuffix(chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const key = parseMessageIdSerialized(messageId, true);
const message = new messages.EditMessageRequest({
session: this.session,
@@ -963,8 +969,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendContactVCard(request: MessageContactVcardRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
const contacts = request.contacts.map((el) => ({ vcard: toVcardV3(el) }));
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const contacts = request.contacts.map((el) => ({
displayName:
(el as any).fullName || parseVCardV3(el.vcard || '').fullName,
vcard: toVcardV3(el),
}));
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
@@ -978,7 +988,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendPoll(request: MessagePollRequest) {
const jid = toJID(request.chatId);
const jid = normalizeJid(toJID(request.chatId));
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
@@ -1004,7 +1014,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async deleteMessage(chatId: string, messageId: string) {
const jid = toJID(this.ensureSuffix(chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const key = parseMessageIdSerialized(messageId);
const message = new messages.RevokeMessageRequest({
session: this.session,
@@ -1021,7 +1031,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!contacts || contacts.length == 0) {
return [];
}
return contacts.map(toJID);
return contacts.map((c) => normalizeJid(toJID(c)));
}
@Activity()
@@ -1077,6 +1087,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
@Activity()
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -1096,7 +1107,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendLocation(request: MessageLocationRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
@@ -1180,7 +1191,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const req = new messages.CreateGroupRequest({
session: this.session,
name: request.name,
participants: request.participants.map((p) => toJID(p.id)),
participants: request.participants.map((p) => normalizeJid(toJID(p.id))),
});
const response = await promisify(this.client.CreateGroup)(req);
const data = parseJson(response);
@@ -1355,7 +1366,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
participants: Array<Participant>,
action: messages.ParticipantAction,
): Promise<any> {
const jids = participants.map((p) => toJID(p.id));
const jids = participants.map((p) => normalizeJid(toJID(p.id)));
const req = new messages.UpdateParticipantsRequest({
session: this.session,
jid: id,
@@ -1408,7 +1419,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendEvent(request: EventMessageRequest): Promise<WAMessage> {
const jid = toJID(this.ensureSuffix(request.chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const event = request.event;
// Create EventLocation if provided
@@ -1464,7 +1475,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async setPresence(presence: WAHAPresenceStatus, chatId?: string) {
let request: any;
let method: any;
const jid = chatId ? toJID(this.ensureSuffix(chatId)) : null;
const jid = chatId ? normalizeJid(toJID(this.ensureSuffix(chatId))) : null;
switch (presence) {
case WAHAPresenceStatus.ONLINE:
request = new messages.PresenceRequest({
@@ -1525,7 +1536,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getPresence(chatId: string): Promise<WAHAChatPresences> {
const jid = toJID(chatId);
const jid = normalizeJid(toJID(chatId));
await this.subscribePresence(jid);
if (!(jid in this.presences.keys())) {
await sleep(1000);
@@ -1536,7 +1547,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async subscribePresence(chatId: string) {
const jid = toJID(chatId);
const jid = normalizeJid(toJID(chatId));
const req = new messages.SubscribePresenceRequest({
session: this.session,
jid: jid,
@@ -1735,7 +1746,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
*/
@Activity()
public async upsertContact(chatId: string, body: ContactUpdateBody) {
const jid = toJID(chatId);
const jid = normalizeJid(toJID(chatId));
const request = new messages.UpdateContactRequest({
session: this.session,
jid: jid,
@@ -1755,7 +1766,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getContact(query: ContactQuery) {
const jid = toJID(query.contactId);
const jid = normalizeJid(toJID(query.contactId));
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
@@ -1828,7 +1839,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = toJID(phoneNumber);
const pn = normalizeJid(toJID(phoneNumber));
const request = new messages.EntityByIdRequest({
session: this.session,
id: pn,
@@ -1855,24 +1866,27 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
pagination.sortOrder = SortOrder.DESC;
}
const chats = await this.getChats(pagination, filter);
const merge = (pagination as GetChatsOverviewParams).merge ?? true;
const promises = [];
for (const chat of chats) {
promises.push(this.fetchChatSummary(chat));
promises.push(this.fetchChatSummary(chat, merge));
}
const result = await Promise.all(promises);
return result;
}
protected async fetchChatSummary(chat): Promise<ChatSummary> {
protected async fetchChatSummary(chat, merge: boolean): Promise<ChatSummary> {
const id = toCusFormat(chat.id);
const name = chat.name;
const picture = await this.getContactProfilePicture(chat.id, false);
const messages = await this.getChatMessages(
chat.id,
{ limit: 1, offset: 0, downloadMedia: false },
{},
);
const lastMessageQuery: GetChatMessagesQuery = {
limit: 1,
offset: 0,
downloadMedia: false,
merge,
};
const messages = await this.getChatMessages(chat.id, lastMessageQuery, {});
const message = messages.length > 0 ? messages[0] : null;
return {
id: id,
@@ -1897,12 +1911,14 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
pagination: PaginationParams,
filter: OverviewFilter | null = null,
) {
const merge =
(pagination as GetChatsParams | GetChatsOverviewParams).merge ?? true;
if (pagination.sortBy === ChatSortField.CONVERSATION_TIMESTAMP) {
pagination.sortBy = 'timestamp';
}
let jids = [];
if (filter?.ids && filter.ids.length > 0) {
jids = filter.ids.map((id) => toJID(id));
jids = filter.ids.map((id) => normalizeJid(toJID(id)));
}
const request = new messages.GetChatsRequest({
session: this.session,
@@ -1920,6 +1936,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
filter: new messages.ChatFilter({
jids: jids,
}),
merge: optional(merge, messages.OptionalBool),
});
const response = await promisify(this.client.GetChats)(request);
const data = parseJsonList(response);
@@ -1932,12 +1949,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
filter: GetChatMessagesFilter,
) {
const downloadMedia = query.downloadMedia;
const merge = query.merge ?? true;
let jid: messages.OptionalString;
if (chatId === 'all') {
jid = null;
} else {
jid = new messages.OptionalString({
value: toJID(this.ensureSuffix(chatId)),
value: normalizeJid(toJID(this.ensureSuffix(chatId))),
});
}
@@ -1969,6 +1987,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
limit: query.limit,
offset: query.offset,
}),
merge: optional(merge, messages.OptionalBool),
});
const response = await promisify(this.client.GetMessages)(request);
const msgs = parseJsonList(response);
@@ -2085,7 +2104,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getChatLabels(chatId: string): Promise<Label[]> {
const jid = toJID(chatId);
const jid = normalizeJid(toJID(chatId));
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
@@ -2097,7 +2116,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async chatsUnreadChat(chatId: string): Promise<any> {
const jid = toJID(this.ensureSuffix(chatId));
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const request = new messages.ChatUnreadRequest({
session: this.session,
jid: jid,
@@ -2109,7 +2128,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async putLabelsToChat(chatId: string, labels: LabelID[]) {
const jid = toJID(chatId);
const jid = normalizeJid(toJID(chatId));
const labelsIds = labels.map((label) => label.id);
const currentLabels = await this.getChatLabels(jid);
const currentLabelsIds = currentLabels.map((label) => label.id);
@@ -2177,6 +2196,16 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const media = await this.downloadMediaSafe(message);
wamessage.media = media;
}
if (downloadMedia && wamessage.replyTo?.hasMedia) {
const msg = {
Message: wamessage.replyTo._data,
Info: {
Chat: message.Info.Chat,
ID: wamessage.replyTo.id || '',
},
};
wamessage.replyTo.media = await this.downloadMediaSafe(msg);
}
return wamessage;
}
@@ -2386,10 +2415,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return null;
}
const body = extractBody(quotedMessage);
const mediaContent = extractMediaContent(quotedMessage);
return {
id: contextInfo.stanzaID,
participant: toCusFormat(contextInfo.participant),
body: body,
hasMedia: Boolean(mediaContent),
media: null,
_data: quotedMessage,
};
}
+17 -3
View File
@@ -64,10 +64,19 @@ export function ToGroupParticipant(
interface GroupParticipantUpdate {
id: string;
author: string;
participants: string[];
participants: Array<string | NOWEBGroupParticipant>;
action: ParticipantAction;
}
function getParticipantId(
participant: string | NOWEBGroupParticipant,
): string | undefined {
if (typeof participant === 'string') {
return participant;
}
return participant?.id;
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
@@ -92,7 +101,8 @@ export function ToGroupV2Participants(
break;
}
const participants: GroupParticipant[] = update.participants.map((id) => {
const participants: GroupParticipant[] = update.participants.map((item) => {
const id = getParticipantId(item);
return {
id: toCusFormat(id),
role: role,
@@ -131,7 +141,11 @@ export function ToGroupV2LeaveEvent(
return null;
}
const meId = esm.b.jidNormalizedUser(me.id);
if (!update.participants.includes(meId)) {
const includesMe = update.participants.some((participant) => {
const id = getParticipantId(participant);
return id === meId;
});
if (!includesMe) {
return null;
}
+120 -25
View File
@@ -1,5 +1,5 @@
import { Browsers, WABrowserDescription } from '@adiwajshing/baileys';
import makeWASocket, {
Browsers,
Chat,
Contact,
decryptPollVote,
@@ -19,6 +19,7 @@ import makeWASocket, {
PresenceData,
proto,
SocketConfig,
WABrowserDescription,
WAMessageContent,
WAMessageKey,
WAMessageUpdate,
@@ -91,6 +92,8 @@ import {
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
OverviewFilter,
PinDuration,
ReadChatMessagesQuery,
@@ -181,13 +184,13 @@ import * as NodeCache from 'node-cache';
import {
filter,
fromEvent,
groupBy,
identity,
merge,
mergeAll,
mergeMap,
Observable,
partition,
groupBy,
share,
tap,
} from 'rxjs';
@@ -906,6 +909,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Other methods
*/
@Activity()
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -925,6 +929,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return this.generateMessageID();
}
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const jid = toJID(this.ensureSuffix(from));
await this.sock.rejectCall(id, jid);
@@ -954,24 +959,63 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
@Activity()
public editMessage(
public async editMessage(
chatId: string,
messageId: string,
request: EditMessageRequest,
) {
const jid = toJID(this.ensureSuffix(chatId));
const key = parseMessageIdSerialized(messageId);
const message = {
const stored = await this.store
?.loadMessage(key.remoteJid, key.id)
.catch(() => null);
const content = extractMessageContent(stored?.message);
let editedMessage = undefined;
if (content?.imageMessage) {
editedMessage = {
imageMessage: {
caption: request.text,
},
};
} else if (content?.videoMessage) {
editedMessage = {
videoMessage: {
caption: request.text,
},
};
} else if (content?.documentMessage) {
editedMessage = {
documentMessage: {
caption: request.text,
},
};
} else if (content?.documentWithCaptionMessage?.message?.documentMessage) {
editedMessage = {
documentWithCaptionMessage: {
message: {
documentMessage: {
caption: request.text,
},
},
},
};
}
let message: any = {
text: request.text,
mentions: request.mentions?.map(toJID),
edit: key,
editedMessage: editedMessage,
linkPreview: this.getLinkPreview(request),
linkPreviewHighQuality: request.linkPreviewHighQuality,
};
const options = {
messageId: this.generateMessageID(),
};
return this.sock.sendMessage(jid, message, options);
if (isJidNewsletter(jid)) {
// Newsletter edits reuse the original message ID
options.messageId = key.id;
}
return await this.sock.sendMessage(jid, message, options);
}
@Activity()
@@ -1136,10 +1180,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
) {
const downloadMedia = query.downloadMedia;
const pagination = query as PaginationParams;
const merge = query.merge ?? true;
const messages = await this.store.getMessagesByJid(
toJID(chatId),
filter,
pagination,
merge,
);
const promises = [];
@@ -1165,7 +1211,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
query: GetChatMessageQuery,
): Promise<null | WAMessage> {
const key = parseMessageIdSerialized(messageId, true);
const message = await this.store.getMessageById(toJID(chatId), key.id);
const merge = query.merge ?? true;
const message = await this.store.getMessageById(
toJID(chatId),
key.id,
merge,
);
if (!message) return null;
return await this.processIncomingMessage(message, query.downloadMedia);
}
@@ -1252,7 +1303,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
*/
async getChats(pagination: PaginationParams) {
const chats = await this.store.getChats(pagination, true);
const merge = (pagination as GetChatsParams).merge ?? true;
const chats = await this.store.getChats(pagination, true, undefined, merge);
// Remove unreadCount, it's not ready yet
chats.forEach((chat) => delete chat.unreadCount);
return chats;
@@ -1262,6 +1314,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
pagination: PaginationParams,
filter?: OverviewFilter,
): Promise<ChatSummary[]> {
const merge = (pagination as GetChatsOverviewParams).merge ?? true;
// Convert customer format IDs to JID format if filter is provided
let jidFilter;
if (filter?.ids && filter.ids.length > 0) {
@@ -1270,19 +1323,27 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
};
}
const chats = await this.store.getChats(pagination, false, jidFilter);
const chats = await this.store.getChats(
pagination,
false,
jidFilter,
merge,
);
// Remove unreadCount, it's not ready yet
chats.forEach((chat) => delete chat.unreadCount);
const promises = [];
for (const chat of chats) {
promises.push(this.fetchChatSummary(chat));
promises.push(this.fetchChatSummary(chat, merge));
}
const result = await Promise.all(promises);
return result;
}
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
protected async fetchChatSummary(
chat: Chat,
merge: boolean,
): Promise<ChatSummary> {
const id = toCusFormat(chat.id);
let name = chat.name;
if (!name) {
@@ -1292,11 +1353,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
name = contact?.name || contact?.notify;
}
const picture = await this.getContactProfilePicture(chat.id, false);
const messages = await this.getChatMessages(
chat.id,
{ limit: 1, offset: 0, downloadMedia: false },
{},
);
const lastMessageQuery: GetChatMessagesQuery = {
limit: 1,
offset: 0,
downloadMedia: false,
merge: merge,
};
const messages = await this.getChatMessages(chat.id, lastMessageQuery, {});
const message = messages.length > 0 ? messages[0] : null;
return {
id: id,
@@ -2358,13 +2421,16 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
shouldProcessIncomingMessage(message): boolean {
// if there is no text or media message
if (!message) return;
if (!message.message) return;
// View-once (self-destructing) messages arrive with key.isViewOnce=true but
// no message content (burned by sender). Allow them through so a webhook
// is still fired with key/timestamp metadata.
if (!message.message && !message.key?.isViewOnce) return;
// Ignore reactions, we have dedicated handler for that
if (message.message.reactionMessage) return;
if (message.message?.reactionMessage) return;
// Ignore poll votes, we have dedicated handler for that
if (message.message.pollUpdateMessage) return;
if (message.message?.pollUpdateMessage) return;
// Ignore calls, we have dedicated handler for that
if (message.message.call?.callKey) return;
if (message.message?.call?.callKey) return;
// Ignore revoke, we have a dedicated event for that
if (
message.message?.protocolMessage?.type ===
@@ -2417,9 +2483,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return null;
}
// Media
if (downloadMedia) {
const media = await this.downloadMediaSafe(message);
wamessage.media = media;
if (downloadMedia && wamessage.hasMedia) {
wamessage.media = await this.downloadMediaSafe(message);
}
if (downloadMedia && wamessage.replyTo?.hasMedia) {
const mediaContent = extractMediaContent(wamessage.replyTo._data);
const m = {
message: wamessage.replyTo._data,
key: {
id:
wamessage.replyTo.id ||
mediaContent.fileSha256 ||
mediaContent.fileEncSha256 ||
mediaContent.mediaKeyTimestamp,
remoteJid: message.key.remoteJid,
},
};
wamessage.replyTo.media = await this.downloadMediaSafe(m);
}
return wamessage;
}
@@ -2468,6 +2549,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
protected extractReplyTo(message): ReplyToMessage | null {
if (!message) return null;
const msgType = getContentType(message);
const contextInfo = message[msgType]?.contextInfo;
if (!contextInfo) {
@@ -2478,10 +2560,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return null;
}
const body = extractBody(quotedMessage);
const mediaContent = extractMediaContent(quotedMessage);
return {
id: contextInfo.stanzaId,
participant: toCusFormat(contextInfo.participant),
body: body,
// Media
hasMedia: Boolean(mediaContent),
media: null,
// Data
_data: quotedMessage,
};
}
@@ -2782,9 +2869,12 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
content.url = null;
}
return (await downloadMediaMessage(
// Use 'stream' mode instead of 'buffer' to fix 0-byte audio files
// 'buffer' mode silently returns empty buffer for audio/voice messages
// See: https://github.com/devlikeapro/waha/issues/1996
const stream = await downloadMediaMessage(
message,
'buffer',
'stream',
{},
{
logger: this.logger,
@@ -2793,7 +2883,12 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
).finally(() => {
// Set url back in case we removed it
content.url = url;
})) as Buffer;
});
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
getFilename(message: any): string | null {
@@ -11,6 +11,7 @@ export interface IChatRepository {
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]>;
getById(id: string): Promise<Chat | null>;
@@ -14,9 +14,10 @@ export interface IMessagesRepository {
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]>;
getByJidById(jid: string, id: string): Promise<any | null>;
getByJidById(jid: string, id: string, merge?: boolean): Promise<any | null>;
updateByJidAndId(jid: string, id: string, update: any): Promise<boolean>;
+7 -1
View File
@@ -31,14 +31,20 @@ export interface INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any>;
getMessageById(chatId: string, messageId: string): Promise<any>;
getMessageById(
chatId: string,
messageId: string,
merge?: boolean,
): Promise<any>;
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]>;
getChat(jid: string): Promise<Chat | null>;
@@ -2,7 +2,10 @@ import type { Chat, Contact, GroupMetadata, proto } from '@adiwajshing/baileys';
import type makeWASocket from '@adiwajshing/baileys';
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { BadRequestException } from '@nestjs/common';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import {
GetChatMessagesFilter,
OverviewFilter,
} from '@waha/structures/chats.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import {
LimitOffsetParams,
@@ -57,15 +60,25 @@ export class NowebInMemoryStore implements INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any> {
throw new BadRequestException(this.errorMessage);
}
getMessageById(chatId: string, messageId: string): Promise<any> {
getMessageById(
chatId: string,
messageId: string,
merge?: boolean,
): Promise<any> {
throw new BadRequestException(this.errorMessage);
}
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]> {
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
throw new BadRequestException(this.errorMessage);
}
@@ -44,6 +44,7 @@ import { INowebStorage } from './INowebStorage';
import { INowebStore } from './INowebStore';
import { LabelAssociationType } from '../labels/LabelAssociationType';
import esm from '@waha/vendor/esm';
import { StatusStringToStatus } from '@waha/core/utils/acks';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -130,7 +131,7 @@ export class NowebPersistentStore implements INowebStore {
return {
id: message.key.remoteJid,
lid: lid,
jid: pn,
phoneNumber: pn,
};
})
.filter(Boolean);
@@ -249,6 +250,9 @@ export class NowebPersistentStore implements INowebStore {
private async syncMessagesHistory(messages) {
const realMessages = messages.filter(esm.b.isRealMessage);
messages = messages.filter((msg) => this.jids.include(msg.key.remoteJid));
for (const message of messages) {
message.status = StatusStringToStatus(message.status);
}
await this.messagesRepo.upsert(realMessages);
this.logger.info(
`history sync - '${messages.length}' got messages, '${realMessages.length}' real messages`,
@@ -288,7 +292,11 @@ export class NowebPersistentStore implements INowebStore {
);
continue;
}
const message = await this.messagesRepo.getByJidById(jid, update.key.id);
let message = await this.messagesRepo.getByJidById(jid, update.key.id);
if (!message) {
// Fallback to just id
message = await this.messagesRepo.getById(update.key.id);
}
if (!message) {
this.logger.warn(
`got update for non-existent message. update: '${JSON.stringify(
@@ -588,6 +596,9 @@ export class NowebPersistentStore implements INowebStore {
data = await this.messagesRepo.getById(id);
} else {
data = await this.messagesRepo.getByJidById(jid, id);
if (!data) {
data = await this.messagesRepo.getById(id);
}
}
if (!data) {
return null;
@@ -599,24 +610,35 @@ export class NowebPersistentStore implements INowebStore {
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge: boolean = true,
): Promise<any> {
pagination.sortBy = 'messageTimestamp';
pagination.sortOrder = pagination.sortOrder || SortOrder.DESC;
return this.messagesRepo.getAllByJid(chatId, filter, pagination);
return this.messagesRepo.getAllByJid(chatId, filter, pagination, merge);
}
getMessageById(chatId: string, messageId: string): Promise<any> {
return this.messagesRepo.getByJidById(chatId, messageId);
getMessageById(
chatId: string,
messageId: string,
merge: boolean = true,
): Promise<any> {
return this.messagesRepo.getByJidById(chatId, messageId, merge);
}
getChats(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge: boolean = true,
): Promise<Chat[]> {
pagination.sortBy ||= 'conversationTimestamp';
pagination.sortOrder ||= SortOrder.DESC;
return this.chatRepo.getAllWithMessages(pagination, broadcast, filter);
return this.chatRepo.getAllWithMessages(
pagination,
broadcast,
filter,
merge,
);
}
async getChat(jid: string): Promise<Chat | null> {
@@ -2,6 +2,7 @@ import type { Chat } from '@adiwajshing/baileys';
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { Knex } from 'knex';
export class SqlChatMethods {
constructor(private repository: SqlKVRepository<any>) {}
@@ -10,23 +11,112 @@ export class SqlChatMethods {
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge: boolean = true,
): Promise<Chat[]> {
// Get chats with conversationTimestamp is not Null
let query = this.repository.select().whereNotNull('conversationTimestamp');
const knex = this.repository.getKnex();
const tableName = this.repository.table;
let baseQuery = this.repository
.select()
.whereNotNull(`${tableName}.conversationTimestamp`);
if (!broadcast) {
// filter out chat by id if it ends at @newsletter or @broadcast
query = query
.andWhereNot('id', 'like', '%@broadcast')
.andWhereNot('id', 'like', '%@newsletter');
if (!merge) {
baseQuery = this.applyNonMergedFilters(baseQuery, tableName, {
broadcast,
filter,
});
const pagedQuery = this.repository.pagination(baseQuery, pagination);
const rows = await pagedQuery;
return rows.map((row) => this.repository.parse(row));
}
// Filter by IDs if provided
if (filter?.ids && filter.ids.length > 0) {
query = query.whereIn('id', filter.ids);
const annotatedQuery = this.annotateWithPnJid(knex, baseQuery, {
tableName,
broadcast,
filter,
}).as('annotated_chats');
const dedupedQuery = knex
.select('*')
.from(
knex
.select(
'annotated_chats.*',
knex.raw(
'ROW_NUMBER() OVER (PARTITION BY annotated_chats.primary_jid ORDER BY annotated_chats."conversationTimestamp" DESC, annotated_chats.primary_priority ASC) as __rownum',
),
)
.from(annotatedQuery)
.as('ranked_chats'),
)
.where('__rownum', 1);
const pagedQuery = this.repository.pagination(dedupedQuery, pagination);
const rows = await pagedQuery;
return rows.map((row) => {
const chat = this.repository.parse(row);
if (merge && row.primary_jid) {
chat.id = row.primary_jid;
}
return chat;
});
}
private annotateWithPnJid(
knex: Knex,
query: Knex.QueryBuilder,
opts: {
tableName: string;
broadcast: boolean;
filter?: OverviewFilter;
},
) {
const pnExpr = this.buildPnJidExpr(opts.tableName);
let annotated = query
.leftJoin('lid_map', 'lid_map.id', `${opts.tableName}.id`)
.select(
knex.raw(`${pnExpr} as primary_jid`),
knex.raw(
`CASE WHEN ${opts.tableName}.id LIKE '%@lid' THEN 1 ELSE 0 END as primary_priority`,
),
);
if (!opts.broadcast) {
annotated = annotated
.andWhereNot(`${opts.tableName}.id`, 'like', '%@broadcast')
.andWhereNot(`${opts.tableName}.id`, 'like', '%@newsletter');
}
query = this.repository.pagination(query, pagination);
return await this.repository.all(query);
if (opts.filter?.ids && opts.filter.ids.length > 0) {
annotated = annotated.andWhere((builder) => {
builder
.whereIn(`${opts.tableName}.id`, opts.filter.ids)
.orWhereIn('lid_map.pn', opts.filter.ids);
});
}
return annotated;
}
private buildPnJidExpr(tableName: string) {
const column = `"${tableName}"."id"`;
return `CASE WHEN ${column} LIKE '%@lid' THEN COALESCE(lid_map.pn, ${column}) ELSE ${column} END`;
}
private applyNonMergedFilters(
query: Knex.QueryBuilder,
tableName: string,
opts: { broadcast: boolean; filter?: OverviewFilter },
) {
let filtered = query;
if (!opts.broadcast) {
filtered = filtered
.andWhereNot(`${tableName}.id`, 'like', '%@broadcast')
.andWhereNot(`${tableName}.id`, 'like', '%@newsletter');
}
if (opts.filter?.ids && opts.filter.ids.length > 0) {
filtered = filtered.whereIn(`${tableName}.id`, opts.filter.ids);
}
return filtered;
}
}
@@ -1,11 +1,17 @@
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { AckToStatus } from '@waha/core/utils/acks';
import { isLidUser } from '@waha/core/utils/jids';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { Knex } from 'knex';
import { INowebLidPNRepository } from '../INowebLidPNRepository';
export class SqlMessagesMethods {
constructor(private repository: SqlKVRepository<any>) {}
constructor(
private repository: SqlKVRepository<any>,
private lidRepository?: INowebLidPNRepository,
) {}
upsert(messages: any[]): Promise<void> {
return this.repository.upsertMany(messages);
@@ -15,10 +21,17 @@ export class SqlMessagesMethods {
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge: boolean = true,
): Promise<any[]> {
let query = this.repository.select();
const tableName = this.repository.table;
if (jid !== ALL_JID) {
query = this.repository.select().where({ jid: jid });
if (merge) {
const pnJid = await this.resolvePnJid(jid);
query = this.applyPnJidFilter(query, pnJid);
} else {
query = query.where(`${tableName}.jid`, jid);
}
}
if (filter['filter.timestamp.lte'] != null) {
query = query.where(
@@ -34,10 +47,11 @@ export class SqlMessagesMethods {
filter['filter.timestamp.gte'],
);
}
const dataColumn = `${this.repository.table}.data`;
if (filter['filter.fromMe'] != null) {
// filter by data json inside
const [sql, value] = this.repository.filterJson(
'data',
dataColumn,
'key.fromMe',
filter['filter.fromMe'],
);
@@ -45,18 +59,39 @@ export class SqlMessagesMethods {
}
if (filter['filter.ack'] != null) {
const status = AckToStatus(filter['filter.ack']);
const [sql, value] = this.repository.filterJson('data', 'status', status);
const [sql, value] = this.repository.filterJson(
dataColumn,
'status',
status,
);
query = query.whereRaw(sql, [value]);
}
query = this.repository.pagination(query, pagination);
return this.repository.all(query);
}
async getByJidById(jid: string, id: string): Promise<any> {
async getByJidById(
jid: string,
id: string,
merge: boolean = true,
): Promise<any> {
if (jid === ALL_JID) {
return this.repository.getBy({ id: id });
}
return this.repository.getBy({ jid: jid, id: id });
const tableName = this.repository.table;
const baseQuery = this.repository.select().where(`${tableName}.id`, id);
let query;
if (merge) {
const pnJid = await this.resolvePnJid(jid);
query = this.applyPnJidFilter(baseQuery, pnJid);
} else {
query = baseQuery.andWhere(`${tableName}.jid`, jid);
}
const rows = await query.limit(1);
if (!rows.length) {
return null;
}
return this.repository.parse(rows[0]);
}
async updateByJidAndId(
@@ -83,4 +118,41 @@ export class SqlMessagesMethods {
deleteAllByJid(jid: string): Promise<void> {
return this.repository.deleteBy({ jid: jid });
}
private applyPnJidFilter(
query: Knex.QueryBuilder,
pnJid: string,
): Knex.QueryBuilder {
const tableName = this.repository.table;
const knex = this.repository.getKnex();
// Use OR conditions on the raw jid column so the (jid, messageTimestamp) index
// can be used for both branches instead of a per-row CASE expression.
return query.where((builder) => {
builder
.where(`${tableName}.jid`, pnJid)
.orWhereIn(
`${tableName}.jid`,
knex.select('id').from('lid_map').where('pn', pnJid),
);
});
}
private async resolvePnJid(jid: string): Promise<string> {
if (!isLidUser(jid)) {
return jid;
}
if (this.lidRepository) {
const mapped = await this.lidRepository.findPNByLid(jid);
if (mapped) {
return mapped;
}
}
const row = await this.repository
.getKnex()
.select('pn')
.from('lid_map')
.where('id', jid)
.first();
return row?.pn || jid;
}
}
@@ -16,7 +16,7 @@ export class NOWEBSqlite3KVRepository<
return JSON.stringify(data, esm.b.BufferJSON.replacer);
}
protected parse(row: any): any {
public parse(row: any): any {
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
}
@@ -30,7 +30,13 @@ export class Sqlite3ChatRepository
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
return this.methods.getAllWithMessages(pagination, broadcast, filter);
return this.methods.getAllWithMessages(
pagination,
broadcast,
filter,
merge,
);
}
}
@@ -6,17 +6,26 @@ import { PaginationParams } from '@waha/structures/pagination.dto';
import { IMessagesRepository } from '../IMessagesRepository';
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
import { INowebLidPNRepository } from '../INowebLidPNRepository';
import Knex from 'knex';
export class Sqlite3MessagesRepository
extends NOWEBSqlite3KVRepository<any>
implements IMessagesRepository
{
constructor(
knex: Knex.Knex,
private readonly lidRepository: INowebLidPNRepository,
) {
super(knex);
}
get schema() {
return NowebMessagesSchema;
}
get methods() {
return new SqlMessagesMethods(this);
return new SqlMessagesMethods(this, this.lidRepository);
}
get metadata() {
@@ -31,12 +40,13 @@ export class Sqlite3MessagesRepository
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]> {
return this.methods.getAllByJid(jid, filter, pagination);
return this.methods.getAllByJid(jid, filter, pagination, merge);
}
async getByJidById(jid: string, id: string): Promise<any> {
return this.methods.getByJidById(jid, id);
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
return this.methods.getByJidById(jid, id, merge);
}
async updateByJidAndId(
@@ -19,6 +19,7 @@ import { KNEX_SQLITE_CLIENT } from '@waha/core/env';
export class Sqlite3Storage extends INowebStorage {
private readonly tables: Schema[];
private readonly knex: Knex.Knex;
private lidRepository: INowebLidPNRepository | null = null;
constructor(filePath: string) {
super();
@@ -84,10 +85,17 @@ export class Sqlite3Storage extends INowebStorage {
}
getMessagesRepository() {
return new Sqlite3MessagesRepository(this.knex);
return new Sqlite3MessagesRepository(this.knex, this.getLidRepository());
}
getLidPNRepository(): INowebLidPNRepository {
return new Sqlite3LidPNRepository(this.knex);
return this.getLidRepository();
}
private getLidRepository(): INowebLidPNRepository {
if (!this.lidRepository) {
this.lidRepository = new Sqlite3LidPNRepository(this.knex);
}
return this.lidRepository;
}
}
+6 -1
View File
@@ -11,7 +11,12 @@ export function extractMediaContent(
content?.videoMessage ||
content?.audioMessage ||
content?.ptvMessage ||
content?.stickerMessage;
content?.stickerMessage ||
content?.templateMessage?.hydratedTemplate?.imageMessage ||
content?.templateMessage?.hydratedTemplate?.videoMessage ||
content?.templateMessage?.interactiveMessageTemplate?.header
?.imageMessage ||
content?.templateMessage?.interactiveMessageTemplate?.header?.videoMessage;
if (mediaContent) {
return mediaContent;
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Expose a function to the page if it does not exist
*
* NOTE:
* Rewrite it to 'upsertFunction' after updating Puppeteer to 20.6 or higher
* using page.removeExposedFunction
* https://pptr.dev/api/puppeteer.page.removeExposedFunction
*
* @param {import(puppeteer).Page} page
* @param {string} name
* @param {Function} fn
*/
export async function exposeFunctionIfAbsent(page, name, fn) {
const exist = await page.evaluate((name) => {
return !!window[name];
}, name);
if (exist) {
return;
}
await page.exposeFunction(name, fn);
}
+124 -13
View File
@@ -1,4 +1,3 @@
import { exposeFunctionIfAbsent } from '@waha/core/engines/webjs/Puppeteer';
import { WebJSPresence } from '@waha/core/engines/webjs/types';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { Label } from '@waha/structures/labels.dto';
@@ -26,6 +25,11 @@ const { LoadPaginator } = require('./_Paginator.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {
exposeFunctionIfAbsent,
} = require('whatsapp-web.js/src/util/Puppeteer');
export class WebjsClientCore extends Client {
public events = new EventEmitter();
private wpage: WPage = null;
@@ -300,25 +304,132 @@ export class WebjsClientCore extends Client {
// @ts-ignore
const chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
let msgs = chat.msgs.getModelsArray().filter(msgFilter);
if (!chat) return [];
while (msgs.length < pagination.limit + pagination.offset) {
const loadedMessages =
let msgs = [];
// Try new WhatsApp API (>= 2.3000.1034162388) which replaced loadEarlierMsgs
// @ts-ignore
const WAWebDBMessageFindLocal = window.require(
'WAWebDBMessageFindLocal',
);
if (WAWebDBMessageFindLocal?.msgFindByDirection) {
const BATCH_SIZE = 20;
// Construct the initial anchor the same way wa-js does:
// serialize to string then reconstruct via MsgKey.fromString so the
// object has the exact shape msgFindByDirection expects.
const lastReceivedSerialized = chat.lastReceivedKey?._serialized;
if (!lastReceivedSerialized) return [];
// @ts-ignore
let currentAnchorKey = window.Store.MsgKey.fromString(
lastReceivedSerialized,
);
// msgFindByDirection is exclusive of the anchor; include the anchor
// message itself (the most recent message in the chat) upfront
// @ts-ignore
const anchorMsg = window.Store.Msg.get(lastReceivedSerialized);
if (anchorMsg) {
msgs.push(anchorMsg);
}
const neededFiltered = Number.isFinite(
pagination.limit + pagination.offset,
)
? pagination.limit + pagination.offset
: Infinity;
// @ts-ignore
const toModel = (m) => {
if (m && typeof m.serialize === 'function') return m;
const serializedId = m?.id?._serialized;
if (serializedId) {
// @ts-ignore
const stored = window.Store.Msg.get(serializedId);
if (stored) return stored;
}
// @ts-ignore
await window.Store.ConversationMsgs.loadEarlierMsgs(chat);
if (!loadedMessages || loadedMessages.length == 0) break;
return new window.Store.Msg.modelClass(m);
};
msgs = [...loadedMessages.filter(msgFilter), ...msgs];
msgs = msgs.sort((a, b) => b.t - a.t);
while (true) {
const result = await WAWebDBMessageFindLocal.msgFindByDirection({
anchor: currentAnchorKey,
count: BATCH_SIZE,
direction: 'before',
});
// Check if the earliest message is already outside the timerange filter
const earliest = msgs[msgs.length - 1];
if (earliest.t < (filter['filter.timestamp.gte'] || Infinity)) {
// Add only messages that pass the filter and stop loading more
break;
const batch = Array.isArray(result)
? result
: result?.messages || [];
if (!batch || batch.length === 0) break;
const batchModels = batch.map(toModel);
// msgFindByDirection returns newest-first (descending) so prepend
// to keep the accumulated list in approximate ascending order
msgs = [...batchModels, ...msgs];
// Deduplicate by serialized id — the same Backbone model object can
// appear in multiple batches when anchors overlap
const seenIds = new Set();
msgs = msgs.filter((m) => {
const sid = m?.id?._serialized;
if (!sid || seenIds.has(sid)) return false;
seenIds.add(sid);
return true;
});
// Stop once we have enough messages that pass the filter
if (msgs.filter(msgFilter).length >= neededFiltered) break;
// Stop if the oldest message in this batch is already before the
// lower timestamp bound - no earlier messages can be in range
if (filter['filter.timestamp.gte'] != null) {
const batchMinT = batchModels.reduce(
(min, m) => Math.min(min, m.t ?? Infinity),
Infinity,
);
if (batchMinT < filter['filter.timestamp.gte']) break;
}
// No more messages available
if (batch.length < BATCH_SIZE) break;
// msgFindByDirection returns newest-first, so the LAST element is
// the oldest message in this batch — use it as the next anchor to
// walk further back in history without overlap
const oldestInBatch = batchModels[batchModels.length - 1];
const oldestSerialized = oldestInBatch?.id?._serialized;
if (!oldestSerialized) break;
// @ts-ignore
currentAnchorKey = window.Store.MsgKey.fromString(oldestSerialized);
}
} else {
// Legacy fallback: loadEarlierMsgs loop
msgs = chat.msgs.getModelsArray();
while (msgs.length < pagination.limit + pagination.offset) {
const loadedMessages =
// @ts-ignore
await window.Store.ConversationMsgs.loadEarlierMsgs(
chat,
chat.msgs,
);
if (!loadedMessages || loadedMessages.length == 0) break;
msgs = [...loadedMessages, ...msgs];
msgs = msgs.sort((a, b) => b.t - a.t);
const earliest = msgs[msgs.length - 1];
if (earliest.t < (filter['filter.timestamp.gte'] || Infinity)) {
break;
}
}
}
msgs = msgs.filter(msgFilter);
// Always sort newest first before applying the pagination window.
msgs = msgs.sort((a, b) => b.t - a.t);
+109 -6
View File
@@ -173,8 +173,10 @@ import {
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
import {
isJidCus,
isJidGroup,
isJidStatusBroadcast,
isLidUser,
normalizeJid,
toCusFormat,
} from '@waha/core/utils/jids';
@@ -185,6 +187,9 @@ import {
WAHA_CLIENT_BROWSER_NAME,
WAHA_CLIENT_DEVICE_NAME,
} from '@waha/core/env';
import { removeSingletonFiles } from '@waha/core/utils/chrome';
import { killProcessesByPatterns } from '@waha/core/utils/processes';
import { IsChrome } from '@waha/version';
export interface WebJSConfig {
webVersion?: string;
@@ -202,6 +207,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
private engineStateCheckDelayedJob: SingleDelayedJobRunner;
private shouldRestart: boolean;
private lastQRDate: Date = null;
private static readonly REACTION_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
whatsapp: WebjsClientCore;
protected qr: QR;
@@ -271,6 +277,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
};
}
protected getUserDataDir(): string {
const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
return `${base}/webjs/default/session-${this.name}`;
}
protected async buildClient() {
const clientOptions = this.getClientOptions();
const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
@@ -358,6 +369,16 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
PAGE_CALL_ERROR_EVENT,
(event: CallErrorEvent) => {
if (event.error instanceof ProtocolError) {
if (this.shouldIgnoreProtocolError(event.error)) {
this.logger.warn(
`ProtocolError when calling page method: ${String(
event.method,
)}, ignoring...`,
);
this.logger.warn(event.error);
return;
}
this.logger.error(
`ProtocolError when calling page method: ${String(
event.method,
@@ -393,6 +414,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
async start() {
await killProcessesByPatterns(
[IsChrome ? 'chrome' : 'chromium', `--a-waha-session=${this.name}`],
'SIGKILL',
this.logger,
);
await removeSingletonFiles(this.getUserDataDir());
this.status = WAHASessionStatus.STARTING;
await this.init().catch((err) => {
this.logger.error('Failed to start the client');
@@ -418,6 +446,16 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.restartClient();
}
/**
* Certain Puppeteer ProtocolErrors (e.g. Network.getResponseBody) are harmless.
* Ignore them so we do not thrash the session state machine.
* https://github.com/devlikeapro/waha/issues/1918
*/
private shouldIgnoreProtocolError(error: ProtocolError): boolean {
const message = error?.message ?? String(error ?? '');
return message.includes('Network.getResponseBody');
}
async unpair() {
this.unpairing = true;
this.shouldRestart = false;
@@ -674,6 +712,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return screenshot as Buffer;
}
@Activity()
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -717,6 +756,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
/**
* Other methods
*/
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const peerJid = normalizeJid(this.ensureSuffix(from));
const call = new CallInstance(this.whatsapp, null);
@@ -991,7 +1031,50 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
messageId: string,
query: GetChatMessageQuery,
): Promise<null | WAMessage> {
const message = await this.whatsapp.getMessageById(messageId);
chatId = this.ensureSuffix(chatId);
// WEBJS waits the serializer messageId
// {fromMe}_{chatId}_{id}[_{participant}]
if (isJidStatusBroadcast(chatId) && !messageId.includes('_')) {
// For status - resolve it as "my" if no details provided
const me = this.getSessionMeInfo();
const lid = me.lid || (await this.whatsapp.findLIDByPhoneNumber(me.id));
messageId = SerializeMessageKey({
fromMe: true,
id: messageId,
remoteJid: Jid.BROADCAST,
participant: lid,
});
}
let message: Message | null = null;
if (isLidUser(chatId) || isJidCus(chatId)) {
// If in DM chats and not fromMe specified - try both fromMe and not
if (!messageId.includes('_')) {
// FromMe - true
if (!message) {
const id = SerializeMessageKey({
fromMe: true,
id: messageId,
remoteJid: chatId,
});
message = await this.whatsapp.getMessageById(id);
}
// FromMe - false
if (!message) {
const id = SerializeMessageKey({
fromMe: false,
id: messageId,
remoteJid: chatId,
});
message = await this.whatsapp.getMessageById(id);
}
}
}
if (!message) {
message = await this.whatsapp.getMessageById(messageId);
}
if (!message) return null;
if (
isJidGroup(message.id.remote) ||
@@ -1909,6 +1992,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const media = await this.downloadMediaSafe(message);
wamessage.media = media;
}
if (downloadMedia && wamessage.replyTo?.hasMedia) {
const quotedMessage = await message.getQuotedMessage().catch(() => null);
if (quotedMessage) {
wamessage.replyTo.media = await this.downloadMediaSafe(quotedMessage);
}
}
return wamessage;
}
@@ -1938,6 +2027,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
// Ignore reactions older than 2 days to prevent stale reactions
// when session reconnects and replays buffered events
const twoDaysAgoSec =
(Date.now() - WhatsappSessionWebJSCore.REACTION_MAX_AGE_MS) / 1000;
if (reaction.timestamp < twoDaysAgoSec) {
return null;
}
const source = this.getMessageSource(reaction.id.id);
return {
id: reaction.id._serialized,
@@ -2063,15 +2160,21 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
protected extractReplyTo(message: Message): ReplyToMessage | null {
const rawData: any = message.rawData;
// @ts-ignore
const quotedMsg = message.rawData?.quotedMsg;
if (!quotedMsg) {
const quotedMsg = rawData?.quotedMsg;
const quotedStanzaId = rawData?.quotedStanzaID || rawData?.quotedStanzaId;
if (!quotedMsg && !quotedStanzaId) {
return;
}
const quotedParticipant =
rawData?.quotedParticipant || quotedMsg?.author || quotedMsg?.from;
return {
id: quotedMsg.id?.id,
participant: quotedMsg.author || quotedMsg.from,
body: quotedMsg.caption || quotedMsg.body,
id: quotedStanzaId || quotedMsg?.id?.id,
participant: quotedParticipant,
body: quotedMsg?.caption || quotedMsg?.body,
hasMedia: Boolean(quotedMsg?.directPath),
media: null,
_data: quotedMsg,
};
}
+18
View File
@@ -0,0 +1,18 @@
export interface IWPPAuthManager {
/**
* Called BEFORE WPP browser starts.
* Downloads and extracts any existing remote session to userDataDir.
*/
beforeStart(): Promise<void>;
/**
* Called AFTER the session reaches WORKING status.
* Waits for stabilization, saves first snapshot, then starts periodic backup.
*/
afterConnected(): Promise<void>;
/**
* Stops the periodic backup runner.
*/
stop(): Promise<void>;
}
+4
View File
@@ -0,0 +1,4 @@
export interface WPPConfig {
webVersion?: string;
puppeteerArgs: string[];
}
+82
View File
@@ -0,0 +1,82 @@
import { Whatsapp as WPPWhatsapp } from '@wppconnect-team/wppconnect';
import type {
PoolMessageOptions,
TextMessageOptions,
} from '@wppconnect/wa-js/dist/chat';
import type { EditMessageOptions } from '@wppconnect/wa-js/dist/chat/functions/editMessage';
import type { TextStatusOptions } from '@wppconnect/wa-js/dist/status/functions/sendTextStatus';
//
// Event data types extracted directly from wppconnect on* method signatures.
// Using Parameters<> keeps these automatically in sync with wppconnect:
// if an inline type changes upstream, TypeScript will catch mismatches here.
//
// Extracts the last parameter of a function (handles overloaded on* methods
// that accept an optional id filter as the first arg before the callback).
type LastParam<F extends (...args: any[]) => any> = Parameters<F> extends [
...any[],
infer Last,
]
? Last
: never;
// Extracts the event data type: first arg of the (last) callback parameter.
type WppEventData<Method extends (...args: any[]) => any> =
LastParam<Method> extends (...args: any[]) => any
? Parameters<LastParam<Method>>[0]
: never;
type WppTextMessageOptions = TextMessageOptions;
type WppMentionedList = WppTextMessageOptions extends {
mentionedList?: infer Value;
}
? Value
: string[];
export type WppPresenceEvent = WppEventData<WPPWhatsapp['onPresenceChanged']>;
export type WppParticipantEvent = WppEventData<
WPPWhatsapp['onParticipantsChanged']
>;
export enum WppParticipantAction {
ADD = 'add',
JOIN = 'join',
REMOVE = 'remove',
LEAVE = 'leave',
LEAVER = 'leaver',
PROMOTE = 'promote',
DEMOTE = 'demote',
}
export enum WppParticipantOperation {
ADD = 'add',
REMOVE = 'remove',
PROMOTE = 'promote',
DEMOTE = 'demote',
}
export type WppReactionEvent = WppEventData<WPPWhatsapp['onReactionMessage']>;
export type WppRevokedMessageEvent = WppEventData<
WPPWhatsapp['onRevokedMessage']
>;
export type WppPollResponseEvent = WppEventData<WPPWhatsapp['onPollResponse']>;
export type WppUpdateLabelEvent = WppEventData<WPPWhatsapp['onUpdateLabel']>;
export type WppIncomingCallEvent = WppEventData<WPPWhatsapp['onIncomingCall']>;
export type WppSendPollOptions = PoolMessageOptions;
export type WppSendTextOptions = WppTextMessageOptions;
export type WppSendTextStatusOptions = Omit<
TextStatusOptions,
'backgroundColor'
> & {
backgroundColor?: string;
};
export type WppEditMessageOptions = EditMessageOptions & {
mentions?: WppMentionedList;
};
// onMessageEdit callback has three separate args: (chat: Wid, id: string, msg: Message).
// fromWppCallback packs them into a tuple since args.length > 1.
export type WppMessageEditArgs = Parameters<
LastParam<WPPWhatsapp['onMessageEdit']>
>;
+216
View File
@@ -0,0 +1,216 @@
import { toCusFormat } from '@waha/core/utils/jids';
import { SerializeMsgKey } from '@waha/core/utils/ids';
import { WAMessageReaction } from '@waha/structures/responses.dto';
import { WAHAPresenceStatus } from '@waha/structures/enums.dto';
import {
GroupId,
GroupParticipant,
GroupParticipantRole,
} from '@waha/structures/groups.dto';
import {
GroupParticipantType,
GroupV2LeaveEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { LabelChatAssociation } from '@waha/structures/labels.dto';
import { WAHAChatPresences } from '@waha/structures/presence.dto';
import {
WppParticipantAction,
WppParticipantEvent,
WppPresenceEvent,
WppReactionEvent,
WppUpdateLabelEvent,
} from '@waha/core/engines/wpp/WppTypes';
//
// Converters
//
function presenceStateToWAHA(
state: string,
isOnline?: boolean,
): WAHAPresenceStatus {
switch (state) {
case 'available':
return WAHAPresenceStatus.ONLINE;
case 'composing':
case 'typing':
return WAHAPresenceStatus.TYPING;
case 'recording':
return WAHAPresenceStatus.RECORDING;
case 'unavailable':
return WAHAPresenceStatus.OFFLINE;
default:
return isOnline ? WAHAPresenceStatus.ONLINE : WAHAPresenceStatus.OFFLINE;
}
}
export function WppPresenceToPresence(
data: WppPresenceEvent,
): WAHAChatPresences {
const chatId = toCusFormat(data.id);
// Group: iterate over per-participant states when available
if (data.isGroup && data.participants?.length) {
return {
id: chatId,
presences: data.participants.map((p) => ({
participant: toCusFormat(p.id),
lastKnownPresence: presenceStateToWAHA(p.state),
lastSeen: null,
})),
};
}
return {
id: chatId,
presences: [
{
participant: chatId,
lastKnownPresence: presenceStateToWAHA(data.state, data.isOnline),
lastSeen: null,
},
],
};
}
export function WppParticipantsToGroupV2Participants(
data: WppParticipantEvent,
): GroupV2ParticipantsEvent | null {
let type: GroupParticipantType;
let role: GroupParticipantRole;
switch (data.action) {
case WppParticipantAction.ADD:
case WppParticipantAction.JOIN:
type = GroupParticipantType.JOIN;
role = GroupParticipantRole.PARTICIPANT;
break;
case WppParticipantAction.REMOVE:
case WppParticipantAction.LEAVE as any:
case WppParticipantAction.LEAVER:
type = GroupParticipantType.LEAVE;
role = GroupParticipantRole.LEFT;
break;
case WppParticipantAction.PROMOTE:
type = GroupParticipantType.PROMOTE;
role = GroupParticipantRole.ADMIN;
break;
case WppParticipantAction.DEMOTE:
type = GroupParticipantType.DEMOTE;
role = GroupParticipantRole.PARTICIPANT;
break;
default:
return null;
}
const group: GroupId = { id: toCusFormat(data.groupId) };
const participants: GroupParticipant[] = data.who.map((id) => ({
id: id,
pn: null,
role: role,
}));
return {
group: group,
type: type,
timestamp: Math.floor(Date.now() / 1000),
participants: participants,
_data: data,
};
}
export function WppParticipantsIsMyJoin(
data: WppParticipantEvent,
me: MeInfo | null | undefined,
): boolean {
const joinActions = [WppParticipantAction.ADD, WppParticipantAction.JOIN];
if (!joinActions.includes(data.action as WppParticipantAction)) return false;
return data.who.some((id) => id === me?.id || id === me?.lid);
}
export function WppParticipantsIsMyLeave(
data: WppParticipantEvent,
me: MeInfo | null | undefined,
): boolean {
const leaveActions = [
WppParticipantAction.REMOVE,
WppParticipantAction.LEAVE,
WppParticipantAction.LEAVER,
];
if (!leaveActions.includes(data.action as WppParticipantAction)) return false;
return data.who.some((id) => id === me?.id || id === me?.lid);
}
export function WppParticipantsToGroupV2Leave(
data: WppParticipantEvent,
): GroupV2LeaveEvent {
return {
timestamp: Math.floor(Date.now() / 1000),
group: { id: toCusFormat(data.groupId) },
_data: data,
};
}
export function WppReactionToMessageReaction(
data: WppReactionEvent,
): WAMessageReaction {
// sender is present at runtime but absent from the installed package's type
const raw = data as any;
const sender: string =
raw.sender?._serialized ?? raw.sender?.toString?.() ?? raw.sender ?? '';
// remote is the chat ID (peer in DM, group JID in group chats)
const remote = toCusFormat(raw.id?.remote ?? '');
const isGroup = remote.includes('@g.us');
// In DMs, the remote party IS the sender when sender field is missing
const from = sender ? toCusFormat(sender) : isGroup ? '' : remote;
const participant = sender ? toCusFormat(sender) : isGroup ? '' : remote;
const id = SerializeMsgKey(data.id);
const messageId = SerializeMsgKey(data.msgId);
return {
id: id,
from: from,
fromMe: raw.id?.fromMe ?? false,
source: null,
participant: participant,
to: remote,
timestamp: data.timestamp,
reaction: {
text: data.reactionText ?? '',
messageId: messageId,
},
// @ts-ignore
_data: data,
};
}
export function WppGp2ToGroupV2Update(msg: any): GroupV2UpdateEvent {
const id = toCusFormat(msg.chatId);
const group: Partial<Record<string, any>> = { id: id };
if (msg.subtype === 'subject') {
group.subject = msg.body;
} else if (msg.subtype === 'description') {
group.description = msg.body;
} else {
return null;
}
return {
timestamp: msg.timestamp,
group: group as any,
_data: msg,
};
}
export function WppUpdateLabelToAssociations(
data: WppUpdateLabelEvent,
): LabelChatAssociation[] {
const chat = data.chat as any;
const chatId: string = chat?.id?._serialized ?? chat?.id ?? chat ?? '';
return data.ids.map((labelId, i) => ({
labelId: labelId,
label: (data.labels?.[i] ?? null) as any,
chatId: toCusFormat(chatId),
}));
}
@@ -0,0 +1,24 @@
import { Observable } from 'rxjs';
import { EnginePayload } from '@waha/structures/webhooks.dto';
/**
* Wraps a WPPConnect `on*` listener method into a lazy RxJS Observable.
* The listener is registered only when subscribed and disposed on teardown.
*
* @param event Event name label stored in EnginePayload, e.g. `'onMessage'`
* @param fn Bound wppconnect listener invocation, e.g. `(cb) => wpp.onMessage(cb)`
*/
export function fromWppCallback(
event: string,
fn: (callback: (...args: any[]) => void) => { dispose: () => void },
): Observable<EnginePayload> {
return new Observable<EnginePayload>((subscriber) => {
const { dispose } = fn((...args: any[]) => {
// Multi-arg callbacks (e.g. onMessageEdit receives chat, id, msg) are
// packed into an array so EnginePayload.data is always a single value.
const data = args.length === 1 ? args[0] : args;
subscriber.next({ event: event, data: data });
});
return () => dispose();
});
}
+123
View File
@@ -0,0 +1,123 @@
import { Observable, share } from 'rxjs';
import { Whatsapp as WPPWhatsapp } from '@wppconnect-team/wppconnect';
import { fromWppCallback } from '@waha/core/engines/wpp/reactive/fromWppCallback';
import { EnginePayload } from '@waha/structures/webhooks.dto';
/**
* One Observable per wppconnect on* listener method we subscribe to.
* The compile-time guard below ensures every key exists on WPPWhatsapp:
* if wppconnect removes a method TypeScript will error on that assertion.
*/
export interface WppStreams {
onMessage: Observable<EnginePayload>;
onAnyMessage: Observable<EnginePayload>;
onAck: Observable<EnginePayload>;
onMessageEdit: Observable<EnginePayload>;
onNotificationMessage: Observable<EnginePayload>;
onParticipantsChanged: Observable<EnginePayload>;
onStateChange: Observable<EnginePayload>;
onStreamChange: Observable<EnginePayload>;
onIncomingCall: Observable<EnginePayload>;
onInterfaceChange: Observable<EnginePayload>;
onPresenceChanged: Observable<EnginePayload>;
onLiveLocation: Observable<EnginePayload>;
onAddedToGroup: Observable<EnginePayload>;
onRevokedMessage: Observable<EnginePayload>;
onReactionMessage: Observable<EnginePayload>;
onPollResponse: Observable<EnginePayload>;
onUpdateLabel: Observable<EnginePayload>;
onOrderStatusUpdate: Observable<EnginePayload>;
}
// Compile-time guard: every key of WppStreams must be a real method on WPPWhatsapp.
// If wppconnect removes a listener method this line will produce a TypeScript error.
type _AssertWppStreams = keyof WppStreams extends keyof WPPWhatsapp
? true
: never;
export const _assertWppStreams: _AssertWppStreams = true;
void _assertWppStreams;
/**
* Builds a map of shared, lazy Observables — one per WPPConnect on* listener.
*
* Each Observable registers the underlying wppconnect callback only when
* subscribed and disposes it on teardown. Using direct method calls
* (rather than a generic loop) lets IDEs resolve types and navigate to
* wppconnect's own declarations.
*
* WppStreams is the exhaustiveness guard: if a field is missing here TypeScript
* will report a missing-property error on the object literal.
*/
export function buildWppStreams(wpp: WPPWhatsapp): WppStreams {
return {
onMessage: fromWppCallback('onMessage', (cb) => wpp.onMessage(cb)).pipe(
share(),
),
onAnyMessage: fromWppCallback('onAnyMessage', (cb) =>
wpp.onAnyMessage(cb),
).pipe(share()),
onAck: fromWppCallback('onAck', (cb) => wpp.onAck(cb)).pipe(share()),
onMessageEdit: fromWppCallback('onMessageEdit', (cb) =>
wpp.onMessageEdit(cb),
).pipe(share()),
onNotificationMessage: fromWppCallback('onNotificationMessage', (cb) =>
wpp.onNotificationMessage(cb),
).pipe(share()),
onParticipantsChanged: fromWppCallback('onParticipantsChanged', (cb) =>
wpp.onParticipantsChanged(cb),
).pipe(share()),
onStateChange: fromWppCallback('onStateChange', (cb) =>
wpp.onStateChange(cb),
).pipe(share()),
onStreamChange: fromWppCallback('onStreamChange', (cb) =>
wpp.onStreamChange(cb),
).pipe(share()),
onIncomingCall: fromWppCallback('onIncomingCall', (cb) =>
wpp.onIncomingCall(cb),
).pipe(share()),
onInterfaceChange: fromWppCallback('onInterfaceChange', (cb) =>
wpp.onInterfaceChange(cb),
).pipe(share()),
onPresenceChanged: fromWppCallback('onPresenceChanged', (cb) =>
wpp.onPresenceChanged(cb),
).pipe(share()),
onLiveLocation: fromWppCallback('onLiveLocation', (cb) =>
wpp.onLiveLocation(cb),
).pipe(share()),
onAddedToGroup: fromWppCallback('onAddedToGroup', (cb) =>
wpp.onAddedToGroup(cb),
).pipe(share()),
onRevokedMessage: fromWppCallback('onRevokedMessage', (cb) =>
wpp.onRevokedMessage(cb),
).pipe(share()),
onReactionMessage: fromWppCallback('onReactionMessage', (cb) =>
wpp.onReactionMessage(cb),
).pipe(share()),
onPollResponse: fromWppCallback('onPollResponse', (cb) =>
wpp.onPollResponse(cb),
).pipe(share()),
onUpdateLabel: fromWppCallback('onUpdateLabel', (cb) =>
wpp.onUpdateLabel(cb),
).pipe(share()),
onOrderStatusUpdate: fromWppCallback('onOrderStatusUpdate', (cb) =>
wpp.onOrderStatusUpdate(cb),
).pipe(share()),
};
}
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -11,6 +11,7 @@ import {
} from '@waha/apps/app_sdk/services/IAppsService';
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { WhatsappSessionGoWSCore } from '@waha/core/engines/gows/session.gows.core';
import { WebhookConductor } from '@waha/core/integrations/webhooks/WebhookConductor';
@@ -24,6 +25,7 @@ import { PinoLogger } from 'nestjs-pino';
import { Observable, retry, share } from 'rxjs';
import { map } from 'rxjs/operators';
import { getNamespace, getSessionNamespace } from '../config';
import { WhatsappConfigService } from '../config.service';
import {
WAHAEngine,
@@ -42,6 +44,7 @@ import { populateSessionInfo, SessionManager } from './abc/manager.abc';
import { SessionParams, WhatsappSession } from './abc/session.abc';
import { EngineConfigService } from './config/EngineConfigService';
import { WhatsappSessionNoWebCore } from './engines/noweb/session.noweb.core';
import { WhatsappSessionWPPCore } from './engines/wpp/session.wpp.core';
import { WhatsappSessionWebJSCore } from './engines/webjs/session.webjs.core';
import { DOCS_URL } from './exceptions';
import { getProxyConfig } from './helpers.proxy';
@@ -84,6 +87,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
config: WhatsappConfigService,
private engineConfigService: EngineConfigService,
private webjsEngineConfigService: WebJSEngineConfigService,
private wppEngineConfigService: WPPEngineConfigService,
gowsConfigService: GowsEngineConfigService,
log: PinoLogger,
private mediaStorageFactory: MediaStorageFactory,
@@ -104,7 +108,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
}),
);
this.store = new LocalStoreCore(engineName.toLowerCase());
this.store = new LocalStoreCore(getNamespace(), getSessionNamespace());
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
this.clearStorage().catch((error) => {
this.log.error({ error }, 'Error while clearing storage');
@@ -114,6 +118,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
protected getEngine(engine: WAHAEngine): typeof WhatsappSession {
if (engine === WAHAEngine.WEBJS) {
return WhatsappSessionWebJSCore;
} else if (engine === WAHAEngine.WPP) {
return WhatsappSessionWPPCore;
} else if (engine === WAHAEngine.NOWEB) {
return WhatsappSessionNoWebCore;
} else if (engine === WAHAEngine.GOWS) {
@@ -206,6 +212,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
};
if (this.EngineClass === WhatsappSessionWebJSCore) {
sessionConfig.engineConfig = this.webjsEngineConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionWPPCore) {
sessionConfig.engineConfig = this.wppEngineConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
}
+2 -2
View File
@@ -17,8 +17,8 @@ export class MediaManager implements IMediaManager {
// https://github.com/IndigoUnited/node-promise-retry
RETRY_OPTIONS = {
retries: 5,
minTimeout: 100,
maxTimeout: 500,
minTimeout: 1000,
maxTimeout: 3000,
};
constructor(
+31 -13
View File
@@ -5,7 +5,7 @@ import * as path from 'path';
import { Logger } from 'pino';
import fs = require('fs');
import { fileExists } from '@waha/utils/files';
import { deleteAsync } from 'del';
import { setLongTimeout } from '@waha/utils/promiseTimeout';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const writeFileAtomic = require('write-file-atomic');
@@ -58,16 +58,33 @@ export class MediaLocalStorage implements IMediaStorage {
return;
}
if (fs.existsSync(this.filesFolder)) {
deleteAsync([`${this.filesFolder}/*`], { force: true }).then((paths) => {
if (paths.length === 0) {
return;
}
this.log.info('Deleted files and directories:\n', paths.join('\n'));
});
} else {
if (!fs.existsSync(this.filesFolder)) {
fs.mkdirSync(this.filesFolder);
this.log.info(`Directory '${this.filesFolder}' created from scratch`);
return;
}
const now = Date.now();
const entries = await fsp.readdir(this.filesFolder, { recursive: true });
for (const entry of entries) {
try {
const filepath = path.join(this.filesFolder, entry as string);
const stat = await fsp.stat(filepath);
if (!stat.isFile()) {
continue;
}
// Schedule removal:
// - expired files get a random jitter to spread deletions out,
// - non-expired files are scheduled for the time they have left.
const age = now - stat.mtimeMs;
const expired = age >= this.lifetimeMs;
const left = this.lifetimeMs - age;
const jitter = Math.random() * 10 * SECOND;
const remaining = expired ? jitter : left;
this.postponeRemoval(filepath, remaining);
} catch (err) {
this.log.warn(`Failed to schedule removal for file ${entry}`, err);
}
}
}
@@ -80,16 +97,17 @@ export class MediaLocalStorage implements IMediaStorage {
return path.resolve(`${this.filesFolder}/${filepath}`);
}
private postponeRemoval(filepath: string) {
private postponeRemoval(filepath: string, delayMs: number | null = null) {
if (this.lifetimeMs === 0) {
return;
}
setTimeout(
const delay = delayMs ?? this.lifetimeMs;
setLongTimeout(
() =>
fs.unlink(filepath, () => {
this.log.info(`File ${filepath} was removed`);
this.log.debug(`File ${filepath} was removed`);
}),
this.lifetimeMs,
delay,
);
}
+16 -6
View File
@@ -10,15 +10,18 @@ export class LocalStoreCore extends LocalStore {
protected readonly baseDirectory: string =
process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
private readonly engine: string;
private readonly namespace: string;
private readonly sessionNamespace: string;
private knex: Knex.Knex;
constructor(engine: string) {
constructor(namespace: string, sessionNamespace: string) {
super();
this.engine = engine;
this.namespace = namespace;
this.sessionNamespace = sessionNamespace;
}
async init(sessionName?: string) {
await fs.mkdir(this.getMainDirectory(), { recursive: true });
await fs.mkdir(this.getEngineDirectory(), { recursive: true });
if (!this.knex) {
this.knex = this.buildKnex();
@@ -40,11 +43,18 @@ export class LocalStoreCore extends LocalStore {
return path.resolve(this.baseDirectory);
}
/**
* Get the directory where the main WAHA database (SQLite) is stored
*/
getMainDirectory() {
return safeJoin(this.baseDirectory, this.namespace);
}
/**
* Get the directory where the engine sessions are stored
*/
getEngineDirectory() {
return safeJoin(this.baseDirectory, this.engine);
return safeJoin(this.baseDirectory, this.sessionNamespace);
}
getSessionDirectory(name: string): string {
@@ -67,8 +77,8 @@ export class LocalStoreCore extends LocalStore {
}
buildKnex(): Knex.Knex {
const engineDir = this.getEngineDirectory();
const database = path.join(engineDir, 'waha.sqlite3');
const mainDir = this.getMainDirectory();
const database = path.join(mainDir, 'waha.sqlite3');
return Knex({
client: KNEX_SQLITE_CLIENT,
connection: { filename: database },
+11 -3
View File
@@ -210,8 +210,12 @@ export class SqlKVRepository<Entity> {
/**
* SQL helpers
*/
public getKnex(): Knex {
return this.knex;
}
public select() {
return this.knex.select().from(this.table);
return this.knex.select(`${this.table}.*`).from(this.table);
}
protected delete() {
@@ -219,7 +223,11 @@ export class SqlKVRepository<Entity> {
}
public pagination(query: any, pagination?: PaginationParams) {
const paginator = new this.Paginator(pagination, this.jsonQuery);
const paginator = new this.Paginator(
pagination,
this.jsonQuery,
this.table,
);
return paginator.apply(query);
}
@@ -234,7 +242,7 @@ export class SqlKVRepository<Entity> {
return JSON.stringify(data);
}
protected parse(row: any) {
public parse(row: any) {
return JSON.parse(row.data);
}
+9 -16
View File
@@ -20,25 +20,18 @@ describe('E164Parser.fromJid', () => {
expect(E164Parser.fromJid('@s.whatsapp.net')).toBeNull();
});
it('applies Brazil add-9 rule when local has 8 digits (any first digit)', () => {
// +55 <DDD:2> <local:8>
expect(E164Parser.fromJid('553188888888@s.whatsapp.net')).toBe(
'+5531988888888',
it('does not add 9 for BR landline (local starts with 2..5)', () => {
expect(E164Parser.fromJid('558540423147@s.whatsapp.net')).toBe(
'+558540423147',
);
expect(E164Parser.fromJid('558820181896@s.whatsapp.net')).toBe(
'+558820181896',
);
});
it('554999111111 => 5549999111111', () => {
expect(E164Parser.fromJid('554999111111@s.whatsapp.net')).toBe(
'+5549999111111',
);
});
it('adds 9 even when local already starts with 9 (Brazil)', () => {
expect(E164Parser.fromJid('553198888888@s.whatsapp.net')).toBe(
'+5531998888888',
);
expect(E164Parser.fromJid('553199999999@s.whatsapp.net')).toBe(
'+5531999999999',
it('adds 9 for BR mobile/others (local not starting with 2..5)', () => {
expect(E164Parser.fromJid('558591203123@s.whatsapp.net')).toBe(
'+5585991203123',
);
});
+9 -1
View File
@@ -56,7 +56,15 @@ export class PhoneJidNormalizer {
}
const RULES = [
// Brazil - ensure mobile numbers have 9 digits after DDD
// Brazil landline (fixed line) heuristic:
// +55 <DDD:2> <local:8> where local[0] is 2..5 => do NOT add extra 9
{
name: 'br-no-add-9-for-landline',
re: /^\+55(\d{2})([2-5]\d{7})$/,
replace: '+55$1$2',
},
// Brazil mobile/others:
// +55 <DDD:2> <local:8> => add extra 9 after DDD
{
name: 'br-add-9-after-ddd',
re: /^\+55(\d{2})(\d{8})$/,
+18 -1
View File
@@ -1,4 +1,4 @@
import { WAMessageAck } from '@waha/structures/enums.dto';
import { WAMessageAck, WAMessageAckName } from '@waha/structures/enums.dto';
export function StatusToAck(status: number): WAMessageAck {
return status - 1;
@@ -7,3 +7,20 @@ export function StatusToAck(status: number): WAMessageAck {
export function AckToStatus(ack: number): number {
return ack + 1;
}
export function StatusStringToStatus(status: number | string): number {
switch (status) {
case WAMessageAckName.ERROR:
return AckToStatus(WAMessageAck.ERROR);
case WAMessageAckName.PENDING:
return AckToStatus(WAMessageAck.PENDING);
case WAMessageAckName.SERVER:
return AckToStatus(WAMessageAck.SERVER);
case WAMessageAckName.READ:
return AckToStatus(WAMessageAck.READ);
case WAMessageAckName.PLAYED:
return AckToStatus(WAMessageAck.PLAYED);
default:
return status as any;
}
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more