Compare commits

..
133 Commits
Author SHA1 Message Date
devlikepro 79233e09e3 fix(NOWEB): Add WAHA_NOWEB_WA_VERSION / WAHA_NOWEB_WA_VERSION_FORCE - closes #2193
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-07-29 15:18:03 +07:00
devlikepro a96a4b2161 up(NOWEB): fix version - fix #2191 2026-07-29 14:31:41 +07:00
devlikepro f736105873 up(dashboard): session info, copy button in Event Monitor 2026-07-22 17:42:41 +07:00
devlikepro 2c1ea2c7c1 feat: /settings/security/member-add-mode - fix #2165 2026-07-22 17:35:49 +07:00
Ali White 216e00cc25 [core] feat(NOWEB): expose group member-add-mode as a settable endpoint close #2172 2026-07-22 17:19:34 +07:00
devlikeproandBerg Pinheiro ac1a015046 [core] Fix restartStoppedSessions aborting on one stuck session, closes #2169
Co-Authored-By: Berg Pinheiro <berg.pinheiro2009@gmail.com>
2026-07-22 17:17:35 +07:00
devlikepro 8f4af6bb08 feat: expose Reachout Timelock data - WORKING status and /me info fix #2166 2026-07-22 16:56:44 +07:00
devlikepro acee1b7d79 chore: timehelper docs 2026-07-22 16:12:21 +07:00
devlikepro 99241c3089 chore: SessionName to validation 2026-07-22 16:00:28 +07:00
devlikepro 8e764372d2 fix: LongTimeout and unref() for not keeping the process alive 2026-07-22 15:53:05 +07:00
devlikepro fa21a60cf3 fix(ChatWoot): safe read, typing when sending messages - fix #2173 2026-07-22 13:20:18 +07:00
devlikepro 02fa0ea06b ci: build dev nightly 2026-07-22 13:20:18 +07:00
devlikepro 8d1ad04625 fix(NOWEB): fix empty message.edited body - fix #2168 2026-07-22 13:20:18 +07:00
devlikepro a639baad8d fix(NOWEB): up engine 2026-07-22 13:20:18 +07:00
devlikepro 2e24107018 chore: up yarn@4.17.1 2026-07-22 13:20:18 +07:00
devlikepro 750ce208b3 chore(WEBJS): Up chrome 140.0.7339.207-1
The previous version is not found
2026-07-22 13:20:18 +07:00
devlikepro 057e0a0e70 up(GOWS): fix channels link preview - mention #2163, fix unknown field "faviconMMSMetadata" - fix #2172 2026-07-22 13:20:18 +07:00
devlikepro 255f84a12d fix(NOWEB): sending preview in channels mention #2163 2026-07-17 19:00:38 +07:00
devlikepro ac6d14394a chore: publish dev on core 2026-07-17 19:00:38 +07:00
devlikepro cb77c2fc49 fix(WEBJS): up engine. Fix sending link preview to channels fix #2163 2026-07-17 19:00:38 +07:00
devlikepro 5c279c5240 [core] 2026.7.2 2026-07-17 14:55:42 +07:00
devlikepro 7e719d8894 [core] 2026.7.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-07-15 20:04:29 +07:00
devlikepro 8faa3ca5a4 [core] Up dashboard 2026-07-15 20:04:29 +07:00
devlikepro 5dd8bf140d [core] Up WEBJS 2026-07-15 20:04:29 +07:00
devlikepro 0501c37500 [core] WEBJS - add lid to /me 2026-07-15 20:04:29 +07:00
devlikepro 1496274a99 [core] Up WEBJS - fix _serialized id rename fix #2157 fix #2158 fix #2159 fix #2160 fix #2162 2026-07-15 20:04:29 +07:00
devlikepro b6ba3951da [core] Up NOWEB 2026-07-15 20:04:29 +07:00
devlikepro a4a3dc93ad [core] Up WPP - fix _serialized issues 2026-07-15 20:04:28 +07:00
devlikepro 57eb0e14ca [core] Up dashboard - fix image emoji 2026-07-15 20:04:28 +07:00
devlikepro d8970326be [core] GOWS - fix "Session silently stops sending webhook events after <stream:error> (media ack)" - fix #2151 2026-07-15 20:04:28 +07:00
devlikepro 06412c2c1e [core] Up WEBJS - fix sending image with "msg.avParams is not a function" fix #2149 2026-07-15 20:04:28 +07:00
devlikepro 0b39645c50 [core] Update Dashboard - passkey flow driven by session.status
Picks up the UI side of the passkey rework: no more passkey.* events,
PASSKEY_CONFIRMATION_REQUIRED handled, GET /auth/passkey/challenge.
2026-07-15 20:04:28 +07:00
devlikepro 76f8cc6f53 [core] Don't watch src/dashboard assets - fixes ENOSPC inotify limit on start:dev 2026-07-15 20:04:28 +07:00
devlikepro ea79b1d886 [core] Passkey - collapse events into session.status, add GET /auth/passkey/challenge
- Remove 'passkey.required' and 'passkey.confirmation.required' events.
  Passkey pairing is a session state, so it rides on 'session.status'
  instead - one new status value per pairing step WhatsApp adds, not
  one new event.
- Add PASSKEY_CONFIRMATION_REQUIRED session status.
- Add 'data' to the session.status payload - the extra info that belongs
  to the current status. PASSKEY_REQUIRED carries the WebAuthn challenge,
  PASSKEY_CONFIRMATION_REQUIRED carries { code }, null otherwise.
- Base session gets setStatus(status, data); the plain 'status = value'
  setter delegates to it with no data, so the data clears itself as soon
  as the session moves on (WORKING, STOPPED, ...) with no extra bookkeeping.
- REST: GET /auth/passkey/challenge (was GET /auth/passkey) returns the
  challenge object, GET /auth/passkey/confirmation returns { code }.
  Both throw 422 when nothing is pending.
- MCP: auth-passkey-challenge, auth-passkey-submit, auth-passkey-confirmation,
  auth-passkey-confirm.
- Drop the SkipHandoffUX auto-confirm branch - it was dead code. whatsmeow
  confirms on its own in that case and only emits passkey-confirmation for
  the manual one (qrchan.go).
- Keep QR rotation from bouncing PASSKEY_CONFIRMATION_REQUIRED back to
  SCAN_QR_CODE, same as PASSKEY_REQUIRED.
2026-07-15 20:04:27 +07:00
devlikepro e54604eb97 [core] rm settings.local.json 2026-07-15 20:04:27 +07:00
Berg Pinheiro d267a29e87 [core] Update Dashboard - adds Passkey UI (extension-assisted + manual DevTools fallback) 2026-07-15 20:04:27 +07:00
Berg Pinheiro d4625359e6 [core] Up GOWS - v1.0.43, adds SubmitPasskeyResponse/ConfirmPasskey RPCs 2026-07-15 20:04:27 +07:00
Berg Pinheiro 3618b92c3e feat(passkey): Add Passkey (WebAuthn) session pairing
- New engine step: gows emits passkey-request/passkey-confirmation, session
  status PASSKEY_REQUIRED, challenge stored and exposed via getPasskeyChallenge().
- REST: GET/POST /api/:session/auth/passkey, GET /api/:session/auth/passkey/confirmation,
  POST /api/:session/auth/passkey/confirm.
- Webhooks: passkey.required (challenge) and passkey.confirmation.required (manual
  code case; most pairings auto-confirm server-side right after the assertion).
- QR rotation no longer bounces PASSKEY_REQUIRED back to SCAN_QR_CODE.
2026-07-15 20:04:27 +07:00
devlikepro 982092cf2b [core] MCP - do no share real api key for media and auth - qr or screenshot - fix #2146 2026-07-15 20:04:27 +07:00
devlikepro 6a452cd66e [core] Up WPP. Use 'main' branch versions for wa-js and wppconnect 2026-07-15 20:04:27 +07:00
devlikepro 19625e38e9 [core] Up NOWEB. Fix chat history ordering fix #2139. 2026-07-15 20:04:27 +07:00
devlikepro 208f4f3d78 [core] GOWS - fix document media — 403 on live media + media-retry never completes/refreshes directPath - fix ##2131
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-06-27 16:49:26 +07:00
devlikepro a9ff7d763d [core] GOWS - use gows-plus repo 2026-06-27 16:49:26 +07:00
devlikepro e290fd545f [core] make release 2026-06-27 16:32:05 +07:00
devlikepro 84f111e2c1 [core] 2026.6.2 2026-06-27 16:32:05 +07:00
devlikepro 55dddd5991 [core] WEBJS - remove all mentions of window.WAHA 2026-06-27 16:32:05 +07:00
devlikepro 638555297c [core] Up WEBJS 2026-06-27 16:32:04 +07:00
devlikepro 0e4de03da3 [core] Up GOWS 2026-06-27 16:32:04 +07:00
devlikepro c6219be356 [core] Up NOWEB 2026-06-27 16:32:04 +07:00
devlikepro 2460a23cab [core] Up WPP 2026-06-27 16:32:04 +07:00
devlikepro c2ed34a171 [core] 2026.6.1
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
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
2026-06-22 15:14:17 +07:00
devlikepro 1db8ae3423 [core] Merge PLUS functionality into CORE
Copy the storage (Mongo/Postgres/SQLite), media (S3/Postgres), engine
auth/store and util implementations from src/plus into src/core, and fold
the *Plus engine sessions, session manager, factories, health and channels
services into their *Core classes.

Core now supports multi-session orchestration, Mongo/Postgres session
storage, S3/Postgres media storage, FFmpeg media conversion, real health
checks and channels metadata. mediaConverter is set once in the base
session, and AppModuleCore wires the conditional media modules and health
indicators.
2026-06-22 15:14:17 +07:00
devlikepro 8a06ee3d44 [core] Up GOWS 2026-06-22 15:14:17 +07:00
devlikepro 9e11312b75 [core] 2026.5.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-05-26 12:21:20 +07:00
devlikepro 1c7ce47dbb [core] Up Dashboard 2026-05-26 12:21:20 +07:00
devlikepro 1d72e2666d [core] WEBJS on PostgreSQL - fix #2090
Session stuck in 'starting' or 'stoped' state due to Node.js Buffer limit reached when loading large RemoteAuth database entries (PostgreSQL/WebJS) #2090
2026-05-26 12:21:20 +07:00
devlikepro 7ae2f7999e [core] GOWS - send video - fix gif to mp4 format fix #2077 2026-05-26 12:21:20 +07:00
devlikepro 68dc4ad642 [core] NOWEB message.edit fix #2072 2026-05-26 12:21:19 +07:00
devlikepro e8f63aeaad [core] GOWS sticker placeholder URL 2026-05-26 12:21:19 +07:00
devlikepro f4e19c7664 [core] Up NOWEB 2026-05-26 12:21:19 +07:00
devlikepro 6d8ef2e375 [core] Up WPP 2026-05-26 12:21:19 +07:00
devlikepro 024cbffb13 [core] Up GOWS
Fix #2084 - All outbound messages fail with server returned error 400 until session restart
Fix #2085 - MediaRetry to also trigger on ciphertext hash mismatch (not only 403)
Fix #2080 - status@broadcast batch timeouts, add WAHA_GOWS_STATUS_PARTICIPANTS_BATCH_SIZE
2026-05-26 12:21:19 +07:00
devlikepro ad399d9b01 [core] watch ignore patterns 2026-05-26 12:21:19 +07:00
devlikepro 6053c162c0 [core] fix Dockerfile warning 2026-05-26 12:21:19 +07:00
Berg Pinheiro 6654a7b3d0 [core] GOWS sticker download: drop placeholder a.whatsapp.net URL (#45)
Problem:
- stickerMessage often has URL https://a.whatsapp.net with no path for
  encrypted media. The previous fix only copied uppercase URL to lowercase url,
  so DownloadMedia still tried HTTP GET on that host (for example DNS lookup
  failures) instead of using directPath and media keys.
- Lottie (application/was) stickers usually ship a full mmg.whatsapp.net URL,
  so they worked; image/webp stickers with the placeholder broke.

Solution:
- Treat a.whatsapp.net with an empty path as a placeholder: clone the message
  and delete both URL and url on stickerMessage before gRPC DownloadMedia.
- For real CDN URLs, keep mirroring URL to url when url is missing.
2026-05-26 12:21:19 +07:00
Berg Pinheiro 4ff1c01e38 [core] Convert GOWS Lottie stickers (application/was) to animated WebP - fix #2039 closes devlikeapro/waha-plus#43
Lottie stickers arrive from WhatsApp as a ZIP archive (mimetype
application/was) containing animation/animation.json. This change
intercepts those stickers in the GOWS Plus session, renders each
animation frame off-screen, and delivers an animated WebP to the
webhook instead of the raw ZIP.

Implementation:
- src/plus/utils/lottie-converter.ts (new):
  - Extracts animation.json from the ZIP via adm-zip
  - Renders frames with @lottiefiles/dotlottie-web + @napi-rs/canvas
    (HTMLCanvasElement polyfill required; ImageData must NOT be polyfilled
    to avoid per-frame pixel caching in WASM memory)
  - Drives frames manually via dotLottie.setFrame(i) after 'load' for
    reliable synchronous capture
  - Encodes each RGBA frame to single-frame WebP with sharp (quality=80)
  - Assembles animated WebP via node-webpmux with full alpha transparency
  - Frame delay derived from animation fps; clamped to >=30ms
  - Fallback delay configurable via WAHA_LOTTIE_DEFAULT_DELAY_MS env var
  - sharp/adm-zip/node-webpmux loaded via require() — their module.exports
    is the callable itself with no .default, so ESM default import resolves
    to undefined at runtime

- src/plus/engines/gows/session.gows.plus.ts:
  - Overrides downloadMedia with LottieAwareGOWSEngineMediaProcessor
  - normalizeStickerUrl: fixes GOWS URL field case mismatch (URL vs url)
    that caused an infinite network loop on Lottie downloads
  - Reports mimetype image/webp and extension .webp for Lottie stickers

- package.json: add @lottiefiles/dotlottie-web, @napi-rs/canvas,
  node-webpmux to dependencies
2026-05-26 12:21:18 +07:00
devlikepro dfe0a3c8c4 [core] Up Dashboard. WEBJS e2e and disappearing setting messages - fix #2046 2026-05-26 12:21:18 +07:00
devlikepro 04121bda52 [core] Up GOWS. Handle encrypted message edits - fix #2062 closes devlikapro/waha-plus#44 2026-05-26 12:21:18 +07:00
devlikepro 7b6f522f92 [core] POST /api/<session>/chats/overview - require pagination
fix #2070
2026-05-26 12:21:18 +07:00
devlikepro 10f666ebdc [core] 2026.4.3
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-05-07 14:14:20 +07:00
devlikepro e815dae6a4 [core] Up Dashboard - MCP app, API Keys scopes 2026-05-07 14:14:20 +07:00
devlikepro b7b57f183a [core] Apps tag for /mcp 2026-05-07 14:14:20 +07:00
devlikepro 4f63da18dd [core] Up NOWEB 2026-05-07 14:14:20 +07:00
devlikepro 751f149573 [core] Up WPP 2026-05-07 14:14:20 +07:00
devlikepro a331fd11bd [core] MCP - fix #1925
Do not restart session when updating some apps - AppDefinition.restartOnChange
2026-05-07 14:14:19 +07:00
devlikepro 376676442e [core] Auth - allow using ?x-api-key as auth 2026-05-07 14:14:19 +07:00
devlikepro 21daf501be [core] Scopes for Api Key (read/send/etc) - fix #2035 2026-05-07 14:14:19 +07:00
devlikepro 2fe7e307f0 [core] detect mimetype function 2026-05-07 14:14:19 +07:00
devlikepro bae171bfe1 [core] Add image/jpeg mimetype 2026-05-07 14:14:19 +07:00
devlikepro e3979339b4 [core] SessionService 2026-05-07 14:14:19 +07:00
devlikepro 58d2de1229 [core] GOWS - Provide message id in request 2026-05-07 14:14:19 +07:00
devlikepro 0e5f38d4a4 [core] NOWEB - Apply DistinctMessages
fix #2054
2026-05-07 14:14:18 +07:00
devlikepro 0f6c6a4147 [core] NOWEB - Provide message id in request
fix #2054
2026-05-07 14:14:18 +07:00
devlikepro 958b96029d [core] Add GET /api/:session/new-message-id 2026-05-07 14:14:18 +07:00
devlikepro 02de0c0d32 [core] Up NOWEB
Fix missing Facebook and Instagram ADs messages - fix #1922
2026-05-07 14:14:18 +07:00
devlikepro 8fd01c2b56 [core] Up NOWEB 2026-05-07 14:14:18 +07:00
devlikepro 63cfe47c83 [core] no fail-fast in dev 2026-05-07 14:14:18 +07:00
devlikepro 40519eac8c [core] Up GOWS
Add tctoken lifecycle - fix #2050
Fix "Sessions take a long time to start after server restart" - fix #2012
Fix 403 on some media download - use re-upload request from the phone for this - fix #2049
2026-05-07 14:14:18 +07:00
devlikepro 2a8158cf8d [core] Up WPP 2026-05-07 14:14:18 +07:00
devlikepro 549551b2a7 [core] ignore openapi.json 2026-05-07 14:14:18 +07:00
devlikepro d589c03da7 [core] AGENTS.md 2026-05-07 14:14:18 +07:00
devlikepro 495857ee41 [core] 2026.4.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-04-17 12:48:34 +07:00
devlikepro e04e55c4c5 [core] Up Dashboard 2026-04-17 12:48:34 +07:00
devlikepro 743e740860 [core] ChatWoot - allow "queue stop check.version"
fix #2025
2026-04-17 12:48:33 +07:00
devlikepro 65623ec1ea [core] ChatWoot - sort queues 2026-04-17 12:48:33 +07:00
devlikepro 72a301c579 [core] ChatWoot - split check.version and check.tier
fix #2025
2026-04-17 12:48:33 +07:00
devlikepro f35fd8d7c6 Revert "[core] ChatWoot: conditional agent name prefix in WA to WhatsApp templates (#1988)"
This reverts commit 1fb82c7678.
fix #2030 - [CHATWOOT] - Native Chatwoot integration sends agent name even when disabled (GOWS)
2026-04-17 12:48:33 +07:00
devlikepro 0594ca0709 [core] Rebuild sharp so it doesn't require SSE4.2 in CPU
fix #1952 -  WebAssembly.Module(): Compiling function #99 failed: Wasm SIMD unsupported @+25226
fix #1961 - Unsupported CPU: Prebuilt binaries for linux-x64 require v2 microarchitecture
2026-04-17 12:48:33 +07:00
devlikepro 4e5ebab97e [core] CPU and OS notes 2026-04-17 12:48:33 +07:00
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
274 changed files with 18573 additions and 1626 deletions

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)"
]
}
}
+1
View File
@@ -0,0 +1 @@
SHARP_IGNORE_GLOBAL_LIBVIPS=1
+3 -1
View File
@@ -35,13 +35,14 @@ jobs:
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
if: github.repository == 'devlikeapro/waha'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
${{ matrix.tag }}
strategy:
fail-fast: false
matrix:
include:
# Chromium - x86
@@ -72,6 +73,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: dev
fetch-depth: 0
- name: Check recent changes
+1
View File
@@ -1,3 +1,4 @@
openapi.json
tmp/*
src/core/engines/gows/proto
.env
+23 -16
View File
@@ -1,25 +1,32 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
supportedArchitectures:
cpu:
- 'arm'
- 'arm64'
- 'ia32'
- 'loong64'
- 'mips'
- 'mipsel'
- 'ppc64'
- 'riscv64'
- 's390'
- 's390x'
- 'x64'
- 'wasm32'
- arm
- arm64
- ia32
- loong64
- mips
- mipsel
- ppc64
- riscv64
- s390
- s390x
- x64
- wasm32
os:
- 'current'
- 'darwin'
- 'linux'
- 'win32'
- current
- darwin
- linux
- win32
+90 -169
View File
@@ -3,180 +3,69 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
only supports the default session plus minimal media features. Plus extends
core via `src/plus` to add multi-session orchestration, richer media handling,
and external storage integrations.
- Core code must remain free from Plus-only references. A pre-commit hook
rejects the word `plus` inside core files; reuse abstractions exposed through
`@waha/core/**` instead of importing Plus modules from Core.
- Commit subjects are validated: changes that touch `src/plus` require a
`[PLUS] …` prefix and must not include non-Plus files; everything else must
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
unsure.
- WAHA ships in **Core** and **Plus** editions
- Core lives under `src/core` and supports the default session with minimal
media features
- Plus extends core via `src/plus` to add multi-session orchestration, richer
media handling, and external storage integrations
- Core code must remain free from Plus-only references (pre-commit hook rejects
"plus" in core files)
- Commit subjects: changes that touch `src/plus` require `[PLUS] …` prefix;
everything else uses `[core] …`
## Tech Stack Snapshot
## Tech Stack
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
scripts with Yarn.
- **Framework**: NestJS v11 with dependency injection, modular controllers in
`src/api`, and Pino-based logging via `nestjs-pino`.
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
storage backends (Mongo/Postgres/SQLite).
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry)
- **Framework**: NestJS v11 with dependency injection and modular controllers in
`src/api`
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`,
`WPP`). Core uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus`
with extra storage backends (Mongo/Postgres/SQLite)
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
once.
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
before adding bespoke logic.
`src/vendor/esm.ts`
- **Utilities**: RxJS streams drive webhook event fan-out. Prefer existing
helpers in `src/utils` and `src/core/utils`
## Repository Landmarks
## Key Paths
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
(Core vs Plus) and configures global interceptors/filters.
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
always thread the session name through request DTOs and guards instead of
hardcoding `default`.
- `src/main.ts`: runtime entry point; dynamically loads AppModule (Core vs Plus)
- `src/api/**`: REST controllers and WebSocket gateway
- `src/core/**`: shared abstractions (config services, engine bootstrap,
storage, session management). Core only allows the `default` session and
cleans up storage on boot.
storage, session management)
- `src/plus/**`: multi-session orchestration, advanced media services, and
external persistence layers (Mongo, Postgres). Reuse this layer when adding
Plus-only capabilities.
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
services.
external persistence layers
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
`domain.action`), helper utilities. Maintain naming consistency when
introducing new events.
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
but keep existing tests working.
`domain.action`), helper utilities
## Coding Expectations
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
logic. Lodash ships with the project—prefer its utilities over hand-rolled
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
helpers like `lodash.camelCase` stay consistent across files. When a new
third-party library seems necessary, confirm with the user, especially if the
package looks stale.
- Favor composability and long-lived solutions
- Reuse existing helpers (`parseBool`, `DefaultMap`, media factories) instead of
reinventing logic
- Stick to NestJS patterns: inject dependencies through constructors, expose
provider tokens from modules, and keep controllers free from business logic.
provider tokens from modules
- Logging goes through injected `PinoLogger` or helpers in
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- 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
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
`src/utils/logging.ts`
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
- Prefer named function declarations over `const` arrow functions
- Avoid naming unused variables with a leading underscore
- Always use explicit property names in object literals — never shorthand: write
`{ key: value }`, not `{ value }` (even when the variable name matches the
key)
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
coalescing)
- Do not place `await` or other async calls inside ternary expressions (`?:`) or
nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign
the awaited value to a variable first
- For configs, prefer runtime configurability over constants (environment keys
follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`)
- Do not use decorative comment blocks (lines of dashes/underscores with a
label) such as `// ─────────── NAME ───────────`; use plain inline comments or
no comment at all
## Language & Localization
- Keep identifiers (classes, methods, variables) and code comments in English so
the codebase stays consistent for the global team.
- Route user-visible strings through the existing i18n structure rather than
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
English as the source locale before adding translations.
## ChatWoot Integration Notes
- Avoid introducing synthetic events; map onto existing webhook or engine events
whenever plausible. Always use the official ChatWoot API client located in
`src/apps/chatwoot/client`.
- When adding events, align consumer names with webhook/event identifiers (e.g.,
`message.any`).
## Workflow Checklist
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
isolate changes to `src/plus` and ensure the commit prefix matches.
2. Lean on existing services/managers; extend the appropriate session manager
rather than branching logic inline.
3. After edits run:
- Do **not** run `pre-commit run --all-files` unless the user explicitly
asks.
- `yarn build`
- `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
touching configs, introducing dependencies, or modifying public APIs.
## Additional Tips
- Concurrency-sensitive sections (session start/stop) rely on `async-lock`. When
adding async flows, reuse `SessionManager.withLock` or existing retry
utilities (`promiseTimeout`, `waitUntil`).
- Media features centralize through `MediaManager` and `MediaStorageFactory`.
When altering media behavior, update both Core and Plus variants where
applicable.
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## 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 and read related source code in the following paths:
- WEBJS: `../whatsapp-web.js`
- NOWEB: `../WhiskeySockets-Baileys`
- 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.
## How to Run API
```bash
export DEBUG=1
@@ -194,17 +83,49 @@ 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 Guidelines
## Code
- 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`
### @Activity Decorator and Presence Tracking
## MCP Tools
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`.
MCP tools live in `src/apps/mcp/tools/` and expose the HTTP API to AI clients.
Each tool file mirrors an API domain (e.g. `chats.tools.ts` → chats endpoints).
**When you change an existing API endpoint:**
- Check the corresponding `*.tools.ts` file and update the tool's `inputSchema`,
description, or behavior if the API signature changed.
**When you add a new API endpoint:**
- Ask the user whether an MCP tool is needed for the new endpoint before
creating one.
- If yes, add the tool to the matching `*.tools.ts` file (or create a new file
for a new domain).
- Every `@Tool` decorator must include an `annotations` block with all three
fields:
```typescript
annotations: {
readOnlyHint: true | false, // true = no side effects (GET-style)
destructiveHint: true | false, // true = irreversible deletion/logout
idempotentHint: true | false, // true = safe to repeat with same args
}
```
- Input schemas live in the matching `*.zod.ts` file.
- Tools call the API via `this.textRequest({ method, url, ... })` inherited from
`McpController`.
## Related Sources
- WEBJS: `../whatsapp-web.js`
- NOWEB: `../WhiskeySockets-Baileys` and `../whatsapp-rust-bridge`
- GOWS: `../gows` and `../whatsmeow`
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
- ChatWoot: `../chatwoot`
-1
View File
@@ -1 +0,0 @@
- Read `AGENTS.md` for more instructions
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+18 -3
View File
@@ -16,10 +16,10 @@ RUN apt-get update && \
WORKDIR /git
COPY package.json .
COPY yarn.lock .
COPY .yarnrc.yml .
ENV YARN_CHECKSUM_BEHAVIOR=update
RUN npm install -g corepack && corepack enable
RUN yarn set version 4.9.2
RUN yarn install
# App
@@ -28,6 +28,21 @@ ADD . /git
RUN yarn install
RUN yarn build && find ./dist -name "*.d.ts" -delete
# Rebuild sharp from source on x86-64 so it runs on pre-v2 CPUs (no SSE4.2 requirement).
# The prebuilt @img/sharp-linux-x64 binary requires x86-64-v2; -march=x86-64 limits
# code generation to baseline x86-64 (SSE2 only). ARM64 prebuilts have no such
# constraint, so no rebuild is needed there.
# We call sharp's own install/build.js (not npm rebuild) so it first generates config.gypi
# with include/lib paths pointing to its bundled @img/sharp-libvips-linux-x64 package,
# then invokes node-gyp with those paths. Without this step node-gyp cannot find vips headers.
RUN if [ "$(uname -m)" = "x86_64" ]; then \
LIBVIPS_DEV_VER=$(node -e "require('/git/node_modules/sharp/package.json').devDependencies['@img/sharp-libvips-dev']" | tr -d '^') && \
npm install --prefix /tmp/sharp-dev --no-package-lock "@img/sharp-libvips-dev@${LIBVIPS_DEV_VER}" && \
cp -r /tmp/sharp-dev/node_modules/@img/sharp-libvips-dev /git/node_modules/@img/ && \
cd /git/node_modules/sharp && \
PATH="/git/node_modules/.bin:$PATH" CFLAGS="-march=x86-64" CXXFLAGS="-march=x86-64" node install/build.js; \
fi
#
# Dashboard
#
@@ -164,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Install Chrome
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="140.0.7339.80-1"
ARG CHROME_VERSION="140.0.7339.207-1"
ARG OPUSTAGS_VERSION="1.10.1"
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
@@ -232,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
ENV WAHA_ZIPPER=ZIPUNZIP
# GOWS - use libc DNS resolver
ENV GODEBUG netdns=cgo
ENV GODEBUG=netdns=cgo
# Run command, etc
EXPOSE 3000
+19 -4
View File
@@ -35,16 +35,17 @@ 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-02-11
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-04-28
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-18
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
up-wpp:
yarn up @wppconnect-team/wppconnect
yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
@@ -57,9 +58,23 @@ proto-gows:
gows:
cd ../gows && \
export PATH=${HOME}/go/bin:${PATH} && \
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
make all
ORIGIN ?= waha-plus
CORE_REMOTE ?= waha
release:
node scripts/release.js
release-push: release
git push $(ORIGIN) core plus
git push --force-with-lease $(ORIGIN) dev
git push $(CORE_REMOTE) core
up-dashboard:
node scripts/up-dashboard.js
copy-dashboard:
cd ../waha-hub/ui && \
make copy-waha
+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
+7 -6
View File
@@ -3,13 +3,14 @@
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
"assets": [
"dashboard/**",
"core/engines/webjs/*",
"plus/engines/webjs/*",
"apps/chatwoot/i18n/locales/*.yaml",
"apps/chatwoot/i18n/locales/*.yml"
{ "include": "dashboard/**", "watchAssets": false },
{ "include": "core/engines/webjs/*", "watchAssets": true },
{ "include": "plus/engines/webjs/*", "watchAssets": true },
{ "include": "apps/chatwoot/i18n/locales/*.yaml", "watchAssets": true },
{ "include": "apps/chatwoot/i18n/locales/*.yml", "watchAssets": true }
],
"watchAssets": true
"watchAssets": false
}
}
+12 -5
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-02-11",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-04-28",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
@@ -39,6 +39,9 @@
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@lottiefiles/dotlottie-web": "^0.72.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "^1.0.0",
"@nestjs/axios": "^3.0.2",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.0",
@@ -57,7 +60,8 @@
"@types/passport": "^1.0.17",
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^1.41.1",
"@wppconnect-team/wppconnect": "github:wppconnect-team/wppconnect#master",
"@wppconnect/wa-js": "github:wppconnect-team/wa-js#main",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
@@ -92,9 +96,11 @@
"mustache": "^4.2.0",
"nestjs-pino": "^4.1.0",
"node-cache": "5.1.2",
"node-webpmux": "^3.2.1",
"passport": "^0.7.0",
"passport-headerapikey": "^1.2.2",
"pg": "^8.13.1",
"pg-copy-streams": "^6.0.0",
"pino-http": "^10.2.0",
"pino-pretty": "^11.2.1",
"pretty-bytes": "5.6.0",
@@ -113,9 +119,10 @@
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
"yaml": "^2.7.1",
"zod": "^4.3.6"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
@@ -198,5 +205,5 @@
}
]
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.17.1"
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
'use strict';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { execFileSync } = require('child_process');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readline = require('readline');
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
const CORE_PREFIX = '[core]';
const PLUS_PREFIX = '[PLUS]';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const assumeYes = args.includes('--yes') || args.includes('-y');
function git(gitArgs, options) {
const opts = options || {};
return execFileSync('git', gitArgs, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
}
function gitOut(gitArgs) {
return git(gitArgs, { inherit: false }).trim();
}
function log(message) {
console.log(message);
}
function fail(message) {
console.error(`\n✗ ${message}`);
process.exit(1);
}
function ensureCleanTree() {
const status = gitOut(['status', '--porcelain']);
if (status) {
fail(
'Working tree is not clean. Commit or stash your changes before releasing.',
);
}
}
function ensureBranchExists(branch) {
try {
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
} catch {
fail(`Branch "${branch}" does not exist locally.`);
}
}
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
// plus every release, so `plus..dev` is exactly the new, unreleased work.
// We intentionally do not use `git cherry` (patch-id matching) because core is
// a rewritten history whose old commits share no patch-ids with dev.
function commitsToCherryPick(boundary, head, prefix) {
const format = '%H%x09%s';
const output = gitOut([
'rev-list',
'--reverse',
'--no-merges',
`--format=${format}`,
`${boundary}..${head}`,
]);
if (!output) {
return [];
}
const picks = [];
for (const line of output.split('\n')) {
// rev-list --format prefixes each entry with a "commit <sha>" header line.
if (!line || line.startsWith('commit ')) {
continue;
}
const tab = line.indexOf('\t');
const sha = line.slice(0, tab);
const subject = line.slice(tab + 1);
if (subject.startsWith(prefix)) {
picks.push({ sha: sha, subject: subject });
}
}
return picks;
}
function cherryPickAll(picks) {
for (const pick of picks) {
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
if (dryRun) {
continue;
}
try {
git(['cherry-pick', pick.sha], { inherit: true });
} catch {
git(['cherry-pick', '--abort'], { inherit: true });
fail(
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
`Aborted the cherry-pick — resolve manually and re-run.`,
);
}
}
}
function checkout(branch) {
log(`\n→ checkout ${branch}`);
if (!dryRun) {
git(['checkout', branch], { inherit: true });
}
}
function confirm(question) {
if (assumeYes || dryRun) {
return Promise.resolve(true);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(function resolver(resolve) {
rl.question(`${question} [y/N] `, function onAnswer(answer) {
rl.close();
resolve(/^y(es)?$/i.test(answer.trim()));
});
});
}
async function main() {
ensureCleanTree();
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
if (dryRun) {
log('Running in --dry-run mode: no branches will be modified.\n');
}
// Capture the last-released plus tip before we touch anything. Step 3 merges
// core into plus and moves the branch, so both pick sets must be computed
// against this saved boundary, not the live plus ref.
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
log(
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
// Step 1 + 2: bring missing [core] commits onto core.
checkout(CORE_BRANCH);
cherryPickAll(corePicks);
// Step 3: merge the freshly updated core into plus.
checkout(PLUS_BRANCH);
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
if (!dryRun) {
try {
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
} catch {
git(['merge', '--abort'], { inherit: true });
fail(
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the merge — resolve manually and re-run.`,
);
}
}
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
log(
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
cherryPickAll(plusPicks);
// Step 5: rebase dev onto the freshly built plus.
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
const proceed = await confirm(
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
);
if (!proceed) {
fail('Aborted before rebasing dev. core/plus changes are kept.');
}
checkout(DEV_BRANCH);
if (!dryRun) {
try {
git(['rebase', PLUS_BRANCH], { inherit: true });
} catch {
git(['rebase', '--abort'], { inherit: true });
fail(
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the rebase — resolve manually and re-run.`,
);
}
}
if (dryRun) {
checkout(startBranch);
log('\nDry run complete. Re-run without --dry-run to apply.');
} else {
log(
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
);
log(
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
);
}
}
main().catch(function onError(error) {
fail(error.message || String(error));
});
+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}`);
+35 -88
View File
@@ -3,24 +3,25 @@ import {
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CanServer, CanSession, FromBody } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
import {
ApiKeyDTO,
ApiKeyRequest,
ScopedApiKeyRequest,
} from '@waha/structures/apikeys.dto';
@ApiSecurity('api_key')
@Controller('api/keys')
@@ -30,57 +31,41 @@ import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
export class ApiKeysController {
constructor(private manager: SessionManager) {}
private get service(): ApiKeyService {
return new ApiKeyService(this.manager);
}
@Post('/')
@ApiOperation({ summary: 'Create a new API key' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${body.session}" does not exist`,
);
}
}
let apikey: ApiKey | null = null;
// Try 5 times to check there's no conflict on id and key
for (let i = 0; i < 5; i++) {
apikey = {
id: generatePrefixedId('key_id'),
key: `key_${generateSecret(32)}`,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
rules: null,
};
const idExists = await this.manager.apiKeyRepository.getById(apikey.id);
if (idExists) {
continue;
}
const keyExists = await this.manager.apiKeyRepository.getByKey(
apikey.key,
);
if (keyExists) {
continue;
}
break;
}
if (!apikey) {
throw new UnprocessableEntityException(
`Failed to generate API key, try again`,
);
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
return this.service.create(body);
}
@Get('/')
@ApiOperation({ summary: 'Get all API keys' })
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.manager.apiKeyRepository.list();
return keys.map((key) => ApiKeyToDTO(key));
return this.service.list();
}
@Post('/media')
@ApiOperation({
summary: 'Create or get a media-download-only API key for a session',
})
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetMediaKey(body.session);
}
@Post('/control')
@ApiOperation({
summary: 'Create or get a control-only API key for a session',
})
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetControlKey(body.session);
}
@Put('/:id')
@@ -90,50 +75,12 @@ export class ApiKeysController {
@Param('id') id: string,
@Body() body: ApiKeyRequest,
): Promise<ApiKeyDTO> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
};
CheckInvariant(apikey);
if (apikey.session) {
const exists = await this.manager.exists(apikey.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${apikey.session}" does not exist`,
);
}
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
return this.service.update(id, body);
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an API key' })
async delete(@Param('id') id: string): Promise<{ result: true }> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
await this.manager.apiKeyRepository.deleteById(id);
return { result: true };
return this.service.delete(id);
}
}
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
key: apikey.key,
isActive: apikey.isActive,
isAdmin: apikey.isAdmin,
session: apikey.session,
};
}
+58 -2
View File
@@ -7,7 +7,12 @@ import {
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import {
QRCodeSessionParam,
@@ -19,6 +24,9 @@ import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import {
PasskeyAssertionRequest,
PasskeyChallenge,
PasskeyConfirmationResponse,
QRCodeFormat,
QRCodeQuery,
QRCodeValue,
@@ -35,7 +43,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/auth')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
class AuthController {
constructor(private manager: SessionManager) {}
@@ -68,6 +76,54 @@ class AuthController {
) {
return session.requestCode(request.phoneNumber, request.method, request);
}
@Get('passkey/challenge')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey (WebAuthn) challenge.',
description:
'Available while the session is in PASSKEY_REQUIRED status. ' +
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
'on the https://web.whatsapp.com origin.',
})
@ApiOkResponse({ type: PasskeyChallenge })
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
return session.getPasskeyChallenge();
}
@Post('passkey')
@SessionApiParam
@ApiOperation({
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
})
submitPasskey(
@SessionParam session: WhatsappSession,
@Body() request: PasskeyAssertionRequest,
) {
return session.sendPasskeyResponse(JSON.stringify(request));
}
@Get('passkey/confirmation')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey confirmation code.',
description:
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
})
@ApiOkResponse({ type: PasskeyConfirmationResponse })
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
return session.getPasskeyConfirmation();
}
@Post('passkey/confirm')
@SessionApiParam
@ApiOperation({
summary: 'Confirm passkey pairing (only needed for the manual code case).',
})
confirmPasskey(@SessionParam session: WhatsappSession) {
return session.confirmPasskey();
}
}
export { AuthController };
+1 -1
View File
@@ -25,7 +25,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
export class CallsController {
constructor(private manager: SessionManager) {}
+14 -1
View File
@@ -50,7 +50,6 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ChannelsController {
constructor(
private manager: SessionManager,
@@ -59,6 +58,7 @@ export class ChannelsController {
@Get('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get list of know channels' })
async list(
@WorkingSessionParam session: WhatsappSession,
@@ -69,6 +69,7 @@ export class ChannelsController {
@Post('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Create a new channel.' })
create(
@WorkingSessionParam session: WhatsappSession,
@@ -80,6 +81,7 @@ export class ChannelsController {
@Delete(':id')
@SessionApiParam
@NewsletterIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Delete the channel.' })
delete(
@WorkingSessionParam session: WhatsappSession,
@@ -91,6 +93,7 @@ export class ChannelsController {
@Get(':id')
@SessionApiParam
@NewsletterIdOrInviteCodeApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get the channel info',
description:
@@ -111,6 +114,7 @@ export class ChannelsController {
@Get(':id/messages/preview')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
@ApiParam({
name: 'id',
@@ -146,6 +150,7 @@ export class ChannelsController {
@Post(':id/follow')
@SessionApiParam
@NewsletterIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Follow the channel.' })
follow(
@WorkingSessionParam session: WhatsappSession,
@@ -157,6 +162,7 @@ export class ChannelsController {
@Post(':id/unfollow')
@SessionApiParam
@NewsletterIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Unfollow the channel.' })
unfollow(
@WorkingSessionParam session: WhatsappSession,
@@ -168,6 +174,7 @@ export class ChannelsController {
@Post(':id/mute')
@SessionApiParam
@NewsletterIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Mute the channel.' })
mute(
@WorkingSessionParam session: WhatsappSession,
@@ -179,6 +186,7 @@ export class ChannelsController {
@Post(':id/unmute')
@SessionApiParam
@NewsletterIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Unmute the channel.' })
unmute(
@WorkingSessionParam session: WhatsappSession,
@@ -190,6 +198,7 @@ export class ChannelsController {
@Post('/search/by-view')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Search for channels (by view)' })
async searchByView(
@@ -202,6 +211,7 @@ export class ChannelsController {
@Post('/search/by-text')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Search for channels (by text)' })
async searchByText(
@@ -213,6 +223,7 @@ export class ChannelsController {
@Get('/search/views')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get list of views for channel search' })
getSearchViews(): Promise<ChannelView[]> {
return this.channelsInfoService.getViews();
@@ -220,6 +231,7 @@ export class ChannelsController {
@Get('/search/countries')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get list of countries for channel search' })
getSearchCountries(): Promise<ChannelCountry[]> {
return this.channelsInfoService.getCountries();
@@ -227,6 +239,7 @@ export class ChannelsController {
@Get('/search/categories')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get list of categories for channel search' })
getSearchCategories(): Promise<ChannelCategory[]> {
return this.channelsInfoService.getCategories();
+16 -1
View File
@@ -52,12 +52,12 @@ import { Action } from '@waha/core/auth/casl.types';
@ApiTags('💬 Chats')
@UsePipes(new ValidationPipe({ transform: true }))
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class ChatsController {
constructor(private manager: SessionManager) {}
@Get('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get chats' })
getChats(
@WorkingSessionParam session: WhatsappSession,
@@ -68,6 +68,7 @@ class ChatsController {
@Get('overview')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary:
'Get chats overview. Includes all necessary things to build UI "your chats overview" page - chat id, name, picture, last message. Sorting by last message timestamp',
@@ -83,6 +84,7 @@ class ChatsController {
@Post('overview')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary:
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
@@ -97,6 +99,7 @@ class ChatsController {
@Delete(':chatId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Deletes the chat' })
@ChatIdApiParam
deleteChat(
@@ -108,6 +111,7 @@ class ChatsController {
@Get(':chatId/picture')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Gets chat picture' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getChatPicture(
@@ -121,6 +125,7 @@ class ChatsController {
@Get(':chatId/messages')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Gets messages in the chat' })
@ChatIdApiParam
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@@ -141,6 +146,7 @@ class ChatsController {
@Post(':chatId/messages/read')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Read unread messages in the chat' })
@ChatIdApiParam
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@@ -154,6 +160,7 @@ class ChatsController {
@Get(':chatId/messages/:messageId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Gets message by id' })
@ChatIdApiParam
async getChatMessage(
@@ -171,6 +178,7 @@ class ChatsController {
@Post(':chatId/messages/:messageId/pin')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Pins a message in the chat' })
@ChatIdApiParam
async pinMessage(
@@ -185,6 +193,7 @@ class ChatsController {
@Post(':chatId/messages/:messageId/unpin')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Unpins a message in the chat' })
@ChatIdApiParam
async unpinMessage(
@@ -198,6 +207,7 @@ class ChatsController {
@Delete(':chatId/messages')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Clears all messages from the chat' })
@ChatIdApiParam
clearMessages(
@@ -209,6 +219,7 @@ class ChatsController {
@Delete(':chatId/messages/:messageId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ChatIdApiParam
@MessageIdApiParam
@ApiOperation({ summary: 'Deletes a message from the chat' })
@@ -222,6 +233,7 @@ class ChatsController {
@Put(':chatId/messages/:messageId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ChatIdApiParam
@MessageIdApiParam
@ApiOperation({ summary: 'Edits a message in the chat' })
@@ -236,6 +248,7 @@ class ChatsController {
@Post(':chatId/archive')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ChatIdApiParam
@ApiOperation({ summary: 'Archive the chat' })
archiveChat(
@@ -247,6 +260,7 @@ class ChatsController {
@Post(':chatId/unarchive')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ChatIdApiParam
@ApiOperation({ summary: 'Unarchive the chat' })
unarchiveChat(
@@ -258,6 +272,7 @@ class ChatsController {
@Post(':chatId/unread')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ChatIdApiParam
@ApiOperation({ summary: 'Unread the chat' })
unreadChat(
+47 -25
View File
@@ -41,6 +41,7 @@ import {
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
NewMessageIDResponse,
SendSeenRequest,
WANumberExistResult,
} from '../structures/chatting.dto';
@@ -49,9 +50,19 @@ import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import {
CanSession,
FromBody,
FromParam,
FromQuery,
} from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@@ -64,7 +75,7 @@ export class ChattingController {
@Post('/sendText')
@ApiOperation({ summary: 'Send a text message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -80,7 +91,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -96,7 +107,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -112,7 +123,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendVoice(@Body() request: MessageVoiceRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendVoice(request);
@@ -124,7 +135,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -140,7 +151,7 @@ export class ChattingController {
description:
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendLinkCustomPreview(
@Body() request: MessageLinkCustomPreviewRequest,
@@ -160,7 +171,7 @@ export class ChattingController {
description: 'Send Buttons',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendButtons(@Body() request: SendButtonsRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -172,7 +183,7 @@ export class ChattingController {
summary: 'Send a list message (interactive)',
description: 'Send a List message with sections and rows',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendList(@Body() request: SendListRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -180,7 +191,7 @@ export class ChattingController {
}
@Post('/forwardMessage')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async forwardMessage(
@Body() request: MessageForwardRequest,
): Promise<WAMessage> {
@@ -189,7 +200,7 @@ export class ChattingController {
}
@Post('/sendSeen')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
@@ -205,7 +216,7 @@ export class ChattingController {
}
@Post('/startTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async startTyping(@Body() chat: ChatRequest) {
// It's infinitive action
const whatsapp = await this.manager.getWorkingSession(chat.session);
@@ -214,7 +225,7 @@ export class ChattingController {
}
@Post('/stopTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async stopTyping(@Body() chat: ChatRequest) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
await whatsapp.stopTyping(chat);
@@ -223,7 +234,7 @@ export class ChattingController {
@Put('/reaction')
@ApiOperation({ summary: 'React to a message with an emoji' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async setReaction(@Body() request: MessageReactionRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.setReaction(request);
@@ -231,7 +242,7 @@ export class ChattingController {
@Put('/star')
@ApiOperation({ summary: 'Star or unstar a message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async setStar(@Body() request: MessageStarRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
await whatsapp.setStar(request);
@@ -243,7 +254,7 @@ export class ChattingController {
summary: 'Send a poll with options',
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
@@ -254,7 +265,7 @@ export class ChattingController {
summary: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPollVote(@Body() request: MessagePollVoteRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -262,14 +273,14 @@ export class ChattingController {
}
@Post('/sendLocation')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendLocation(@Body() request: MessageLocationRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLocation(request);
}
@Post('/sendContactVcard')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendContactVCard(request);
@@ -279,7 +290,7 @@ export class ChattingController {
@ApiOperation({
summary: 'Reply on a button message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendButtonsReply(@Body() request: MessageButtonReply) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -288,7 +299,7 @@ export class ChattingController {
@Get('/sendText')
@ApiOperation({ summary: 'Send a text message', deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Send, FromQuery('session')))
async sendTextGet(@Query() query: MessageTextQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
const msg = new MessageTextRequest();
@@ -303,7 +314,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Query() query: GetMessageQuery,
@@ -320,7 +331,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
async DEPRECATED_checkNumberStatus(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -334,7 +345,7 @@ export class ChattingController {
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async reply(@Body() request: MessageReplyRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.reply(request);
@@ -342,9 +353,20 @@ export class ChattingController {
@Post('/sendLinkPreview')
@ApiOperation({ deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLinkPreview(request);
}
@Get('/:session/new-message-id')
@SessionApiParam
@ApiOperation({ summary: 'Generate a new message ID' })
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
async getNewMessageId(
@WorkingSessionParam session: WhatsappSession,
): Promise<NewMessageIDResponse> {
const id = await session.generateNewMessageId();
return { id: id };
}
}
+7 -7
View File
@@ -37,7 +37,7 @@ export class ContactsController {
@Get('/all')
@ApiOperation({ summary: 'Get all contacts' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@Query() query: SessionQuery,
@@ -53,7 +53,7 @@ export class ContactsController {
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
async get(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContact(query);
@@ -61,7 +61,7 @@ export class ContactsController {
@Get('/check-exists')
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
async checkExists(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -75,7 +75,7 @@ export class ContactsController {
description:
'Returns null if you do not have permission to read their status.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
async getAbout(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContactAbout(query);
@@ -87,7 +87,7 @@ export class ContactsController {
description:
'If privacy settings do not allow to get the picture, the method will return null.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
@@ -100,7 +100,7 @@ export class ContactsController {
@Post('/block')
@ApiOperation({ summary: 'Block contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async block(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.blockContact(request);
@@ -108,7 +108,7 @@ export class ContactsController {
@Post('/unblock')
@ApiOperation({ summary: 'Unblock contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async unblock(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.unblockContact(request);
+2 -1
View File
@@ -28,12 +28,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
@Get('/:id')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiParam({
name: 'id',
required: true,
@@ -56,6 +56,7 @@ export class ContactsSessionController {
@Put('/:chatId')
@SessionApiParam
@ChatIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Create or update contact',
description:
+1 -1
View File
@@ -30,7 +30,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/events')
@ApiTags('📅 Events')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
export class EventsController {
constructor(private manager: SessionManager) {}
+60 -1
View File
@@ -38,6 +38,7 @@ import {
JoinGroupRequest,
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -51,12 +52,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/groups')
@ApiTags('👥 Groups')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class GroupsController {
constructor(private manager: SessionManager) {}
@Post('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Create a new group.' })
createGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -67,6 +68,7 @@ export class GroupsController {
@Get('join-info')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get info about the group before joining.' })
async joinInfoGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -79,6 +81,7 @@ export class GroupsController {
@Post('join')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Join group via code' })
async joinGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -91,6 +94,7 @@ export class GroupsController {
@Get('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get all groups.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getGroups(
@@ -105,6 +109,7 @@ export class GroupsController {
@Get('/count')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get the number of groups.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getGroupsCount(
@@ -120,6 +125,7 @@ export class GroupsController {
@Post('refresh')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Refresh groups from the server.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async refreshGroups(@WorkingSessionParam session: WhatsappSession) {
@@ -129,6 +135,7 @@ export class GroupsController {
@Get(':id')
@GroupIdApiParam
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get the group.' })
getGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -140,6 +147,7 @@ export class GroupsController {
@Delete(':id')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Delete the group.' })
deleteGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -152,6 +160,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Leave the group.' })
leaveGroup(
@WorkingSessionParam session: WhatsappSession,
@@ -163,6 +172,7 @@ export class GroupsController {
@Get(':id/picture')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get group picture' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getChatPicture(
@@ -177,6 +187,7 @@ export class GroupsController {
@Put(':id/picture')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Set group picture' })
async setPicture(
@Param('id') id: string,
@@ -190,6 +201,7 @@ export class GroupsController {
@Delete(':id/picture')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Delete group picture' })
async deletePicture(
@Param('id') id: string,
@@ -207,6 +219,7 @@ export class GroupsController {
})
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
setDescription(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@@ -218,6 +231,7 @@ export class GroupsController {
@Put(':id/subject')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Updates the group subject',
description:
@@ -234,6 +248,7 @@ export class GroupsController {
@Put(':id/settings/security/info-admin-only')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Updates the group "info admin only" settings.',
description:
@@ -250,6 +265,7 @@ export class GroupsController {
@Get(':id/settings/security/info-admin-only')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: "Get the group's 'info admin only' settings.",
description:
@@ -265,6 +281,7 @@ export class GroupsController {
@Put(':id/settings/security/messages-admin-only')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - who can send messages',
description:
@@ -281,6 +298,7 @@ export class GroupsController {
@Get(':id/settings/security/messages-admin-only')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - who can send messages',
description: 'The group settings to only allow admins to send messages.',
@@ -292,9 +310,43 @@ export class GroupsController {
return session.getMessagesAdminsOnly(id);
}
@Put(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - who can add new members',
description:
'Updates the group settings for who can add new members to the group - all members or admins only.',
})
setMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberAddMode,
) {
return session.setMemberAddMode(id, request.membersCanAddNewMember);
}
@Get(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - who can add new members',
description:
'The group settings for who can add new members to the group - all members or admins only.',
})
getMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberAddMode> {
return session.getMemberAddMode(id);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Gets the invite code for the group.' })
getInviteCode(
@WorkingSessionParam session: WhatsappSession,
@@ -307,6 +359,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary:
'Invalidates the current group invite code and generates a new one.',
@@ -321,6 +374,7 @@ export class GroupsController {
@Get(':id/participants/')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get participants' })
getParticipants(
@WorkingSessionParam session: WhatsappSession,
@@ -332,6 +386,7 @@ export class GroupsController {
@Get(':id/participants/v2')
@GroupIdApiParam
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get group participants.' })
getGroupParticipants(
@WorkingSessionParam session: WhatsappSession,
@@ -344,6 +399,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Add participants' })
addParticipants(
@WorkingSessionParam session: WhatsappSession,
@@ -357,6 +413,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Remove participants',
})
@@ -372,6 +429,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Promote participants to admin users.' })
promoteToAdmin(
@WorkingSessionParam session: WhatsappSession,
@@ -385,6 +443,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Demotes participants to regular users.' })
demoteToAdmin(
@WorkingSessionParam session: WhatsappSession,
+7 -1
View File
@@ -37,12 +37,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/labels')
@ApiTags('🏷️ Labels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LabelsController {
constructor(private manager: SessionManager) {}
@Get('/')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get all labels' })
getAll(@WorkingSessionParam session: WhatsappSession): Promise<Label[]> {
return session.getLabels();
@@ -50,6 +50,7 @@ export class LabelsController {
@Post('/')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Create a new label' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async create(
@@ -75,6 +76,7 @@ export class LabelsController {
@Put('/:labelId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Update a label' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async update(
@@ -106,6 +108,7 @@ export class LabelsController {
@Delete('/:labelId')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Delete a label' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async delete(
@@ -123,6 +126,7 @@ export class LabelsController {
@Get('/chats/:chatId')
@SessionApiParam
@ChatIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get labels for the chat' })
getChatLabels(
@WorkingSessionParam session: WhatsappSession,
@@ -134,6 +138,7 @@ export class LabelsController {
@Put('/chats/:chatId')
@SessionApiParam
@ChatIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Save labels for the chat' })
putChatLabels(
@WorkingSessionParam session: WhatsappSession,
@@ -145,6 +150,7 @@ export class LabelsController {
@Get('/:labelId/chats')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get chats by label' })
getChatsByLabel(
@WorkingSessionParam session: WhatsappSession,
+1 -1
View File
@@ -33,7 +33,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
export class LidsController {
constructor(private manager: SessionManager) {}
+1 -1
View File
@@ -32,7 +32,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
class MediaController {
constructor(private manager: SessionManager) {}
+4 -1
View File
@@ -31,12 +31,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/presence')
@ApiTags('✅ Presence')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class PresenceController {
constructor(private manager: SessionManager) {}
@Post('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Set session presence' })
setPresence(
@WorkingSessionParam session: WhatsappSession,
@@ -67,6 +67,7 @@ export class PresenceController {
@Get('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get all subscribed presence information.' })
getPresenceAll(
@WorkingSessionParam session: WhatsappSession,
@@ -77,6 +78,7 @@ export class PresenceController {
@Get(':chatId')
@SessionApiParam
@ChatIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary:
"Get the presence for the chat id. If it hasn't been subscribed - it also subscribes to it.",
@@ -91,6 +93,7 @@ export class PresenceController {
@Post(':chatId/subscribe')
@SessionApiParam
@ChatIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Subscribe to presence events for the chat.',
})
+5 -1
View File
@@ -33,12 +33,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ProfileController {
constructor(private manager: SessionManager) {}
@Get('')
@SessionApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({ summary: 'Get my profile' })
async getMyProfile(
@WorkingSessionParam session: WhatsappSession,
@@ -57,6 +57,7 @@ export class ProfileController {
@Put('/name')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Set my profile name' })
async setProfileName(
@@ -69,6 +70,7 @@ export class ProfileController {
@Put('/status')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Set profile status (About)' })
async setProfileStatus(
@@ -81,6 +83,7 @@ export class ProfileController {
@Put('/picture')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Set profile picture' })
async setProfilePicture(
@WorkingSessionParam session: WhatsappSession,
@@ -92,6 +95,7 @@ export class ProfileController {
@Delete('/picture')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Delete profile picture' })
async deleteProfilePicture(
@WorkingSessionParam session: WhatsappSession,
+1 -1
View File
@@ -24,7 +24,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.Control, FromQuery('session')))
export class ScreenshotController {
constructor(private manager: SessionManager) {}
+2 -2
View File
@@ -42,7 +42,7 @@ export class ServerController {
@Get('version')
@ApiOperation({ summary: 'Get the version of the server' })
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
get(): WAHAEnvironment {
return VERSION;
}
@@ -76,7 +76,7 @@ export class ServerController {
@Get('status')
@ApiOperation({ summary: 'Get the server status' })
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
async status(): Promise<ServerStatusResponse> {
const now = Date.now();
const uptime = Math.floor(process.uptime() * 1000);
+38 -165
View File
@@ -3,8 +3,6 @@ import {
Controller,
Delete,
Get,
Inject,
NotFoundException,
Param,
Post,
Put,
@@ -26,18 +24,12 @@ import {
SessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
AppsService,
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import {
SessionLogoutDeprecatedRequest,
SessionStartDeprecatedRequest,
SessionStopDeprecatedRequest,
} from '@waha/structures/sessions.deprecated.dto';
import { generatePrefixedId } from '@waha/utils/ids';
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
@@ -55,39 +47,29 @@ import { CheckPolicies } from '../core/auth/policies.decorator';
import { PoliciesGuard } from '../core/auth/policies.guard';
import { CanSession, FromBody, FromParam } from '../core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
import { SessionService } from '@waha/core/services/SessionService';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
@UseGuards(PoliciesGuard)
class SessionsController {
constructor(
private manager: SessionManager,
@Inject(AppsService) private appsService: IAppsService,
) {}
private withLock(name: string, fn: () => any) {
return this.manager.withLock(name, fn);
}
constructor(private readonly sessionService: SessionService) {}
@Get('/')
@ApiOperation({
summary: 'List all sessions',
})
@ApiOperation({ summary: 'List all sessions' })
@CheckPolicies(CanSession(Action.List))
@ApiOAuth2(['read:items'])
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
@Req() req,
): Promise<SessionInfo[]> {
let sessions = await this.manager.getSessions(query.all);
let sessions = await this.sessionService.getSessions(query.all);
if (!req.user?.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
sessions = FilterSessions(req.ability, Action.Retrieve, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
}
await this.sessionService.expandSessionApps(sessions);
}
return sessions;
}
@@ -95,18 +77,15 @@ class SessionsController {
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async get(
@Param('session') name: string,
@Query() query: SessionInfoQuery,
): Promise<SessionInfo> {
const session = await this.manager.getSessionInfo(name);
if (session === null) {
throw new NotFoundException('Session not found');
}
const session = await this.sessionService.getSession(name);
if (query.expand?.includes(SessionExpand.apps)) {
session.apps = await this.appsService.list(this.manager, name);
await this.sessionService.expandSessionApps([session]);
}
return session;
}
@@ -114,9 +93,9 @@ class SessionsController {
@Get(':session/me')
@SessionApiParam
@ApiOperation({ summary: 'Get information about the authenticated account' })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
return session.getSessionMeInfo();
return this.sessionService.getSessionMe(session);
}
@Post('')
@@ -129,71 +108,20 @@ class SessionsController {
@CheckPolicies(CanSession(Action.Create))
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
await this.withLock(name, async () => {
if (await this.manager.exists(name)) {
const msg = `Session '${name}' already exists. Use PUT to update it.`;
throw new UnprocessableEntityException(msg);
}
const config = request.config;
const start = request.start || false;
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (start) {
await this.manager.assign(name);
await this.manager.start(name);
}
});
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
return this.sessionService.createSession(request);
}
@Put(':session')
@ApiOperation({
summary: 'Update a session',
description: '',
})
@ApiOperation({ summary: 'Update a session' })
@SessionApiParam
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Setting, FromParam('session')))
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@Body() request: SessionUpdateRequest,
): Promise<SessionDTO> {
await this.withLock(name, async () => {
if (!(await this.manager.exists(name))) {
throw new NotFoundException('Session not found');
}
const config = request.config;
const isRunning = this.manager.isRunning(name);
await this.manager.stop(name, true);
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (isRunning) {
await this.manager.start(name);
}
});
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
return this.sessionService.updateSession(name, request);
}
@Delete(':session')
@@ -206,13 +134,7 @@ class SessionsController {
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('session') name: string): Promise<void> {
await this.withLock(name, async () => {
await this.manager.unassign(name);
await this.manager.unpair(name);
await this.manager.stop(name, true);
await this.manager.logout(name);
await this.manager.delete(name);
});
return this.sessionService.deleteSession(name);
}
@Post(':session/start')
@@ -222,18 +144,10 @@ class SessionsController {
description:
'Start the session with the given name. The session must exist. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async start(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
const exists = await this.manager.exists(name);
if (!exists) {
throw new NotFoundException('Session not found');
}
await this.manager.assign(name);
await this.manager.start(name);
});
return await this.manager.getSessionInfo(name);
return this.sessionService.startSession(name);
}
@Post(':session/stop')
@@ -242,14 +156,10 @@ class SessionsController {
summary: 'Stop the session',
description: 'Stop the session with the given name. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stop(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
await this.manager.unassign(name);
await this.manager.stop(name, false);
});
return await this.manager.getSessionInfo(name);
return this.sessionService.stopSession(name);
}
@Post(':session/logout')
@@ -258,23 +168,10 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
const exists = await this.manager.exists(name);
if (!exists) {
throw new NotFoundException('Session not found');
}
const isRunning = this.manager.isRunning(name);
await this.manager.unpair(name);
await this.manager.stop(name, true);
await this.manager.logout(name);
if (isRunning) {
await this.manager.start(name);
}
});
return await this.manager.getSessionInfo(name);
return this.sessionService.logoutSession(name);
}
@Post(':session/restart')
@@ -283,11 +180,10 @@ class SessionsController {
summary: 'Restart the session',
description: 'Restart the session with the given name.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async restart(@Param('session') name: string): Promise<SessionDTO> {
await this.manager.restart(name);
return await this.manager.getSessionInfo(name);
return this.sessionService.restartSession(name);
}
@Post('/start/')
@@ -297,25 +193,22 @@ class SessionsController {
'Create session (if not exists) or update a config (if exists) and start it.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
async DEPRACATED_start(
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
const name = request.name;
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
if (this.manager.isRunning(name)) {
const msg = `Session '${name}' is already started.`;
throw new UnprocessableEntityException(msg);
if (this.sessionService.isSessionRunning(request.name)) {
throw new UnprocessableEntityException(
`Session '${request.name}' is already started.`,
);
}
return await this.withLock(name, async () => {
const config = request.config;
await this.manager.upsert(name, config);
await this.manager.assign(name);
return await this.manager.start(name);
});
return this.sessionService.upsertAndStartSession(
request.name,
request.config,
);
}
@Post('/stop/')
@@ -324,30 +217,18 @@ class SessionsController {
description: 'Stop session and Logout by default.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
const name = request.name;
if (request.logout) {
// Old API did remove the session complete
await this.withLock(name, async () => {
await this.manager.unassign(name);
await this.manager.unpair(name);
await this.manager.stop(name, true);
await this.manager.logout(name);
await this.manager.delete(name);
});
await this.sessionService.deleteSession(request.name);
} else {
await this.withLock(name, async () => {
await this.manager.unassign(name);
await this.manager.stop(name, false);
});
await this.sessionService.stopSession(request.name);
}
return;
}
@Post('/logout/')
@@ -356,22 +237,14 @@ class SessionsController {
description: 'Stop, Logout and Delete session.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
if (!request.name) {
throw new UnprocessableEntityException('Session name is required');
}
const name = request.name;
await this.withLock(name, async () => {
await this.manager.unassign(name);
await this.manager.unpair(name);
await this.manager.stop(name, true);
await this.manager.logout(name);
await this.manager.delete(name);
});
return;
await this.sessionService.deleteSession(request.name);
}
}
+6 -1
View File
@@ -25,12 +25,12 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/:session/status')
@ApiTags('🟢 Status')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class StatusController {
constructor(private manager: SessionManager) {}
@Post('text')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Send text status' })
sendTextStatus(
@WorkingSessionParam session: WhatsappSession,
@@ -41,6 +41,7 @@ class StatusController {
@Post('image')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Send image status' })
sendImageStatus(
@WorkingSessionParam session: WhatsappSession,
@@ -51,6 +52,7 @@ class StatusController {
@Post('voice')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Send voice status' })
sendVoiceStatus(
@WorkingSessionParam session: WhatsappSession,
@@ -61,6 +63,7 @@ class StatusController {
@Post('video')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'Send video status' })
sendVideoStatus(
@WorkingSessionParam session: WhatsappSession,
@@ -71,6 +74,7 @@ class StatusController {
@Post('delete')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({ summary: 'DELETE sent status' })
deleteStatus(
@WorkingSessionParam session: WhatsappSession,
@@ -81,6 +85,7 @@ class StatusController {
@Get('new-message-id')
@SessionApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Generate message ID you can use to batch contacts',
})
+1 -1
View File
@@ -13,7 +13,7 @@ import { Action } from '@waha/core/auth/casl.types';
@Controller('api/version')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
export class VersionController {
@Get('')
@ApiOperation({
+2 -2
View File
@@ -86,12 +86,12 @@ export class WebsocketGatewayCore
const params = this.getParams(request);
let session: string = params.session;
const ability = this.casl.createForUser(user);
if (session == '*' && !ability.can(Action.Use, 'all')) {
if (session == '*' && !ability.can(Action.Manage, 'all')) {
// Limit user to listen only the session events
session = user.session;
}
if (!ability.can(Action.Use, new SessionName(session))) {
if (!ability.can(Action.Read, new SessionName(session))) {
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
return;
}
+15 -14
View File
@@ -31,6 +31,7 @@ import {
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { APPS } from '../apps/definition';
import { App } from '../dto/app.dto';
import { ListAppsQuery } from '../dto/query.dto';
@@ -47,7 +48,7 @@ export class AppsController {
@Get('/')
@ApiOperation({ summary: 'List all apps for a session' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@CheckPolicies(CanSession(Action.App, FromQuery('session')))
@UsePipes(new WAHAValidationPipe())
async list(
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
@@ -57,12 +58,12 @@ export class AppsController {
@Post('/')
@ApiOperation({ summary: 'Create a new app' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@CheckPolicies(CanSession(Action.App, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled) {
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
await this.manager.restart(app.session);
}
return result;
@@ -70,14 +71,14 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
if (!app) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
if (!req.ability?.can(Action.App, new SessionName(app.session))) {
throw new ForbiddenException();
}
return app;
@@ -85,7 +86,7 @@ export class AppsController {
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@@ -94,11 +95,11 @@ export class AppsController {
): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (existing) {
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
if (!req.ability?.can(Action.App, new SessionName(existing.session))) {
throw new ForbiddenException();
}
} else {
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
if (!req.ability?.can(Action.App, new SessionName(app.session))) {
throw new ForbiddenException();
}
}
@@ -112,28 +113,28 @@ export class AppsController {
}
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
const isRunning = this.manager.isRunning(result.session);
if (isRunning && APPS[result.app].restartOnChange) {
await this.manager.restart(result.session);
}
return result;
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@CheckPolicies(CanServer(Action.Read))
@CheckPolicies(CanServer(Action.Retrieve))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
if (!req.ability?.can(Action.App, new SessionName(existing.session))) {
throw new ForbiddenException();
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
if (isRunning && APPS[app.app].restartOnChange) {
await this.manager.restart(app.session);
}
}
+11
View File
@@ -9,6 +9,8 @@ export interface AppDefinition {
queue: boolean;
// If app has any migrations
migrations: boolean;
// If adding, updating, or removing this app requires a session restart
restartOnChange: boolean;
}
// All Apps
@@ -18,11 +20,20 @@ export const APPS: Record<AppName, AppDefinition> = {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
},
[AppName.mcp]: {
name: AppName.mcp,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
},
};
+1
View File
@@ -1,4 +1,5 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
}
+14 -3
View File
@@ -1,5 +1,6 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -11,9 +12,12 @@ import {
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
export type AllowedAppConfig =
| ChatWootAppConfig
| CallsAppConfig
| McpAppConfig;
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
export class App<T extends AllowedAppConfig = any> {
@IsString()
id: string;
@@ -43,6 +47,8 @@ export class App<T extends AllowedAppConfig = any> {
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
case AppName.mcp:
return McpAppConfig;
default:
return Object;
}
@@ -62,4 +68,9 @@ export class CallsAppDto extends App<CallsAppConfig> {
config: CallsAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto;
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
+26 -10
View File
@@ -9,6 +9,7 @@ import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -36,15 +37,18 @@ export class AppsEnabledService implements IAppsService {
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
@Optional() protected readonly mcpAppService: McpAppService,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
apps.forEach((app) => {
for (const app of apps) {
delete app.pk;
});
const service = this.getAppService(app);
await service?.enrich(manager, app);
}
return apps;
}
@@ -90,10 +94,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);
@@ -101,6 +105,9 @@ export class AppsEnabledService implements IAppsService {
const result = await repo.save(app);
delete result.pk;
if (app.enabled !== false) {
await service?.afterCreated(manager, result);
}
return result;
}
@@ -112,6 +119,8 @@ export class AppsEnabledService implements IAppsService {
return null;
}
delete (app as any).pk;
const service = this.getAppService(app);
await service?.enrich(manager, app);
return app;
}
@@ -147,21 +156,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(manager, savedApp, app);
} else {
await service.beforeDisabled(savedApp, app);
await service?.beforeDisabled(manager, savedApp, app);
}
} else {
await service.beforeUpdated(savedApp, app);
await service?.beforeUpdated(manager, savedApp, app);
}
await repo.update(app.id, app);
const updated = await repo.getById(app.id);
@@ -177,7 +186,7 @@ export class AppsEnabledService implements IAppsService {
throw new NotFoundException(`App '${appId}' not found`);
}
const service = this.getAppService(app);
await service?.beforeDeleted(app);
await service?.beforeDeleted(manager, app);
await repo.delete(app.id);
delete app.pk;
return app;
@@ -186,6 +195,11 @@ export class AppsEnabledService implements IAppsService {
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
for (const app of apps) {
const service = this.getAppService(app);
await service?.beforeSessionDeleted(manager, app);
}
await repo.deleteBySession(session);
}
@@ -257,6 +271,8 @@ export class AppsEnabledService implements IAppsService {
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
case AppName.mcp:
return this.mcpAppService;
default:
throw new Error(`App '${app.app}' not supported`);
}
+35 -4
View File
@@ -1,4 +1,5 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
/**
@@ -9,19 +10,49 @@ export interface IAppService {
beforeCreated(app: App): Promise<void>;
/**
* Called after the app record is saved to the database during creation.
* Use this for side effects that must happen after the app exists in storage.
*/
afterCreated(manager: SessionManager, app: App): Promise<void>;
/**
* Called only when the app transitions from disabled -> enabled.
*/
beforeEnabled(savedApp: App, newApp: App): Promise<void>;
beforeEnabled(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
/**
* Called only when the app transitions from enabled -> disabled.
*/
beforeDisabled(savedApp: App, newApp: App): Promise<void>;
beforeDisabled(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
beforeUpdated(savedApp: App, newApp: App): Promise<void>;
beforeUpdated(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
beforeDeleted(app: App): Promise<void>;
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Called for each app before a bulk session deletion removes all app records.
* Use this to clean up external resources tied to the app (e.g. API keys).
*/
beforeSessionDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Called after reading an app from storage, before returning it to the caller.
* Use this to populate transient fields that are not persisted (e.g. secret values).
*/
enrich(manager: SessionManager, app: App): Promise<void>;
beforeSessionStart(app: App, session: WhatsappSession): void;
+6 -5
View File
@@ -13,8 +13,7 @@ import {
WANumberExistResult,
} from '@waha/structures/chatting.dto';
import { SessionInfo } from '@waha/structures/sessions.dto';
import axios, { AxiosInstance } from 'axios';
import { Auth } from '@waha/core/auth/config';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { ContactSortField } from '@waha/structures/contacts.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
@@ -25,9 +24,7 @@ export interface RequestOptions {
export class WAHASelf {
public client: AxiosInstance;
constructor() {
// Set 'X-Api-Key'
const key = Auth.keyplain.value;
constructor(public key: string) {
const port =
parseInt(process.env.PORT) ||
parseInt(process.env.WHATSAPP_API_PORT) ||
@@ -42,6 +39,10 @@ export class WAHASelf {
});
}
request(config: AxiosRequestConfig) {
return this.client.request(config);
}
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
const response = await this.client.get(url, {
responseType: 'arraybuffer',
+6
View File
@@ -5,6 +5,7 @@ import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
@@ -83,6 +84,8 @@ function getAppModule(name: AppName) {
return CallsAppExports;
case AppName.chatwoot:
return ChatWootExports;
case AppName.mcp:
return McpModuleExports;
default:
throw Error(`App module not found for ${name}`);
}
@@ -91,11 +94,13 @@ function getAppModule(name: AppName) {
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.mcp).imports,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
],
controllers: [
AppsController,
...getAppModule(AppName.mcp).controllers,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
],
@@ -104,6 +109,7 @@ export const AppsEnabled = {
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.mcp).providers,
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
],
+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 {
+37 -4
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@@ -25,32 +26,64 @@ export class CallsAppService implements IAppService {
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
// Enabling behaves the same as creating for this lightweight app.
void manager;
void savedApp;
void newApp;
return;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void newApp;
void manager;
void savedApp;
void newApp;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
async beforeDeleted(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async afterCreated(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
+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);
}
}
+6
View File
@@ -6,6 +6,7 @@ import {
NoRetriesJobOptions,
} from '@waha/apps/app_sdk/constants';
import { MessageCleanupConsumer } from '@waha/apps/chatwoot/consumers/scheduled/message.cleanup';
import { CheckTierConsumer } from '@waha/apps/chatwoot/consumers/scheduled/check.tier';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import * as lodash from 'lodash';
@@ -51,6 +52,10 @@ const IMPORTS = lodash.flatten([
name: QueueName.SCHEDULED_CHECK_VERSION,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.SCHEDULED_CHECK_TIER,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.TASK_CONTACTS_PULL,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -145,6 +150,7 @@ const PROVIDERS = [
// Scheduled
MessageCleanupConsumer,
CheckVersionConsumer,
CheckTierConsumer,
// Services
ChatWootWAHAQueueService,
ChatWootQueueService,
+12 -2
View File
@@ -20,8 +20,18 @@ export async function QueueStatus(ctx: QueueCommandContext, name: string) {
for (const status of result) {
status.name = QueueNameRepr(status.name);
}
// locked: true - last
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
const categoryOrder = ['inbox', 'whatsapp', 'task', 'scheduled'];
const categoryIndex = (name: string) => {
const category = name.split(' | ')[0];
const index = categoryOrder.indexOf(category);
return index === -1 ? categoryOrder.length : index;
};
// locked: true - last, then by category (inbox/whatsapp/task/scheduled), then by name
result = lodash.sortBy(result, [
(x) => !!x.locked,
(x) => categoryIndex(x.name),
'name',
]);
const msg = ctx.l.r('cli.cmd.queue.status.result', {
queues: result,
});
+3
View File
@@ -17,6 +17,9 @@ export async function runText(
.map((line) => line.trimStart()) // remove indentation
.join(' ') // join with space
.trim(); // final cleanup
// Strip WhatsApp/markdown formatting characters so shell-quote doesn't
// treat them as glob operators and return non-string tokens
text = text.replace(/[*_~`]/g, '');
const output = new BufferedOutput();
const program = BuildProgram(commands, ctx, output);
const argv = parse(text);
+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
View File
@@ -4,6 +4,7 @@ export enum QueueName {
//
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
SCHEDULED_CHECK_TIER = 'chatwoot.scheduled | check.tier',
//
// Task
@@ -221,10 +221,23 @@ export class MessageHandler {
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
await session.startTyping({ chatId: chatId, session: '' });
// Best effort - do not block sending on read-receipt failure
await session.readMessages(chatId).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error reading messages in chat '${chatId}': ${err}`,
);
});
await session.startTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error starting typing in chat '${chatId}': ${err}`,
);
});
await sleep(2000);
await session.stopTyping({ chatId: chatId, session: '' });
await session.stopTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error stopping typing in chat '${chatId}': ${err}`,
);
});
const msg = await session.sendText(request);
return msg;
}
@@ -0,0 +1,42 @@
import { Processor } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SUPPORT_US_URL } from '@waha/core/constants';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { VERSION, WAHAVersion } from '@waha/version';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootScheduledConsumer } from './base';
@Processor(QueueName.SCHEDULED_CHECK_TIER, { concurrency: JOB_CONCURRENCY })
export class CheckTierConsumer extends ChatWootScheduledConsumer {
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
super(manager, log, rmutex, CheckTierConsumer.name);
}
protected ErrorHeaderKey(): TKey {
return TKey.JOB_SCHEDULED_ERROR_HEADER;
}
protected async Process(container: DIContainer, job: Job): Promise<any> {
const logger = container.Logger();
const locale = container.Locale();
const conversation = await container
.ContactConversationService()
.InboxNotifications();
if (VERSION.tier !== WAHAVersion.CORE) {
logger.info('WAHA is not using the CORE version');
return;
}
logger.info('WAHA is using the CORE version');
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
supportUrl: SUPPORT_US_URL,
});
await conversation.incoming(supportMessage);
}
}
@@ -1,24 +1,19 @@
import { Processor } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CHANGELOG_URL, SUPPORT_US_URL } from '@waha/core/constants';
import { CHANGELOG_URL } from '@waha/core/constants';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { VERSION, WAHAVersion } from '@waha/version';
import { VERSION } from '@waha/version';
import axios from 'axios';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { ChatWootScheduledConsumer } from './base';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootScheduledConsumer } from './base';
/**
* Scheduled consumer for version checking
* Periodically checks for new versions and notifies if updates are available
*/
@Processor(QueueName.SCHEDULED_CHECK_VERSION, { concurrency: JOB_CONCURRENCY })
export class CheckVersionConsumer extends ChatWootScheduledConsumer {
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
@@ -29,34 +24,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
return TKey.JOB_SCHEDULED_ERROR_HEADER;
}
/**
* Process the version check job
* Checks for new versions and notifies if updates are available
*/
protected async Process(container: DIContainer, job: Job): Promise<any> {
await this.CheckWAHACoreVersion(container);
await this.CheckNewVersionAvailable(container);
}
private async CheckWAHACoreVersion(container: DIContainer) {
const logger = container.Logger();
const locale = container.Locale();
const conversation = await container
.ContactConversationService()
.InboxNotifications();
// If using the CORE version, send a message encouraging support
if (VERSION.tier !== WAHAVersion.CORE) {
logger.info('WAHA is not using the CORE version');
return;
}
logger.info('WAHA is using the CORE version');
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
supportUrl: SUPPORT_US_URL,
});
await conversation.incoming(supportMessage);
}
private async CheckNewVersionAvailable(container: DIContainer) {
const logger = container.Logger();
logger.info('Processing version check job');
const currentVersion = VERSION.version;
@@ -72,7 +40,6 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
`New version available: ${latestVersion} (current: ${currentVersion})`,
);
// Send a message to Chatwoot conversation about a new version
const locale = container.Locale();
const message = locale.key(TKey.WAHA_NEW_VERSION_AVAILABLE).render({
currentVersion: currentVersion,
@@ -212,7 +212,11 @@ class MessagesPullHandler {
// Perform some actions before any job started
if (options.pause) {
await this.queueManager.pause();
const queues = [
...this.queueManager.resolve('inbox'),
...this.queueManager.resolve('waha'),
];
await this.queueManager.pause(queues);
await this.activity.queue(true);
}
}
@@ -228,7 +232,11 @@ class MessagesPullHandler {
// Resume queues if paused
if (options.pause) {
await this.queueManager.resume();
const queues = [
...this.queueManager.resolve('inbox'),
...this.queueManager.resolve('waha'),
];
await this.queueManager.resume(queues);
await this.activity.queue(false);
}
// Progress summary
@@ -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) {
@@ -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 {
+2 -1
View File
@@ -30,6 +30,7 @@ import {
ConversationSelector,
ConversationSort,
} from '@waha/apps/chatwoot/services/ConversationSelector';
import { Auth } from '@waha/core/auth/config';
/**
* Dependency Injection Container for ChatWoot
@@ -194,7 +195,7 @@ export class DIContainer {
*/
@CacheSync()
public WAHASelf(): WAHASelf {
const self = new WAHASelf();
const self = new WAHASelf(Auth.keyplain.value);
const logging = new AxiosLogging(this.Logger());
logging.applyTo(self.client);
return self;
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 تحقق مما هو جديد في [**🆕 سجل التغييرات**]({{{changelogUrl}}})
_لإيقاف هذه الإشعارات، أرسل_ `queue stop check.version`
waha.core.version.used: |-
❤️ **ادعم WAHA!**
@@ -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** لعرضه.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 পরিবর্তন লগ**]({{{changelogUrl}}}) এ নতুন কি আছে তা দেখুন
_এই বিজ্ঞপ্তিগুলি অক্ষম করতে, পাঠান_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA সমর্থন করুন!**
@@ -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** খুলুন।
+11
View File
@@ -328,6 +328,8 @@ waha.new.version.available: |-
👉 Sehen Sie, was neu ist im [**🆕 Änderungsprotokoll**]({{{changelogUrl}}})
_Um diese Benachrichtigungen zu deaktivieren, senden Sie_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Unterstützen Sie WAHA!**
@@ -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.
+11
View File
@@ -268,6 +268,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}}}
@@ -705,6 +714,8 @@ waha.new.version.available: |-
👉 Check out what's new in the [**🆕 Changelog**]({{{changelogUrl}}})
_To disable these notifications, send_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Support WAHA!**
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Mira qué hay de nuevo en el [**🆕 Registro de Cambios**]({{{changelogUrl}}})
_Para desactivar estas notificaciones, envía_ `queue stop check.version`
waha.core.version.used: |-
❤️ **¡Apoya a WAHA!**
@@ -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.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 موارد جدید را در [**🆕 تغییرات**]({{{changelogUrl}}}) مشاهده کنید
_برای غیرفعال کردن این اعلان‌ها، ارسال کنید_ `queue stop check.version`
waha.core.version.used: |-
❤️ **از WAHA پشتیبانی کنید!**
@@ -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** را باز کنید.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Découvrez les nouveautés dans le [**🆕 Journal des modifications**]({{{changelogUrl}}})
_Pour désactiver ces notifications, envoyez_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Soutenez WAHA !**
@@ -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.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 בדוק מה חדש ב[**🆕 יומן שינויים**]({{{changelogUrl}}})
_כדי לבטל התראות אלו, שלח_ `queue stop check.version`
waha.core.version.used: |-
❤️ **תמוך ב-WAHA!**
@@ -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** כדי לצפות בזה.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 चेंजलॉग**]({{{changelogUrl}}}) में क्या नया है देखें
_इन सूचनाओं को अक्षम करने के लिए, भेजें_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA का समर्थन करें!**
@@ -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** खोलें।
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Lihat apa yang baru di [**🆕 Changelog**]({{{changelogUrl}}})
_Untuk menonaktifkan notifikasi ini, kirim_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Dukung WAHA!**
@@ -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.
+11
View File
@@ -268,6 +268,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}}}
@@ -705,6 +714,8 @@ waha.new.version.available: |-
👉 Scopri le novità nel [**🆕 Changelog**]({{{changelogUrl}}})
_Per disattivare queste notifiche, invia_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Supporta WAHA!**
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 ਬਦਲਾਅ ਲੌਗ**]({{{changelogUrl}}}) ਵਿੱਚ ਨਵਾਂ ਕੀ ਹੈ ਦੇਖੋ
_ਇਹ ਸੂਚਨਾਵਾਂ ਬੰਦ ਕਰਨ ਲਈ, ਭੇਜੋ_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA ਦਾ ਸਮਰਥਨ ਕਰੋ!**
@@ -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** ਖੋਲ੍ਹੋ।
+11
View File
@@ -326,6 +326,8 @@ waha.new.version.available: |-
👉 Confira o que há de novo no [**🆕 Registro de Alterações**]({{{changelogUrl}}})
_Para desativar essas notificações, envie_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Apoie o WAHA!**
@@ -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.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Узнайте, что нового в [**🆕 Списке изменений**]({{{changelogUrl}}})
_Чтобы отключить эти уведомления, отправьте_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Поддержите WAHA!**
@@ -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**, чтобы просмотреть.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 Değişiklik Günlüğü**]({{{changelogUrl}}}) içinde yenilikleri kontrol edin
_Bu bildirimleri devre dışı bırakmak için gönderin_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA'yı Destekleyin!**
@@ -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.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Перевірте, що нового у [**🆕 Журналі змін**]({{{changelogUrl}}})
_Щоб вимкнути ці сповіщення, надішліть_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Підтримайте WAHA!**
@@ -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**, щоб переглянути.
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 تبدیلیوں کی فہرست**]({{{changelogUrl}}}) میں نیا کیا ہے دیکھیں
_ان اطلاعات کو بند کرنے کے لیے بھیجیں_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA کی حمایت کریں!**
@@ -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** کھولیں۔
+11
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 查看[**🆕 更新日志**]({{{changelogUrl}}})
_要禁用这些通知,请发送_ `queue stop check.version`
waha.core.version.used: |-
❤️ **支持 WAHA!**
@@ -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>;
@@ -6,6 +6,7 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
import { App } from '@waha/apps/chatwoot/storage';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@@ -38,27 +39,31 @@ export class ChatWootAppService implements IAppService {
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Enabling -> behave like created
await this.beforeCreated(newApp);
return;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Disabling -> behave like deleted
await this.beforeDeleted(savedApp);
return;
await this.beforeDeleted(manager, savedApp);
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
) {
void manager;
const isTheSameUrl = savedApp.config.url === newApp.config.url;
const isTheSameInboxId = savedApp.config.inboxId === newApp.config.inboxId;
const isTheSameInbox = isTheSameUrl && isTheSameInboxId;
@@ -76,7 +81,11 @@ export class ChatWootAppService implements IAppService {
}
}
async beforeDeleted(app: App<ChatWootAppConfig>): Promise<void> {
async beforeDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
await this.chatWootScheduleService.unschedule(app.id, app.session);
this.cleanCache(app);
this.sendDisconnectedMessage(app).catch((err) => {
@@ -86,6 +95,30 @@ export class ChatWootAppService implements IAppService {
});
}
async afterCreated(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
private async sendConnectedMessage(app: App<ChatWootAppConfig>) {
const di = new DIContainer(0, app.config, this.logger, null);
const repo = di.ContactConversationService();
@@ -54,6 +54,21 @@ export class ChatWootScheduleService {
},
},
);
// Check the tier
const checkTierQueue = this.queueRegistry.queue(
QueueName.SCHEDULED_CHECK_TIER,
);
await checkTierQueue.upsertJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
// Every Monday (1) at 14:00
{ pattern: '0 0 14 * * 1' },
{
data: {
app: appId,
session: sessionName,
},
},
);
}
async unschedule(appId: string, sessionName: string): Promise<void> {
@@ -71,6 +86,13 @@ export class ChatWootScheduleService {
await checkVersionQueue.removeJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_VERSION, appId),
);
// Check the tier
const checkTierQueue = this.queueRegistry.queue(
QueueName.SCHEDULED_CHECK_TIER,
);
await checkTierQueue.removeJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
);
// contacts
const contactsPullQueue = this.queueRegistry.queue(
+15 -2
View File
@@ -1,6 +1,7 @@
import { QueueName } from '../consumers/QueueName';
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
import { Injectable } from '@nestjs/common';
import { QueueNameRepr } from '@waha/apps/app_sdk/JobUtils';
const Managable = true;
const Locked = false;
@@ -18,7 +19,8 @@ export class QueueManager {
constructor(private readonly registry: QueueRegistry) {
this.queues = {
[QueueName.SCHEDULED_MESSAGE_CLEANUP]: Locked,
[QueueName.SCHEDULED_CHECK_VERSION]: Locked,
[QueueName.SCHEDULED_CHECK_VERSION]: Managable,
[QueueName.SCHEDULED_CHECK_TIER]: Locked,
[QueueName.TASK_CONTACTS_PULL]: Locked,
[QueueName.TASK_MESSAGES_PULL]: Locked,
[QueueName.WAHA_SESSION_STATUS]: Locked,
@@ -75,8 +77,19 @@ export class QueueManager {
case undefined:
return queues;
default:
default: {
const matches = queues.filter((q) => q.split(' | ')[1] === shortcut);
if (matches.length === 1) {
return [matches[0]];
}
if (matches.length > 1) {
const names = matches.map(QueueNameRepr).join(', ');
throw new Error(
`Ambiguous queue name "${shortcut}", matches: ${names}`,
);
}
return [shortcut as QueueName];
}
}
}
@@ -16,6 +16,8 @@ export class QueueRegistry {
private readonly scheduledMessageCleanupQueue: Queue,
@InjectQueue(QueueName.SCHEDULED_CHECK_VERSION)
private readonly scheduledCheckVersionQueue: Queue,
@InjectQueue(QueueName.SCHEDULED_CHECK_TIER)
private readonly scheduledCheckTierQueue: Queue,
@InjectQueue(QueueName.TASK_CONTACTS_PULL)
private readonly taskContactsPullQueue: Queue,
@InjectQueue(QueueName.TASK_MESSAGES_PULL)
@@ -55,6 +57,7 @@ export class QueueRegistry {
this.queues = {
[QueueName.SCHEDULED_MESSAGE_CLEANUP]: this.scheduledMessageCleanupQueue,
[QueueName.SCHEDULED_CHECK_VERSION]: this.scheduledCheckVersionQueue,
[QueueName.SCHEDULED_CHECK_TIER]: this.scheduledCheckTierQueue,
[QueueName.TASK_CONTACTS_PULL]: this.taskContactsPullQueue,
[QueueName.TASK_MESSAGES_PULL]: this.taskMessagesPullQueue,
[QueueName.WAHA_SESSION_STATUS]: this.wahaSessionStatusQueue,
+126
View File
@@ -1,18 +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 }>(
@@ -24,6 +40,8 @@ interface IEngineHelper {
FilterChatIdsForMessages(chats: string[]): string[];
SupportsAllChatForMessage(): boolean;
ExtractQuotedMedia(replyData: any): QuotedMedia | null;
}
class NOWEBHelper implements IEngineHelper {
@@ -35,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 {
@@ -63,6 +89,10 @@ class NOWEBHelper implements IEngineHelper {
SupportsAllChatForMessage(): boolean {
return true;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractProtoQuotedMedia(replyData);
}
}
class GOWSHelper implements IEngineHelper {
@@ -74,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
@@ -107,6 +145,10 @@ class GOWSHelper implements IEngineHelper {
ContactIsMy(contact) {
return true;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractProtoQuotedMedia(replyData, true);
}
}
class WEBJSHelper implements IEngineHelper {
@@ -118,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
*/
@@ -161,6 +211,10 @@ class WEBJSHelper implements IEngineHelper {
ContactIsMy(contact) {
return contact.isMyContact;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractBrowserQuotedMedia(replyData);
}
}
class WPPHelper implements IEngineHelper {
@@ -172,6 +226,14 @@ class WPPHelper 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 WPP engine.
* WPP returns message.id as a composite string "fromMe_chatId_msgId".
@@ -217,6 +279,10 @@ class WPPHelper implements IEngineHelper {
ContactIsMy(contact) {
return contact.isMyContact;
}
ExtractQuotedMedia(replyData: any): QuotedMedia | null {
return extractBrowserQuotedMedia(replyData);
}
}
// Choose the right EngineHelper based on getEngineName() function
@@ -241,6 +307,66 @@ switch (getEngineName()) {
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);
+2
View File
@@ -1,3 +1,5 @@
import { EngineHelper } from './engines';
import type { QuotedMedia } from './engines';
export { EngineHelper };
export type { QuotedMedia };
+15
View File
@@ -0,0 +1,15 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { McpService } from '../mcp.service';
@Controller('mcp')
@ApiTags('🧩 Apps')
export class McpController {
constructor(private readonly mcp: McpService) {}
@Post()
post(@Req() req: Request, @Res() res: Response) {
return this.mcp.handlePost(req, res);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { AxiosRequestConfig } from 'axios';
import { ImageMcpResponse, TextMcpResponse } from '@waha/apps/mcp/responses';
export class McpController {
constructor(protected readonly api: WAHASelf) {}
protected async request(config: AxiosRequestConfig<any>) {
const requestConfig = { ...config };
requestConfig.validateStatus = () => true;
const response = await this.api.request(requestConfig);
return response;
}
protected async textRequest(config: AxiosRequestConfig) {
const response = await this.request(config);
const responseText =
typeof response.data === 'string'
? response.data
: JSON.stringify(response.data);
return TextMcpResponse(
JSON.stringify({ status: response.status, response: responseText }),
);
}
protected async imageRequest(url: string) {
const response = await this.api.request({
method: 'GET',
url: url,
responseType: 'arraybuffer',
});
return ImageMcpResponse(Buffer.from(response.data));
}
protected async scopedApiKey(
url: string,
session: string,
): Promise<string | null> {
const response = await this.request({
method: 'POST',
url: url,
data: { session: session },
});
if (response.status >= 200 && response.status < 300) {
return response.data?.key ?? null;
}
// e.g. 403 (caller lacks the scope) or 422 (session missing) → fall back
return null;
}
protected async mediaApiKey(session: string): Promise<string | null> {
return this.scopedApiKey('/api/keys/media', session);
}
protected async controlApiKey(session: string): Promise<string | null> {
return this.scopedApiKey('/api/keys/control', session);
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpController } from '@waha/apps/mcp/decorators/controller';
export const RESOURCES_KEY = Symbol('waha:mcp:resources');
export const RESOURCE_TEMPLATES_KEY = Symbol('waha:mcp:resource-templates');
export interface ResourceDef {
name: string;
uri: string;
config: ResourceMetadata;
method: string;
}
export interface ResourceTemplateDef {
name: string;
uriTemplate: string;
config: ResourceMetadata;
method: string;
}
export function Resource(
name: string,
uri: string,
config: ResourceMetadata = {},
) {
return (target: object, propertyKey: string) => {
const defs: ResourceDef[] = (target as any)[RESOURCES_KEY] ?? [];
defs.push({ name, uri, config, method: propertyKey });
(target as any)[RESOURCES_KEY] = defs;
};
}
export function ResourceTemplate(
name: string,
uriTemplate: string,
config: ResourceMetadata = {},
) {
return (target: object, propertyKey: string) => {
const defs: ResourceTemplateDef[] =
(target as any)[RESOURCE_TEMPLATES_KEY] ?? [];
defs.push({ name, uriTemplate, config, method: propertyKey });
(target as any)[RESOURCE_TEMPLATES_KEY] = defs;
};
}
export function getResources(controller: McpController): ResourceDef[] {
return (controller as any)[RESOURCES_KEY] ?? [];
}
export function getResourceTemplates(
controller: McpController,
): ResourceTemplateDef[] {
return (controller as any)[RESOURCE_TEMPLATES_KEY] ?? [];
}
+23
View File
@@ -0,0 +1,23 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpController } from '@waha/apps/mcp/decorators/controller';
export const TOOLS_KEY = Symbol('waha:mcp:tools');
export type ToolConfig = Parameters<McpServer['registerTool']>[1];
export interface ToolDef {
name: string;
config: ToolConfig;
method: string;
}
export function Tool(name: string, config: ToolConfig) {
return (target: object, propertyKey: string) => {
const defs: ToolDef[] = (target as any)[TOOLS_KEY] ?? [];
defs.push({ name, config, method: propertyKey });
(target as any)[TOOLS_KEY] = defs;
};
}
export function getTools(controller: McpController): ToolDef[] {
return (controller as any)[TOOLS_KEY] ?? [];
}
+43
View File
@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDefined,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { SessionActionsDTO } from '@waha/structures/apikeys.dto';
export class McpAppConfig {
@ApiProperty({
type: SessionActionsDTO,
description: 'Permission scopes for the generated API key.',
})
@IsDefined()
@ValidateNested()
@Type(() => SessionActionsDTO)
actions: SessionActionsDTO;
@ApiProperty({
example: 'key_id_00000000000000000000000000',
required: false,
nullable: true,
readOnly: true,
description: 'ID of the API key created for this app. Read-only.',
})
@IsOptional()
@IsString()
key_id?: string;
@ApiProperty({
example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA',
required: false,
nullable: true,
readOnly: true,
description:
'The API key value. Populated on read; not persisted in this record.',
})
@IsOptional()
@IsString()
key?: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
export const McpModuleExports = {
imports: [],
controllers: [McpController],
providers: [McpService, McpAppService],
};
+64
View File
@@ -0,0 +1,64 @@
import {
McpServer,
ResourceTemplate as SdkResourceTemplate,
} from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { VERSION } from '@waha/version';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { getTools } from '@waha/apps/mcp/decorators/tool';
import {
getResources,
getResourceTemplates,
} from '@waha/apps/mcp/decorators/resource';
export class WMcpServer {
private readonly mcp: McpServer;
constructor(controllers: McpController[]) {
this.mcp = new McpServer({
name: 'whatsapp-server-mcp',
version: `${VERSION.version} (${VERSION.engine}, ${VERSION.tier}, ${VERSION.platform})`,
});
for (const controller of controllers) {
for (const { name, config, method } of getTools(controller)) {
this.mcp.registerTool(
name,
config,
(controller as any)[method].bind(controller),
);
}
for (const { name, uri, config, method } of getResources(controller)) {
this.mcp.registerResource(
name,
uri,
config,
(controller as any)[method].bind(controller),
);
}
for (const { name, uriTemplate, config, method } of getResourceTemplates(
controller,
)) {
const template = new SdkResourceTemplate(uriTemplate, {
list: undefined,
});
this.mcp.registerResource(
name,
template,
config,
(controller as any)[method].bind(controller),
);
}
}
}
connect(transport: Transport) {
return this.mcp.connect(transport);
}
close() {
return this.mcp.close();
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { Request, Response } from 'express';
import { WMcpServer } from './mcp.server';
import { SendTools } from './tools/send.tools';
import { AuthTools } from '@waha/apps/mcp/tools/auth.tools';
import { SessionTools } from '@waha/apps/mcp/tools/sessions.tools';
import { ChatTools } from '@waha/apps/mcp/tools/chats.tools';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { ApiTools } from '@waha/apps/mcp/tools/api.tools';
import { CallTools } from '@waha/apps/mcp/tools/calls.tools';
import { ChannelTools } from '@waha/apps/mcp/tools/channels.tools';
import { ContactTools } from '@waha/apps/mcp/tools/contacts.tools';
import { GroupTools } from '@waha/apps/mcp/tools/groups.tools';
import { LabelTools } from '@waha/apps/mcp/tools/labels.tools';
import { LidTools } from '@waha/apps/mcp/tools/lids.tools';
import { PingTools } from '@waha/apps/mcp/tools/ping.tools';
import { PresenceTools } from '@waha/apps/mcp/tools/presence.tools';
import { ProfileTools } from '@waha/apps/mcp/tools/profile.tools';
import { StatusTools } from '@waha/apps/mcp/tools/status.tools';
import { ServerTools } from '@waha/apps/mcp/tools/server.tools';
import { KeysTools } from '@waha/apps/mcp/tools/keys.tools';
@Injectable()
export class McpService {
async handlePost(req: Request, res: Response) {
const apiKey = req.headers['x-api-key'] as string | undefined;
const api = new WAHASelf(apiKey);
const server = new WMcpServer([
new PingTools(api),
new SendTools(api),
new AuthTools(api),
new SessionTools(api),
new ChatTools(api),
new ApiTools(api),
new CallTools(api),
new ChannelTools(api),
new ContactTools(api),
new GroupTools(api),
new LabelTools(api),
new LidTools(api),
new PresenceTools(api),
new ProfileTools(api),
new StatusTools(api),
new ServerTools(api),
new KeysTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}
+31
View File
@@ -0,0 +1,31 @@
export function TextMcpResponse(text: string) {
return { content: [{ type: 'text' as const, text: text }] };
}
export function JsonMcpToolResponse(value: unknown) {
return TextMcpResponse(JSON.stringify(value));
}
export function ImageMcpResponse(buffer: Buffer) {
return {
content: [
{
type: 'image' as const,
data: buffer.toString('base64'),
mimeType: 'image/png',
},
],
};
}
export function JsonMcpResponse(uri: URL, data: any) {
return {
contents: [
{
uri: uri.toString(),
mimeType: 'application/json',
text: JSON.stringify(data),
},
],
};
}
+605
View File
@@ -0,0 +1,605 @@
import 'reflect-metadata';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsIn,
IsNumber,
IsOptional,
IsString,
IsUrl,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { z } from 'zod';
import { DtoToZod } from './DtoToZod';
function expectSchemasToEqual(schema1: z.ZodType, schema2: z.ZodType) {
expect(z.toJSONSchema(schema1)).toEqual(z.toJSONSchema(schema2));
}
describe('DtoToZod', () => {
describe('primitive types', () => {
it('converts @IsString to z.string()', () => {
class Dto {
@IsString()
name: string;
}
const Expected = z.object({
name: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsNumber to z.number()', () => {
class Dto {
@IsNumber()
count: number;
}
const Expected = z.object({
count: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsBoolean to z.boolean()', () => {
class Dto {
@IsBoolean()
flag: boolean;
}
const Expected = z.object({
flag: z.boolean(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('optional fields', () => {
it('marks @IsOptional fields as optional', () => {
class Dto {
@IsNumber()
@IsOptional()
limit?: number;
}
const Expected = z.object({
limit: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('marks @ApiPropertyOptional fields as optional', () => {
class Dto {
@IsString()
@ApiPropertyOptional({ description: 'Optional name' })
name?: string;
}
const Expected = z.object({
name: z.string().optional().describe('Optional name'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('marks @ApiProperty({ required: false }) fields as optional', () => {
class Dto {
@IsBoolean()
@ApiProperty({ required: false })
active?: boolean;
}
const Expected = z.object({
active: z.boolean().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('required fields fail without value', () => {
class Dto {
@IsString()
name: string;
}
const Expected = z.object({
name: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('default values', () => {
it('picks up default from class field initializer', () => {
class Dto {
@IsBoolean()
@IsOptional()
active?: boolean = true;
}
const Expected = z.object({
active: z.boolean().optional().default(true),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('picks up default from @ApiProperty', () => {
class Dto {
@IsBoolean()
@IsOptional()
@ApiProperty({ default: false, description: 'Enable feature' })
enabled?: boolean;
}
const Expected = z.object({
enabled: z
.boolean()
.optional()
.default(false)
.describe('Enable feature'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('does not apply default to required fields', () => {
class Dto {
@IsNumber()
count: number;
}
const Expected = z.object({
count: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('prefers @ApiProperty default over instance default', () => {
class Dto {
@IsNumber()
@IsOptional()
@ApiProperty({ default: 99 })
val?: number = 1;
}
const Expected = z.object({
val: z.number().optional().default(99),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('descriptions', () => {
it('applies @ApiProperty description to required field', () => {
class Dto {
@IsString()
@ApiProperty({ description: 'Session identifier' })
session: string;
}
const Expected = z.object({
session: z.string().describe('Session identifier'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies description to optional field with default', () => {
class Dto {
@IsBoolean()
@IsOptional()
@ApiProperty({
description: 'Include stopped sessions',
default: false,
})
all?: boolean;
}
const Expected = z.object({
all: z
.boolean()
.optional()
.default(false)
.describe('Include stopped sessions'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('string refinements', () => {
it('applies @IsUrl as .url()', () => {
class Dto {
@IsUrl()
webhookUrl: string;
}
const Expected = z.object({
webhookUrl: z.string().url(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies @MaxLength', () => {
class Dto {
@IsString()
@MaxLength(10)
name: string;
}
const Expected = z.object({
name: z.string().max(10),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies @Matches regex', () => {
class Dto {
@IsString()
@Matches(/^[a-z]+$/)
slug: string;
}
const Expected = z.object({
slug: z.string().regex(/^[a-z]+$/),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('chains multiple string refinements', () => {
class Dto {
@IsString()
@MaxLength(54)
@Matches(/^[a-zA-Z0-9_-]*$/)
sessionName: string;
}
const Expected = z.object({
sessionName: z
.string()
.max(54)
.regex(/^[a-zA-Z0-9_-]*$/),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('enums', () => {
it('converts string enum with @IsEnum', () => {
enum Color {
RED = 'red',
GREEN = 'green',
BLUE = 'blue',
}
class Dto {
@IsEnum(Color)
color: Color;
}
const Expected = z.object({
color: z.nativeEnum(Color),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts numeric enum with @IsEnum', () => {
enum Priority {
LOW = 1,
MEDIUM = 2,
HIGH = 3,
}
class Dto {
@IsEnum(Priority)
@IsOptional()
priority?: Priority;
}
const Expected = z.object({
priority: z.nativeEnum(Priority).optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('@IsIn union', () => {
it('creates union of number literals from @IsIn', () => {
class Dto {
@IsIn([86400, 604800, 2592000])
duration: number;
}
const Expected = z.object({
duration: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('creates union of string literals from @IsIn', () => {
class Dto {
@IsIn(['asc', 'desc'])
@IsOptional()
@ApiProperty({ default: 'desc' })
order?: string;
}
const Expected = z.object({
order: z.string().optional().default('desc'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('nested objects', () => {
it('converts @ValidateNested + @Type to nested ZodObject', () => {
class AddressDto {
@IsString()
@ApiProperty({ description: 'City name' })
city: string;
}
class Dto {
@ValidateNested()
@Type(() => AddressDto)
@IsOptional()
address?: AddressDto;
}
const Expected = z.object({
address: z
.object({
city: z.string().describe('City name'),
})
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('propagates descriptions from nested DTO fields', () => {
class AddressDto {
@IsString()
@ApiProperty({ description: 'City name' })
city: string;
}
class Dto {
@ValidateNested()
@Type(() => AddressDto)
@IsOptional()
address?: AddressDto;
}
const Expected = z.object({
address: z
.object({
city: z.string().describe('City name'),
})
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('arrays', () => {
it('converts @IsArray + @IsString({ each }) to z.array(z.string())', () => {
class Dto {
@IsArray()
@IsString({ each: true })
tags: string[];
}
const Expected = z.object({
tags: z.array(z.string()),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts array of nested DTOs', () => {
class ItemDto {
@IsString()
label: string;
}
class Dto {
@ValidateNested({ each: true })
@Type(() => ItemDto)
@IsArray()
@IsOptional()
items?: ItemDto[];
}
const Expected = z.object({
items: z
.array(
z.object({
label: z.string(),
}),
)
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsArray + @IsEnum({ each }) to array of enum', () => {
enum Status {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
class Dto {
@IsArray()
@IsEnum(Status, { each: true })
@IsOptional()
statuses?: Status[];
}
const Expected = z.object({
statuses: z.array(z.nativeEnum(Status)).optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('plugin-generated fields (undecorated)', () => {
it('handles required undecorated field', () => {
class Dto {
field: string;
static _OPENAPI_METADATA_FACTORY() {
return { field: { required: true, type: () => String } };
}
}
const Expected = z.object({
field: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional undecorated field', () => {
class Dto {
field?: string;
static _OPENAPI_METADATA_FACTORY() {
return { field: { required: false, type: () => String } };
}
}
const Expected = z.object({
field: z.string().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional number with default from factory', () => {
class Dto {
count?: number;
static _OPENAPI_METADATA_FACTORY() {
return {
count: { required: false, type: () => Number, default: 10 },
};
}
}
const Expected = z.object({
count: z.number().optional().default(10),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional boolean with description from factory', () => {
class Dto {
verbose?: boolean;
static _OPENAPI_METADATA_FACTORY() {
return {
verbose: {
required: false,
type: () => Boolean,
description: 'Enable verbose output',
},
};
}
}
const Expected = z.object({
verbose: z.boolean().optional().describe('Enable verbose output'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles array field from factory', () => {
class Dto {
tags: string[];
static _OPENAPI_METADATA_FACTORY() {
return { tags: { required: true, type: () => [String] } };
}
}
const Expected = z.object({
tags: z.array(z.string()),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles mix of decorated and undecorated fields', () => {
class Dto {
@IsString()
name: string;
age?: number;
static _OPENAPI_METADATA_FACTORY() {
return { age: { required: false, type: () => Number } };
}
}
const Expected = z.object({
name: z.string(),
age: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('inheritance', () => {
it('includes parent class fields', () => {
class BaseDto {
@IsString()
@ApiProperty({ description: 'Unique ID' })
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string().describe('Unique ID'),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
it('inherits parent field types and descriptions', () => {
class BaseDto {
@IsString()
@ApiProperty({ description: 'Unique ID' })
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string().describe('Unique ID'),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
it('parent required fields remain required in child', () => {
class BaseDto {
@IsString()
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string(),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
});
});
+416
View File
@@ -0,0 +1,416 @@
import 'reflect-metadata';
import { defaultMetadataStorage } from 'class-transformer/cjs/storage';
import { getMetadataStorage } from 'class-validator';
import { z } from 'zod';
// ──────────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T = any> = new (...args: any[]) => T;
export type DtoShape<T> = {
[K in keyof T & string]: z.ZodType<T[K]>;
};
// ──────────────────────────────────────────────────────────────────────────────
// Internal metadata interfaces
// ──────────────────────────────────────────────────────────────────────────────
interface CvConstraint {
type: string;
name: string;
propertyName: string;
constraints: unknown[];
each: boolean;
}
// Built-in decorators (@IsString, @IsArray, etc.) use type='customValidation' with
// the real name in `name`. Special decorators (@IsOptional, @ValidateNested) use
// a descriptive `type` directly with no `name`.
function constraintKey(c: CvConstraint): string {
return c.type === 'customValidation' ? c.name : c.type;
}
interface TypeMeta {
typeFunction?: () => Constructor;
propertyName: string;
}
interface ApiPropertyMeta {
description?: string;
default?: unknown;
// Explicitly set via @ApiPropertyOptional() or @ApiProperty({ required: false })
required?: boolean;
// design:type merged by NestJS Swagger at decoration time — already a constructor
type?: unknown;
isArray?: boolean;
}
// Shape of entries returned by the _OPENAPI_METADATA_FACTORY static method that
// the @nestjs/swagger compiler plugin generates for every DTO property.
interface FactoryPropertyMeta {
required?: boolean;
// Lazy constructor ref: `() => String`, or array notation: `() => [String]`
type?: () => unknown;
default?: unknown;
description?: string;
}
// ──────────────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────────────
// class-validator constraint type names
const CV_IS_STRING = 'isString';
const CV_IS_BOOLEAN = 'isBoolean';
const CV_IS_NUMBER = 'isNumber';
const CV_IS_INT = 'isInt';
const CV_IS_ARRAY = 'isArray';
const CV_IS_ENUM = 'isEnum';
const CV_NESTED = 'nestedValidation';
const CV_OPTIONAL = 'conditionalValidation'; // @IsOptional / @ValidateIf
const CV_IS_URL = 'isUrl';
const CV_MAX_LENGTH = 'maxLength';
const CV_MATCHES = 'matches';
const CV_IS_IN = 'isIn';
// NestJS Swagger reflect-metadata keys (stable since @nestjs/swagger v3)
const SW_PROPS = 'swagger/apiModelProperties';
const SW_PROPS_ARRAY = 'swagger/apiModelPropertiesArray';
// Static method injected by the @nestjs/swagger compiler plugin on every DTO
const METADATA_FACTORY_NAME = '_OPENAPI_METADATA_FACTORY';
function collectPropertyNames(cls: Constructor): string[] {
const names = new Set<string>();
// @ApiProperty-decorated properties — walk prototype chain
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const arr =
(Reflect.getMetadata(SW_PROPS_ARRAY, proto) as string[] | undefined) ??
[];
for (const entry of arr) {
names.add(entry.startsWith(':') ? entry.slice(1) : entry);
}
proto = Object.getPrototypeOf(proto) as object;
}
// class-validator metadata (handles inheritance internally)
const cvAll = getMetadataStorage().getTargetValidationMetadatas(
cls,
'',
false,
false,
) as CvConstraint[];
for (const meta of cvAll) {
names.add(meta.propertyName);
}
// _OPENAPI_METADATA_FACTORY — plugin-generated, covers undecorated properties
for (const key of Object.keys(getClassFactoryMeta(cls))) {
names.add(key);
}
return [...names];
}
// Reads the _OPENAPI_METADATA_FACTORY static method walking the constructor chain
// so that parent-class factory entries are merged (child takes precedence).
function getClassFactoryMeta(
cls: Constructor,
): Record<string, FactoryPropertyMeta> {
const result: Record<string, FactoryPropertyMeta> = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
let ctor: Function = cls;
while (ctor && ctor !== Object) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const factory = (ctor as any)[METADATA_FACTORY_NAME] as unknown;
if (typeof factory === 'function') {
const meta = (factory as () => Record<string, FactoryPropertyMeta>)();
for (const [key, val] of Object.entries(meta)) {
if (!(key in result)) result[key] = val;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
ctor = Object.getPrototypeOf(ctor) as Function;
}
return result;
}
function getConstraints(cls: Constructor, prop: string): CvConstraint[] {
const all = getMetadataStorage().getTargetValidationMetadatas(
cls,
'',
false,
false,
) as CvConstraint[];
return all.filter((m) => m.propertyName === prop);
}
// Walks the constructor chain because class-transformer stores type metadata
// on the exact constructor where @Type() was declared.
function getTypeMeta(cls: Constructor, prop: string): TypeMeta | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
let ctor: Function = cls;
while (ctor && ctor !== Object && typeof ctor.prototype !== 'undefined') {
const meta = defaultMetadataStorage.findTypeMetadata(ctor, prop) as
| TypeMeta
| undefined;
if (meta?.typeFunction) return meta;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
ctor = Object.getPrototypeOf(ctor) as Function;
}
return undefined;
}
function getApiPropertyMeta(
cls: Constructor,
prop: string,
): ApiPropertyMeta | undefined {
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const meta = Reflect.getMetadata(SW_PROPS, proto, prop) as
| ApiPropertyMeta
| undefined;
if (meta !== undefined) return meta;
proto = Object.getPrototypeOf(proto) as object;
}
return undefined;
}
function getDesignType(cls: Constructor, prop: string): unknown {
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const type = Reflect.getMetadata('design:type', proto, prop);
if (type !== undefined) return type;
proto = Object.getPrototypeOf(proto) as object;
}
return undefined;
}
function getInstanceDefault(cls: Constructor, prop: string): unknown {
try {
const instance = new cls() as Record<string, unknown>;
const val = instance[prop];
return val !== undefined ? val : undefined;
} catch {
return undefined;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Schema builders
// ──────────────────────────────────────────────────────────────────────────────
function inferFromDesignType(type: unknown): z.ZodTypeAny {
if (type === String) return z.string();
if (type === Boolean) return z.boolean();
if (type === Number) return z.number();
if (type === Array) return z.array(z.unknown());
// Record<*, *> and plain objects — TypeScript emits Object for both
if (type === Object) return z.record(z.string(), z.unknown());
return z.unknown();
}
function buildStringSchema(cs: CvConstraint[]): z.ZodString {
let schema = z.string();
for (const c of cs) {
const key = constraintKey(c);
if (key === CV_IS_URL) {
schema = schema.url();
} else if (key === CV_MAX_LENGTH) {
schema = schema.max(c.constraints[0] as number);
} else if (key === CV_MATCHES) {
schema = schema.regex(c.constraints[0] as RegExp);
}
}
return schema;
}
function buildEnumSchema(cs: CvConstraint[]): z.ZodTypeAny {
const c = cs.find((m) => constraintKey(m) === CV_IS_ENUM);
if (!c) return z.string();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return z.nativeEnum(c.constraints[0] as any);
}
// function buildIsInSchema(cs: CvConstraint[]): z.ZodTypeAny {
// const c = cs.find((m) => m.type === CV_IS_IN);
// if (!c) return z.unknown();
//
// const values = c.constraints[0] as unknown[];
// if (values.length === 0) return z.unknown();
//
// const [first, second, ...rest] = values.map((v) => z.literal(v));
// return z.union([
// first,
// second,
// ...rest,
// ] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
// }
// Determines the (element) Zod type from a set of constraints.
// Returns undefined when the constraints do not specify a type.
function resolveElementSchema(
cls: Constructor,
prop: string,
cs: CvConstraint[],
): z.ZodTypeAny | undefined {
const types = new Set(cs.map(constraintKey));
if (types.has(CV_NESTED)) {
const meta = getTypeMeta(cls, prop);
if (meta?.typeFunction) return DtoToZod(meta.typeFunction());
return z.record(z.string(), z.unknown());
}
// if (types.has(CV_IS_IN)) return buildIsInSchema(cs);
if (types.has(CV_IS_ENUM)) return buildEnumSchema(cs);
const isStringLike =
types.has(CV_IS_STRING) ||
types.has(CV_IS_URL) ||
types.has(CV_MAX_LENGTH) ||
types.has(CV_MATCHES);
if (isStringLike) return buildStringSchema(cs);
if (types.has(CV_IS_BOOLEAN)) return z.boolean();
if (types.has(CV_IS_NUMBER) || types.has(CV_IS_INT)) return z.number();
return undefined;
}
// Falls back to @Type() metadata when element constraints are absent (e.g. array
// with only @Type(() => SomeClass) and @ValidateNested({ each: true }), or arrays
// of primitives decorated only with @Type(() => Number)).
function resolveElementFromTypeMeta(
cls: Constructor,
prop: string,
): z.ZodTypeAny | undefined {
const meta = getTypeMeta(cls, prop);
if (!meta?.typeFunction) return undefined;
const ctor = meta.typeFunction();
if (ctor === String) return z.string();
if (ctor === Number) return z.number();
if (ctor === Boolean) return z.boolean();
return DtoToZod(ctor);
}
// Uses the `type` stored in @ApiProperty() metadata, which NestJS Swagger
// populates from design:type at decoration time. Handles constructor refs,
// lazy () => Class functions, and primitive string type names.
function resolveFromSwaggerType(type: unknown): z.ZodTypeAny | undefined {
if (!type) return undefined;
// Lazy type function: @ApiProperty({ type: () => SomeClass }) or factory type: () => X
if (typeof type === 'function' && type.name === 'type') {
return resolveFromSwaggerType((type as () => unknown)());
}
// Array notation [Constructor]: from _OPENAPI_METADATA_FACTORY type: () => [String]
if (Array.isArray(type) && (type as unknown[]).length === 1) {
const elem = resolveFromSwaggerType((type as unknown[])[0]);
return z.array(elem ?? z.unknown());
}
if (type === String) return z.string();
if (type === Boolean) return z.boolean();
if (type === Number) return z.number();
if (type === Array) return z.array(z.unknown());
if (type === Object) return z.record(z.string(), z.unknown());
// String type names (e.g. 'string', 'number', 'boolean') from explicit @ApiProperty({ type: 'string' })
if (type === 'string') return z.string();
if (type === 'number') return z.number();
if (type === 'boolean') return z.boolean();
if (type === 'integer') return z.number().int();
// Class constructor — recurse
if (typeof type === 'function') {
try {
return DtoToZod(type as Constructor);
} catch {
return undefined;
}
}
return undefined;
}
function buildPropertySchema(
cls: Constructor,
prop: string,
cs: CvConstraint[],
apiMeta: ApiPropertyMeta | undefined,
factoryMeta: FactoryPropertyMeta | undefined,
): z.ZodTypeAny {
const ownCs = cs.filter((c) => !c.each);
const ownTypes = new Set(ownCs.map(constraintKey));
// Detect array from class-validator @IsArray() or from @ApiProperty({ isArray: true })
const isArray = ownTypes.has(CV_IS_ARRAY) || apiMeta?.isArray === true;
if (isArray) {
const eachCs = cs.filter((c) => c.each);
const elemSchema =
resolveElementSchema(cls, prop, eachCs) ??
resolveElementFromTypeMeta(cls, prop) ??
z.unknown();
return z.array(elemSchema);
}
return (
resolveElementSchema(cls, prop, ownCs) ??
resolveFromSwaggerType(apiMeta?.type) ??
resolveFromSwaggerType(factoryMeta?.type) ??
inferFromDesignType(getDesignType(cls, prop))
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────────────────
export function DtoToZod<T>(cls: Constructor<T>): z.ZodObject<DtoShape<T>> {
const shape: Record<string, z.ZodTypeAny> = {};
const factoryAll = getClassFactoryMeta(cls);
for (const prop of collectPropertyNames(cls)) {
const cs = getConstraints(cls, prop);
const apiMeta = getApiPropertyMeta(cls, prop);
const factoryMeta = factoryAll[prop];
// @IsOptional() registers as conditionalValidation; @ApiPropertyOptional()
// / @ApiProperty({ required: false }) sets required: false on the swagger meta.
// Factory required: false covers undecorated optional fields (plugin-generated).
const isOptional =
cs.some((c) => constraintKey(c) === CV_OPTIONAL) ||
apiMeta?.required === false ||
factoryMeta?.required === false;
let schema = buildPropertySchema(cls, prop, cs, apiMeta, factoryMeta);
if (isOptional) {
schema = schema.optional();
const defaultVal =
apiMeta?.default ??
factoryMeta?.default ??
getInstanceDefault(cls, prop);
if (defaultVal !== undefined) {
schema = (schema as z.ZodOptional<z.ZodTypeAny>).default(defaultVal);
}
}
const desc = apiMeta?.description ?? factoryMeta?.description;
if (desc) schema = schema.describe(desc);
shape[prop] = schema;
}
return z.object(shape) as unknown as z.ZodObject<DtoShape<T>>;
}
+183
View File
@@ -0,0 +1,183 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@Injectable()
export class McpAppService implements IAppService {
constructor(
@InjectPinoLogger('McpAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<McpAppConfig>): void {
if (!app.config) {
app.config = new McpAppConfig();
}
delete app.config.key_id;
delete app.config.key;
}
async beforeCreated(app: App<McpAppConfig>): Promise<void> {
void app;
}
async afterCreated(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyDto = await new ApiKeyService(manager).createForApp(
{
isAdmin: false,
session: app.session,
isActive: true,
actions: app.config?.actions ?? null,
},
app.id,
);
const updatedConfig = {
...(app.config ?? {}),
key_id: keyDto.id,
} as McpAppConfig;
const repo = new AppRepository(manager.store.getWAHADatabase());
await repo.update(app.id, { config: updatedConfig });
app.config = updatedConfig;
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.requireKeyExists(manager, savedApp);
await this.syncKeyActions(manager, savedApp, newApp);
await this.setKeyActive(manager, savedApp, true);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.setKeyActive(manager, savedApp, false);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.requireKeyExists(manager, savedApp);
await this.syncKeyActions(manager, savedApp, newApp);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeDeleted(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
await this.deleteKey(manager, app);
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
await this.deleteKey(manager, app);
}
async enrich(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
const keyDto = await new ApiKeyService(manager).getById(keyId);
if (!keyDto) {
return;
}
app.config = { ...(app.config ?? {}), key: keyDto.key } as McpAppConfig;
}
beforeSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
}
afterSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
}
private async requireKeyExists(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
throw new UnprocessableEntityException(
'MCP app has no associated API key. Delete this app and create a new one.',
);
}
const existing = await new ApiKeyService(manager).getById(keyId);
if (!existing) {
throw new UnprocessableEntityException(
`The API key for this MCP app no longer exists. Delete this app and create a new one.`,
);
}
}
private async syncKeyActions(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
const keyId = savedApp.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).updateForApp(keyId, {
actions: newApp.config?.actions ?? null,
});
}
private async setKeyActive(
manager: SessionManager,
app: App<McpAppConfig>,
isActive: boolean,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).updateForApp(keyId, {
isActive: isActive,
});
}
private async deleteKey(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).deleteForApp(keyId);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { APIInput } from '@waha/apps/mcp/tools/api.zod';
import { Auth } from '@waha/core/auth/config';
import { z } from 'zod';
export class ApiTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('api-openapi', {
title: 'Get OpenAPI schema for HTTP API',
description:
'Get the latest OpenAPI schema for the HTTP API available to call in the "api-call" tool. ' +
'Use it as a last option if no native tools are found. ' +
'Find appropriate /api/send.* methods to send messages to direct chats (lid, c.us), groups (g.us) and channels/newsletter (@newsletter). ' +
'Use api/:session/status to send status.',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async openapi() {
const headers: Record<string, string> = {};
const swaggerUser = Auth.swagger.username.value;
const swaggerPassword = Auth.swagger.password.value;
if (swaggerUser && swaggerPassword) {
const encoded = Buffer.from(`${swaggerUser}:${swaggerPassword}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${encoded}`;
}
return this.textRequest({
method: 'GET',
url: '/-json',
headers: headers,
});
}
@Tool('api-call', {
title: 'Call HTTP API',
description:
'Call HTTP API for certain method if no native MCP tools available. ' +
'Before calling fetch "api-openapi" tool to get the latest openapi spec. ' +
'Use it as last option if no native tools are found.',
inputSchema: APIInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async call({ path, method, body }: z.infer<typeof APIInput>) {
return this.textRequest({
method: method,
url: path,
data: body,
});
}
}
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more