Compare commits

...
91 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
228 changed files with 17408 additions and 1507 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)",
"Bash(pre-commit run:*)",
"Bash(yarn test:*)"
]
}
}
+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
+7
View File
@@ -1,9 +1,16 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
supportedArchitectures:
cpu:
- arm
+90 -172
View File
@@ -3,183 +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.
- **OS** - the project works on any OS inside the Docker container; the base
image is defined in `Dockerfile`.
- **CPU** - the image must run on both `x86_64` (including pre-v2 CPUs without
SSE4.2) and `aarch64`.
`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:
- `pre-commit run --all-files`
- `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
@@ -197,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`
+3 -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
@@ -179,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 \
@@ -247,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
+15 -4
View File
@@ -35,17 +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/wa-js
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
@@ -61,6 +61,17 @@ gows:
(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
+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 -6
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,8 +60,8 @@
"@types/passport": "^1.0.17",
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^1.41.1",
"@wppconnect/wa-js": "^3.23.3",
"@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",
@@ -93,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",
@@ -114,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"
@@ -199,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));
});
+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;
@@ -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;
}
@@ -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;
}
@@ -156,12 +165,12 @@ export class AppsEnabledService implements IAppsService {
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,
],
+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;
}
@@ -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;
}
+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;
@@ -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();
+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,
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const APIInput = z.object({
path: z
.string()
.describe(
'API path to call, e.g: /api/sessions&query=here, include query in path, no host and protocol required.',
),
method: z
.enum(['GET', 'POST', 'DELETE', 'PATCH'])
.describe('API method to call'),
body: z.any().optional().describe('Body if required by openapi spec'),
});
+197
View File
@@ -0,0 +1,197 @@
import { z } from 'zod';
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 {
AuthPasskeyChallengeInput,
AuthPasskeyConfirmationInput,
AuthPasskeyConfirmInput,
AuthPasskeySubmitInput,
AuthQRInput,
AuthRequestCodeInput,
ScreenshotInput,
} from '@waha/apps/mcp/tools/auth.zod';
function AuthContent(key: string | null): any {
const open = key
? `add "?x-api-key=${key}" to the query params (this is a control-only key scoped to this session)`
: `append "?x-api-key=YOUR_API_KEY" to the query params, using the key you already have`;
return {
type: 'text' as const,
text: `
You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +.
If the user wants to open the QR code or screenshot in a browser, ${open}.
`,
};
}
export class AuthTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('auth-qr', {
title: 'Get QR code',
description:
'Get QR code to pair WhatsApp Session. ' +
'The first QR code is valid for 60 seconds; each subsequent code is valid for 20 seconds. ' +
'If the code expires before scanning, call this tool again to get a fresh one. ' +
'If you run out of codes the server closes the connection — reconnect and start over.',
inputSchema: AuthQRInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async authQR({ session }: z.infer<typeof AuthQRInput>) {
const result = await this.imageRequest(`/api/${session}/auth/qr`);
const key = await this.controlApiKey(session);
result.content.push(AuthContent(key));
return result;
}
@Tool('screenshot', {
title: 'Get screenshot',
description:
'Get a screenshot of the current WhatsApp Web page (WEBJS/WPP only)',
inputSchema: ScreenshotInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async screenshot({ session }: z.infer<typeof ScreenshotInput>) {
const result = await this.imageRequest(
`/api/screenshot?session=${session}`,
);
const key = await this.controlApiKey(session);
result.content.push(AuthContent(key));
return result;
}
@Tool('auth-request-code', {
title: 'Request pairing code',
description:
'Request a one-time pairing code for phone-number-based authentication (alternative to QR). ' +
'Leave method empty for Web pairing.',
inputSchema: AuthRequestCodeInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async requestCode({
session,
...body
}: z.infer<typeof AuthRequestCodeInput>) {
const result = await this.textRequest({
method: 'POST',
url: `/api/${session}/auth/request-code`,
data: body,
});
result.content.push({
type: 'text',
text:
'Share the pairing code with the user and ask them to complete linking:\n' +
'1. Open WhatsApp on your phone\n' +
'2. Tap More Options ⋮ or Settings\n' +
'3. Tap Linked Devices → Link a device\n' +
'4. Tap "Link with phone number instead" and enter the code',
});
return result;
}
@Tool('auth-passkey-challenge', {
title: 'Get passkey challenge',
description:
'Get the pending passkey (WebAuthn) challenge for a session in PASSKEY_REQUIRED status. ' +
'Fails with 422 when nothing is pending. ' +
'You cannot sign the challenge yourself - the assertion has to be produced by an authenticator ' +
'on the https://web.whatsapp.com origin (the WAHA browser extension, or the DevTools fallback). ' +
'Hand the challenge to the user, then submit the result with auth-passkey-submit.',
inputSchema: AuthPasskeyChallengeInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyChallenge({
session,
}: z.infer<typeof AuthPasskeyChallengeInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/auth/passkey/challenge`,
});
}
@Tool('auth-passkey-submit', {
title: 'Submit passkey assertion',
description:
'Submit the WebAuthn assertion produced by navigator.credentials.get() to finish passkey pairing. ' +
'Get the challenge from auth-passkey-challenge first. ' +
'After this the session usually goes straight to WORKING; ' +
'if it goes to PASSKEY_CONFIRMATION_REQUIRED instead, follow up with auth-passkey-confirmation.',
inputSchema: AuthPasskeySubmitInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async passkeySubmit({
session,
...body
}: z.infer<typeof AuthPasskeySubmitInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/auth/passkey`,
data: body,
});
}
@Tool('auth-passkey-confirmation', {
title: 'Get passkey confirmation code',
description:
'Get the pending passkey confirmation code for a session in PASSKEY_CONFIRMATION_REQUIRED status. ' +
'Fails with 422 when nothing is pending. ' +
'Show the code to the user, ask them to check it matches the one on their phone, ' +
'then call auth-passkey-confirm.',
inputSchema: AuthPasskeyConfirmationInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyConfirmation({
session,
}: z.infer<typeof AuthPasskeyConfirmationInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/auth/passkey/confirmation`,
});
}
@Tool('auth-passkey-confirm', {
title: 'Confirm passkey pairing',
description:
'Finish passkey pairing after the user confirmed the code matches the one shown on their phone. ' +
'Only call it once the user has verified the code from auth-passkey-confirmation.',
inputSchema: AuthPasskeyConfirmInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyConfirm({ session }: z.infer<typeof AuthPasskeyConfirmInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/auth/passkey/confirm`,
});
}
}
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
PasskeyAssertionRequest,
RequestCodeRequest,
} from '@waha/structures/auth.dto';
export const AuthQRInput = z.object({
session: z.string(),
});
export const ScreenshotInput = z.object({
session: z.string(),
});
export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({
session: z.string(),
});
export const AuthPasskeyChallengeInput = z.object({
session: z.string(),
});
export const AuthPasskeyConfirmationInput = z.object({
session: z.string(),
});
export const AuthPasskeySubmitInput = DtoToZod(PasskeyAssertionRequest).extend({
session: z.string(),
});
export const AuthPasskeyConfirmInput = z.object({
session: z.string(),
});
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
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 { RejectCallInput } from '@waha/apps/mcp/tools/calls.zod';
export class CallTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('calls-reject', {
title: 'Reject incoming call',
description: 'Reject an incoming WhatsApp call',
inputSchema: RejectCallInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async rejectCall({ session, ...body }: z.infer<typeof RejectCallInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/calls/reject`,
data: body,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { RejectCallRequest } from '@waha/structures/calls.dto';
export const RejectCallInput = DtoToZod(RejectCallRequest).extend({
session: z.string().describe('Session name'),
});
+280
View File
@@ -0,0 +1,280 @@
import { z } from 'zod';
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 {
ChannelCreateInput,
ChannelIdInput,
ChannelPreviewMessagesInput,
ChannelsListInput,
ChannelSearchByTextInput,
ChannelSearchByViewInput,
ChannelSearchMetaInput,
} from '@waha/apps/mcp/tools/channels.zod';
export class ChannelTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('channels-list', {
title: 'List channels',
description: 'Get list of known WhatsApp channels for a session',
inputSchema: ChannelsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...query }: z.infer<typeof ChannelsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels`,
params: query,
});
}
@Tool('channels-create', {
title: 'Create channel',
description: 'Create a new WhatsApp channel',
inputSchema: ChannelCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof ChannelCreateInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels`,
data: body,
});
}
@Tool('channels-delete', {
title: 'Delete channel',
description: 'Delete a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/channels/${id}`,
});
}
@Tool('channels-get', {
title: 'Get channel info',
description:
'Get channel information by ID (123@newsletter) or invite code/link',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}`,
});
}
@Tool('channels-messages-preview', {
title: 'Preview channel messages',
description:
'Preview recent messages from a channel by ID (123@newsletter) or invite code/link',
inputSchema: ChannelPreviewMessagesInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async previewMessages({
session,
id,
...query
}: z.infer<typeof ChannelPreviewMessagesInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}/messages/preview`,
params: query,
});
}
@Tool('channels-follow', {
title: 'Follow channel',
description: 'Follow a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async follow({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/follow`,
});
}
@Tool('channels-unfollow', {
title: 'Unfollow channel',
description: 'Unfollow a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unfollow({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/unfollow`,
});
}
@Tool('channels-mute', {
title: 'Mute channel',
description: 'Mute notifications for a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async mute({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/mute`,
});
}
@Tool('channels-unmute', {
title: 'Unmute channel',
description: 'Unmute notifications for a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unmute({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/unmute`,
});
}
@Tool('channels-search-by-view', {
title: 'Search channels by view',
description:
'Search for public WhatsApp channels by view, countries, and categories',
inputSchema: ChannelSearchByViewInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async searchByView({
session,
...body
}: z.infer<typeof ChannelSearchByViewInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/search/by-view`,
data: body,
});
}
@Tool('channels-search-by-text', {
title: 'Search channels by text',
description: 'Search for public WhatsApp channels by text query',
inputSchema: ChannelSearchByTextInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async searchByText({
session,
...body
}: z.infer<typeof ChannelSearchByTextInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/search/by-text`,
data: body,
});
}
@Tool('channels-search-views', {
title: 'Get channel search views',
description:
'Get available view options for channel search (e.g. RECOMMENDED)',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchViews({ session }: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/views`,
});
}
@Tool('channels-search-countries', {
title: 'Get channel search countries',
description: 'Get available country codes for channel search',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchCountries({
session,
}: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/countries`,
});
}
@Tool('channels-search-categories', {
title: 'Get channel search categories',
description: 'Get available category options for channel search',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchCategories({
session,
}: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/categories`,
});
}
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
const SessionField = z.string().describe('Session name');
const ChannelIdField = z
.string()
.describe('Channel ID (123@newsletter) or invite code');
export const ChannelsListInput = DtoToZod(ListChannelsQuery).extend({
session: SessionField,
});
export const ChannelCreateInput = DtoToZod(CreateChannelRequest).extend({
session: SessionField,
});
export const ChannelIdInput = z.object({
session: SessionField,
id: ChannelIdField,
});
export const ChannelPreviewMessagesInput = DtoToZod(
PreviewChannelMessages,
).extend({
session: SessionField,
id: ChannelIdField,
});
export const ChannelSearchByViewInput = DtoToZod(ChannelSearchByView).extend({
session: SessionField,
});
export const ChannelSearchByTextInput = DtoToZod(ChannelSearchByText).extend({
session: SessionField,
});
export const ChannelSearchMetaInput = z.object({
session: SessionField,
});
+396
View File
@@ -0,0 +1,396 @@
import { z } from 'zod';
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 { TextMcpResponse } from '@waha/apps/mcp/responses';
import {
ChatInput,
ChatMessageInput,
ChatMessagesInput,
ChatPictureInput,
ChatsListInput,
ChatsOverviewBodyInput,
ChatsOverviewInput,
DeleteMessageInput,
EditMessageInput,
PinMessageInput,
ReadChatMessagesInput,
UnpinMessageInput,
} from '@waha/apps/mcp/tools/chats.zod';
function FetchMediaContent(key: string | null) {
if (key) {
return {
type: 'text' as const,
text:
`To fetch media use "X-Api-Key: ${key}" HTTP header. ` +
`To open it in a browser add "?x-api-key=${key}" to the query params. ` +
`This is a media-only API key: it can ONLY download files for this session — ` +
`it cannot read messages, send, or control the session.`,
};
}
return {
type: 'text' as const,
text:
`To fetch media, use your existing WAHA API key in the "X-Api-Key" HTTP header ` +
`(or append "?x-api-key=YOUR_API_KEY" to open it in a browser).`,
};
}
export class ChatTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('chats-list', {
title: 'List chats',
description: 'Get list of chats for a session',
inputSchema: ChatsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...pagination }: z.infer<typeof ChatsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats`,
params: pagination,
});
}
@Tool('chats-overview', {
title: 'Get chats overview',
description:
'Get chats overview with last message, name, and picture. Sorted by last message timestamp',
inputSchema: ChatsOverviewInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async overview({ session, ...params }: z.infer<typeof ChatsOverviewInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats/overview`,
params: params,
});
}
@Tool('chats-overview-post', {
title: 'Get chats overview (POST)',
description:
'Get chats overview via POST body — use this instead of chats-overview when filtering by many chat ids',
inputSchema: ChatsOverviewBodyInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async overviewPost({
session,
...body
}: z.infer<typeof ChatsOverviewBodyInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/overview`,
data: body,
});
}
@Tool('chats-delete', {
title: 'Delete chat',
description: 'Delete a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deleteChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}`,
});
}
@Tool('chats-get-picture', {
title: 'Get chat picture',
description: 'Get the profile picture URL for a chat',
inputSchema: ChatPictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getPicture({
session,
chatId,
refresh,
}: z.infer<typeof ChatPictureInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/picture`,
params: { refresh: refresh },
});
}
@Tool('chats-get-messages', {
title: 'Get chat messages',
description:
'Get messages in a chat. ' +
'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' +
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.',
inputSchema: ChatMessagesInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessages({
session,
chatId,
_data,
...query
}: z.infer<typeof ChatMessagesInput>) {
const response = await this.request({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages`,
params: query,
});
let messages = response.data;
if (!_data && Array.isArray(messages)) {
messages = messages.map((msg: any) => {
const result = { ...msg };
delete result._data;
if (result.replyTo) {
result.replyTo = { ...result.replyTo };
delete result.replyTo._data;
}
return result;
});
}
const responseText =
typeof messages === 'string' ? messages : JSON.stringify(messages);
const result = TextMcpResponse(
JSON.stringify({ status: response.status, response: responseText }),
);
if (query.downloadMedia) {
const mediaKey = await this.mediaApiKey(session);
result.content.push(FetchMediaContent(mediaKey));
}
return result;
}
@Tool('chats-read-messages', {
title: 'Read chat messages',
description: 'Mark messages as read in a chat',
inputSchema: ReadChatMessagesInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async readMessages({
session,
chatId,
...query
}: z.infer<typeof ReadChatMessagesInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/read`,
params: query,
});
}
@Tool('chats-get-message', {
title: 'Get message by ID',
description: 'Get a specific message by its ID',
inputSchema: ChatMessageInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessage({
session,
chatId,
messageId,
...query
}: z.infer<typeof ChatMessageInput>) {
const result = await this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
params: query,
});
if (query.downloadMedia) {
const mediaKey = await this.mediaApiKey(session);
result.content.push(FetchMediaContent(mediaKey));
}
return result;
}
@Tool('chats-pin-message', {
title: 'Pin message',
description: 'Pin a message in a chat',
inputSchema: PinMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async pinMessage({
session,
chatId,
messageId,
duration,
}: z.infer<typeof PinMessageInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/${messageId}/pin`,
data: { duration: duration },
});
}
@Tool('chats-unpin-message', {
title: 'Unpin message',
description: 'Unpin a message in a chat',
inputSchema: UnpinMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unpinMessage({
session,
chatId,
messageId,
}: z.infer<typeof UnpinMessageInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/${messageId}/unpin`,
});
}
@Tool('chats-clear-messages', {
title: 'Clear chat messages',
description: 'Delete all messages from a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async clearMessages({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}/messages`,
});
}
@Tool('chats-delete-message', {
title: 'Delete message',
description: 'Delete a specific message from a chat',
inputSchema: DeleteMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deleteMessage({
session,
chatId,
messageId,
}: z.infer<typeof DeleteMessageInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
});
}
@Tool('chats-edit-message', {
title: 'Edit message',
description: 'Edit the text of a sent message',
inputSchema: EditMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async editMessage({
session,
chatId,
messageId,
...body
}: z.infer<typeof EditMessageInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
data: body,
});
}
@Tool('chats-archive', {
title: 'Archive chat',
description: 'Archive a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async archiveChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/archive`,
});
}
@Tool('chats-unarchive', {
title: 'Unarchive chat',
description: 'Unarchive a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unarchiveChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/unarchive`,
});
}
@Tool('chats-unread', {
title: 'Mark chat as unread',
description: 'Mark a chat as unread',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unreadChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/unread`,
});
}
}
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChatPictureQuery,
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
OverviewFilter,
PinMessageRequest,
ReadChatMessagesQuery,
} from '@waha/structures/chats.dto';
const SessionField = z.string().describe('Session name');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
const MessageIdField = z.string().describe('Message ID');
export const ChatsListInput = DtoToZod(GetChatsParams).extend({
session: SessionField,
});
export const ChatsOverviewInput = DtoToZod(GetChatsOverviewParams)
.merge(DtoToZod(OverviewFilter))
.extend({ session: SessionField });
export const ChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
export const ChatPictureInput = DtoToZod(ChatPictureQuery).extend({
session: SessionField,
chatId: ChatIdField,
});
export const ChatMessagesInput = DtoToZod(GetChatMessagesQuery)
.merge(DtoToZod(GetChatMessagesFilter))
.extend({
session: SessionField,
chatId: z.string().describe('Chat ID'),
_data: z
.boolean()
.optional()
.default(false)
.describe(
'Include raw _data field in messages. When false (default), _data is stripped from each message and from replyTo.',
),
});
export const ReadChatMessagesInput = DtoToZod(ReadChatMessagesQuery).extend({
session: SessionField,
chatId: ChatIdField,
});
export const ChatMessageInput = DtoToZod(GetChatMessageQuery).extend({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const PinMessageInput = DtoToZod(PinMessageRequest).extend({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const UnpinMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const DeleteMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const EditMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
text: z.string().describe('New text'),
linkPreview: z.boolean().optional().default(true),
});
export const ChatsOverviewBodyInput = z.object({
session: SessionField,
pagination: DtoToZod(GetChatsOverviewParams),
filter: DtoToZod(OverviewFilter),
});
+169
View File
@@ -0,0 +1,169 @@
import { z } from 'zod';
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 {
ContactCheckExistsInput,
ContactGetInput,
ContactProfilePictureInput,
ContactRequestInput,
ContactsGetAllInput,
ContactUpsertInput,
} from '@waha/apps/mcp/tools/contacts.zod';
export class ContactTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('contacts-get-all', {
title: 'Get all contacts',
description: 'Get all contacts for a session',
inputSchema: ContactsGetAllInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session, ...query }: z.infer<typeof ContactsGetAllInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/all',
params: { session: session, ...query },
});
}
@Tool('contacts-get', {
title: 'Get contact info',
description:
'Get basic contact info. Always returns a result even if the number is not registered in WhatsApp — use contacts-check-exists to verify registration.',
inputSchema: ContactGetInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof ContactGetInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/contacts/${id}`,
});
}
@Tool('contacts-check-exists', {
title: 'Check if number is on WhatsApp',
description: 'Check whether a phone number is registered in WhatsApp',
inputSchema: ContactCheckExistsInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async checkExists(query: z.infer<typeof ContactCheckExistsInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/check-exists',
params: query,
});
}
@Tool('contacts-get-about', {
title: "Get contact's about",
description:
'Get the contact\'s "about" / status text. Returns null if privacy settings block access.',
inputSchema: ContactGetInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAbout({ session, id }: z.infer<typeof ContactGetInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/about',
params: { session: session, contactId: id },
});
}
@Tool('contacts-get-picture', {
title: "Get contact's profile picture",
description:
"Get the contact's profile picture URL. Returns null if privacy settings block access.",
inputSchema: ContactProfilePictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getProfilePicture(query: z.infer<typeof ContactProfilePictureInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/profile-picture',
params: query,
});
}
@Tool('contacts-block', {
title: 'Block contact',
description: 'Block a contact',
inputSchema: ContactRequestInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async block(body: z.infer<typeof ContactRequestInput>) {
return this.textRequest({
method: 'POST',
url: '/api/contacts/block',
data: body,
});
}
@Tool('contacts-unblock', {
title: 'Unblock contact',
description: 'Unblock a contact',
inputSchema: ContactRequestInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unblock(body: z.infer<typeof ContactRequestInput>) {
return this.textRequest({
method: 'POST',
url: '/api/contacts/unblock',
data: body,
});
}
@Tool('contacts-upsert', {
title: 'Create or update contact',
description:
'Create or update a contact in the phone address book. May not work if multiple WhatsApp apps are installed on the same phone.',
inputSchema: ContactUpsertInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async upsert({
session,
chatId,
...body
}: z.infer<typeof ContactUpsertInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/contacts/${chatId}`,
data: body,
});
}
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ContactsPaginationParams,
ContactProfilePictureQuery,
ContactRequest,
ContactUpdateBody,
} from '@waha/structures/contacts.dto';
import { CheckNumberStatusQuery } from '@waha/structures/chatting.dto';
const SessionField = z.string().describe('Session name');
const ContactIdField = z.string().describe('Contact ID (e.g. 11111@c.us)');
export const ContactsGetAllInput = DtoToZod(ContactsPaginationParams).extend({
session: SessionField,
});
export const ContactGetInput = z.object({
session: SessionField,
id: ContactIdField,
});
export const ContactCheckExistsInput = DtoToZod(CheckNumberStatusQuery);
export const ContactProfilePictureInput = DtoToZod(ContactProfilePictureQuery);
export const ContactRequestInput = DtoToZod(ContactRequest);
export const ContactUpsertInput = DtoToZod(ContactUpdateBody).extend({
session: SessionField,
chatId: ContactIdField,
});
+504
View File
@@ -0,0 +1,504 @@
import { z } from 'zod';
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 {
GroupAdminOnlyInput,
GroupCreateInput,
GroupDescriptionInput,
GroupIdInput,
GroupJoinInput,
GroupParticipantsInput,
GroupPictureInput,
GroupsListInput,
GroupsSessionInput,
GroupSetPictureInput,
GroupSubjectInput,
} from '@waha/apps/mcp/tools/groups.zod';
export class GroupTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('groups-list', {
title: 'List groups',
description: 'Get all groups for a session',
inputSchema: GroupsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...query }: z.infer<typeof GroupsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups`,
params: query,
});
}
@Tool('groups-count', {
title: 'Count groups',
description: 'Get the number of groups for a session',
inputSchema: GroupsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async count({ session }: z.infer<typeof GroupsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/count`,
});
}
@Tool('groups-refresh', {
title: 'Refresh groups',
description: 'Refresh the groups list from the server',
inputSchema: GroupsSessionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async refresh({ session }: z.infer<typeof GroupsSessionInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/refresh`,
});
}
@Tool('groups-join-info', {
title: 'Get group join info',
description:
'Get info about a group before joining via invite code or link',
inputSchema: GroupJoinInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async joinInfo({ session, ...query }: z.infer<typeof GroupJoinInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/join-info`,
params: query,
});
}
@Tool('groups-join', {
title: 'Join group',
description: 'Join a group via invite code or invite link',
inputSchema: GroupJoinInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async join({ session, ...body }: z.infer<typeof GroupJoinInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/join`,
data: body,
});
}
@Tool('groups-create', {
title: 'Create group',
description: 'Create a new WhatsApp group',
inputSchema: GroupCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof GroupCreateInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups`,
data: body,
});
}
@Tool('groups-get', {
title: 'Get group',
description: 'Get group information by ID',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}`,
});
}
@Tool('groups-delete', {
title: 'Delete group',
description: 'Delete a WhatsApp group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/groups/${id}`,
});
}
@Tool('groups-leave', {
title: 'Leave group',
description: 'Leave a WhatsApp group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async leave({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/leave`,
});
}
@Tool('groups-get-picture', {
title: 'Get group picture',
description: 'Get the group profile picture URL',
inputSchema: GroupPictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getPicture({
session,
id,
...query
}: z.infer<typeof GroupPictureInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/picture`,
params: query,
});
}
@Tool('groups-set-picture', {
title: 'Set group picture',
description: 'Set the group profile picture',
inputSchema: GroupSetPictureInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async setPicture({
session,
id,
...body
}: z.infer<typeof GroupSetPictureInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/picture`,
data: body,
});
}
@Tool('groups-delete-picture', {
title: 'Delete group picture',
description: 'Remove the group profile picture',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deletePicture({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/groups/${id}/picture`,
});
}
@Tool('groups-set-description', {
title: 'Set group description',
description: 'Update the group description',
inputSchema: GroupDescriptionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setDescription({
session,
id,
...body
}: z.infer<typeof GroupDescriptionInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/description`,
data: body,
});
}
@Tool('groups-set-subject', {
title: 'Set group subject',
description: 'Update the group name/subject',
inputSchema: GroupSubjectInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setSubject({
session,
id,
...body
}: z.infer<typeof GroupSubjectInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/subject`,
data: body,
});
}
@Tool('groups-get-info-admin-only', {
title: 'Get info-admin-only setting',
description: 'Get whether only admins can edit group info',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getInfoAdminOnly({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/info-admin-only`,
});
}
@Tool('groups-set-info-admin-only', {
title: 'Set info-admin-only setting',
description:
'Allow only admins to edit group info (title, description, photo)',
inputSchema: GroupAdminOnlyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setInfoAdminOnly({
session,
id,
...body
}: z.infer<typeof GroupAdminOnlyInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/info-admin-only`,
data: body,
});
}
@Tool('groups-get-messages-admin-only', {
title: 'Get messages-admin-only setting',
description: 'Get whether only admins can send messages in the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessagesAdminOnly({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`,
});
}
@Tool('groups-set-messages-admin-only', {
title: 'Set messages-admin-only setting',
description: 'Allow only admins to send messages in the group',
inputSchema: GroupAdminOnlyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setMessagesAdminOnly({
session,
id,
...body
}: z.infer<typeof GroupAdminOnlyInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`,
data: body,
});
}
@Tool('groups-get-invite-code', {
title: 'Get group invite code',
description: 'Get the invite code for a group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getInviteCode({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/invite-code`,
});
}
@Tool('groups-revoke-invite-code', {
title: 'Revoke group invite code',
description: 'Invalidate the current invite code and generate a new one',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async revokeInviteCode({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/invite-code/revoke`,
});
}
@Tool('groups-get-participants', {
title: 'Get group participants',
description: 'Get the list of group participants with roles',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getParticipants({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/participants/v2`,
});
}
@Tool('groups-add-participants', {
title: 'Add participants',
description: 'Add participants to a group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async addParticipants({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/add`,
data: body,
});
}
@Tool('groups-remove-participants', {
title: 'Remove participants',
description: 'Remove participants from a group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async removeParticipants({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/remove`,
data: body,
});
}
@Tool('groups-promote-to-admin', {
title: 'Promote to admin',
description: 'Promote participants to group admin',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async promoteToAdmin({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/admin/promote`,
data: body,
});
}
@Tool('groups-demote-to-user', {
title: 'Demote to user',
description: 'Demote admin participants back to regular users',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async demoteToUser({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/admin/demote`,
data: body,
});
}
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
CreateGroupRequest,
DescriptionRequest,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
ParticipantsRequest,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '@waha/structures/groups.dto';
import { ChatPictureQuery } from '@waha/structures/chats.dto';
import { ProfilePictureRequest } from '@waha/structures/profile.dto';
const SessionField = z.string().describe('Session name');
const GroupIdField = z.string().describe('Group ID (e.g. 123456789@g.us)');
export const GroupsListInput = DtoToZod(GroupsPaginationParams)
.merge(DtoToZod(GroupsListFields))
.extend({ session: SessionField });
export const GroupsSessionInput = z.object({ session: SessionField });
export const GroupIdInput = z.object({
session: SessionField,
id: GroupIdField,
});
export const GroupCreateInput = DtoToZod(CreateGroupRequest).extend({
session: SessionField,
});
export const GroupJoinInput = DtoToZod(JoinGroupRequest).extend({
session: SessionField,
});
export const GroupPictureInput = DtoToZod(ChatPictureQuery).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupSetPictureInput = DtoToZod(ProfilePictureRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupDescriptionInput = DtoToZod(DescriptionRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupSubjectInput = DtoToZod(SubjectRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupAdminOnlyInput = DtoToZod(SettingsSecurityChangeInfo).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({
session: SessionField,
id: GroupIdField,
});
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
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 { TextMcpResponse } from '@waha/apps/mcp/responses';
import { ScopedKeyInput } from '@waha/apps/mcp/tools/keys.zod';
export class KeysTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('keys-get-scoped-key', {
title: 'Get a scoped API key',
description:
'Create or get a minimal, scoped API key for a session. ' +
'Use "media" scope for a download-only key to fetch media files, ' +
'or "control" scope for a control-only key to open the QR code / screenshot in a browser. ' +
'The returned key is far weaker than your own key and is safe to hand to the user for that single purpose.',
inputSchema: ScopedKeyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async getScopedKey({ session, scope }: z.infer<typeof ScopedKeyInput>) {
let key: string | null = null;
if (scope === 'media') {
key = await this.mediaApiKey(session);
} else {
key = await this.controlApiKey(session);
}
if (!key) {
return TextMcpResponse(
JSON.stringify({
error:
'Could not mint a scoped API key. Check that the session exists and that your key has access to it.',
}),
);
}
return TextMcpResponse(JSON.stringify({ scope: scope, key: key }));
}
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
const SessionField = z.string().describe('Session name');
export const ScopedKeyInput = z.object({
session: SessionField,
scope: z
.enum(['media', 'control'])
.describe(
'Scope of the key. ' +
'"media" — download-only key for fetching media files of the session. ' +
'"control" — control-only key to open QR code / screenshot in a browser.',
),
});
+148
View File
@@ -0,0 +1,148 @@
import { z } from 'zod';
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 {
LabelBodyInput,
LabelChatInput,
LabelIdInput,
LabelsSessionInput,
LabelUpdateInput,
SetChatLabelsInput,
} from '@waha/apps/mcp/tools/labels.zod';
export class LabelTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('labels-get-all', {
title: 'Get all labels',
description: 'Get all labels for a session',
inputSchema: LabelsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session }: z.infer<typeof LabelsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels`,
});
}
@Tool('labels-create', {
title: 'Create label',
description: 'Create a new label (max 20 per session)',
inputSchema: LabelBodyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof LabelBodyInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/labels`,
data: body,
});
}
@Tool('labels-update', {
title: 'Update label',
description: 'Update an existing label name or color',
inputSchema: LabelUpdateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async update({
session,
labelId,
...body
}: z.infer<typeof LabelUpdateInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/labels/${labelId}`,
data: body,
});
}
@Tool('labels-delete', {
title: 'Delete label',
description: 'Delete a label',
inputSchema: LabelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, labelId }: z.infer<typeof LabelIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/labels/${labelId}`,
});
}
@Tool('labels-get-chat-labels', {
title: 'Get chat labels',
description: 'Get all labels applied to a chat',
inputSchema: LabelChatInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getChatLabels({ session, chatId }: z.infer<typeof LabelChatInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels/chats/${chatId}`,
});
}
@Tool('labels-set-chat-labels', {
title: 'Set chat labels',
description: 'Replace all labels on a chat',
inputSchema: SetChatLabelsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setChatLabels({
session,
chatId,
...body
}: z.infer<typeof SetChatLabelsInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/labels/chats/${chatId}`,
data: body,
});
}
@Tool('labels-get-chats-by-label', {
title: 'Get chats by label',
description: 'Get all chats that have a given label applied',
inputSchema: LabelIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getChatsByLabel({ session, labelId }: z.infer<typeof LabelIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels/${labelId}/chats`,
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { LabelBody, SetLabelsRequest } from '@waha/structures/labels.dto';
const SessionField = z.string().describe('Session name');
const LabelIdField = z.string().describe('Label ID');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
export const LabelsSessionInput = z.object({ session: SessionField });
export const LabelIdInput = z.object({
session: SessionField,
labelId: LabelIdField,
});
export const LabelBodyInput = DtoToZod(LabelBody).extend({
session: SessionField,
});
export const LabelUpdateInput = DtoToZod(LabelBody).extend({
session: SessionField,
labelId: LabelIdField,
});
export const LabelChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
export const SetChatLabelsInput = DtoToZod(SetLabelsRequest).extend({
session: SessionField,
chatId: ChatIdField,
});
+90
View File
@@ -0,0 +1,90 @@
import { z } from 'zod';
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 {
LidInput,
LidsListInput,
LidsSessionInput,
PhoneNumberInput,
} from '@waha/apps/mcp/tools/lids.zod';
export class LidTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('lids-get-all', {
title: 'Get all LIDs',
description:
'LIDs (Linked IDs) are anonymous identifiers WhatsApp assigns to contacts in some regions instead of phone numbers. ' +
'Get all known LID-to-phone-number mappings for a session.',
inputSchema: LidsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session, ...query }: z.infer<typeof LidsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids`,
params: query,
});
}
@Tool('lids-count', {
title: 'Count LIDs',
description: 'Get the number of known LIDs for a session',
inputSchema: LidsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async count({ session }: z.infer<typeof LidsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/count`,
});
}
@Tool('lids-find-pn-by-lid', {
title: 'Find phone number by LID',
description: 'Look up the phone number (chat ID) for a given LID',
inputSchema: LidInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async findPNByLid({ session, lid }: z.infer<typeof LidInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/${lid}`,
});
}
@Tool('lids-find-lid-by-pn', {
title: 'Find LID by phone number',
description: 'Look up the LID for a given phone number / chat ID',
inputSchema: PhoneNumberInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async findLidByPN({
session,
phoneNumber,
}: z.infer<typeof PhoneNumberInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/pn/${phoneNumber}`,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { LidsListQueryParams } from '@waha/structures/lids.dto';
const SessionField = z.string().describe('Session name');
export const LidsListInput = DtoToZod(LidsListQueryParams).extend({
session: SessionField,
});
export const LidsSessionInput = z.object({ session: SessionField });
export const LidInput = z.object({
session: SessionField,
lid: z.string().describe('LID (e.g. 1111111@lid)'),
});
export const PhoneNumberInput = z.object({
session: SessionField,
phoneNumber: z
.string()
.describe('Phone number / chat ID (e.g. 3333333@c.us)'),
});
+25
View File
@@ -0,0 +1,25 @@
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
export class PingTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('ping', {
title: 'Ping the server',
description: 'Check if the WAHA server is alive and responding',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async ping() {
return this.textRequest({
method: 'GET',
url: '/ping',
});
}
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
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 {
PresenceChatInput,
PresenceSessionInput,
PresenceSetInput,
} from '@waha/apps/mcp/tools/presence.zod';
export class PresenceTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('presence-set', {
title: 'Set presence',
description:
'Set the session presence. Use ONLINE/OFFLINE for global scope (no chatId). ' +
'Use TYPING/RECORDING/PAUSED with a chatId for chat-scoped presence.',
inputSchema: PresenceSetInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async set({ session, ...body }: z.infer<typeof PresenceSetInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/presence`,
data: body,
});
}
@Tool('presence-get-all', {
title: 'Get all presences',
description: 'Get all subscribed presence information for a session',
inputSchema: PresenceSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session }: z.infer<typeof PresenceSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/presence`,
});
}
@Tool('presence-get', {
title: 'Get chat presence',
description:
'Get presence for a chat. Subscribes automatically if not yet subscribed.',
inputSchema: PresenceChatInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, chatId }: z.infer<typeof PresenceChatInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/presence/${chatId}`,
});
}
@Tool('presence-subscribe', {
title: 'Subscribe to presence',
description: 'Subscribe to presence events for a chat',
inputSchema: PresenceChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async subscribe({ session, chatId }: z.infer<typeof PresenceChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/presence/${chatId}/subscribe`,
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { WAHASessionPresence } from '@waha/structures/presence.dto';
const SessionField = z.string().describe('Session name');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
export const PresenceSessionInput = z.object({ session: SessionField });
export const PresenceSetInput = DtoToZod(WAHASessionPresence).extend({
session: SessionField,
});
export const PresenceChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod';
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 {
ProfileNameInput,
ProfilePictureInput,
ProfileSessionInput,
ProfileStatusInput,
} from '@waha/apps/mcp/tools/profile.zod';
export class ProfileTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('profile-get', {
title: 'Get my profile',
description:
'Get the profile info (id, name, picture) for the session account',
inputSchema: ProfileSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session }: z.infer<typeof ProfileSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/profile`,
});
}
@Tool('profile-set-name', {
title: 'Set profile name',
description: 'Update the display name for the session account',
inputSchema: ProfileNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setName({ session, ...body }: z.infer<typeof ProfileNameInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/name`,
data: body,
});
}
@Tool('profile-set-status', {
title: 'Set profile status',
description: 'Update the "About" / status text for the session account',
inputSchema: ProfileStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setStatus({ session, ...body }: z.infer<typeof ProfileStatusInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/status`,
data: body,
});
}
@Tool('profile-set-picture', {
title: 'Set profile picture',
description: 'Update the profile picture for the session account',
inputSchema: ProfilePictureInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async setPicture({ session, ...body }: z.infer<typeof ProfilePictureInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/picture`,
data: body,
});
}
@Tool('profile-delete-picture', {
title: 'Delete profile picture',
description: 'Remove the profile picture for the session account',
inputSchema: ProfileSessionInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deletePicture({ session }: z.infer<typeof ProfileSessionInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/profile/picture`,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ProfileNameRequest,
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
const SessionField = z.string().describe('Session name');
export const ProfileSessionInput = z.object({ session: SessionField });
export const ProfileNameInput = DtoToZod(ProfileNameRequest).extend({
session: SessionField,
});
export const ProfileStatusInput = DtoToZod(ProfileStatusRequest).extend({
session: SessionField,
});
export const ProfilePictureInput = DtoToZod(ProfilePictureRequest).extend({
session: SessionField,
});
+425
View File
@@ -0,0 +1,425 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { z } from 'zod';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
ForwardMessageInput,
NewMessageIdInput,
SendEventInput,
SendButtonsInput,
SendButtonsReplyInput,
SendContactVcardInput,
SendFileInput,
SendImageInput,
SendLinkCustomPreviewInput,
SendListInput,
SendLocationInput,
SendPollInput,
SendPollVoteInput,
SendSeenInput,
SendTextInput,
SendVideoInput,
SendVoiceInput,
SetReactionInput,
SetStarInput,
TypingInput,
} from '@waha/apps/mcp/tools/send.zod';
const FileNote =
'Use file.url for remote HTTP/HTTPS URLs. ' +
'For local file paths (e.g. /tmp/file.ext) - upload it to S3 or other remote server first, or read the file, encode it as base64, and pass it via file.data instead.';
export class SendTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('send-text', {
title: 'Send a text message',
description:
'Send a text message to a number. ' +
'Always call start-typing first, wait a few seconds, call stop-typing, then send the message.',
inputSchema: SendTextInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendText(data: z.infer<typeof SendTextInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendText',
data: data,
});
}
@Tool('send-image', {
title: 'Send an image',
description: 'Send an image to a chat. ' + FileNote,
inputSchema: SendImageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendImage(data: z.infer<typeof SendImageInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendImage',
data: data,
});
}
@Tool('send-file', {
title: 'Send a file',
description: 'Send a file (document) to a chat. ' + FileNote,
inputSchema: SendFileInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendFile(data: z.infer<typeof SendFileInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendFile',
data: data,
});
}
@Tool('send-voice', {
title: 'Send a voice message',
description: 'Send a voice message to a chat. ' + FileNote,
inputSchema: SendVoiceInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVoice(data: z.infer<typeof SendVoiceInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendVoice',
data: data,
});
}
@Tool('send-video', {
title: 'Send a video',
description: 'Send a video to a chat. ' + FileNote,
inputSchema: SendVideoInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVideo(data: z.infer<typeof SendVideoInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendVideo',
data: data,
});
}
@Tool('send-link-custom-preview', {
title: 'Send a text message with a custom link preview',
description:
'Send a text message with a custom link preview (title, description, image)',
inputSchema: SendLinkCustomPreviewInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendLinkCustomPreview(
data: z.infer<typeof SendLinkCustomPreviewInput>,
) {
return this.textRequest({
method: 'POST',
url: '/api/send/link-custom-preview',
data: data,
});
}
@Tool('send-buttons', {
title: 'Send a buttons message',
description: 'Send an interactive buttons message',
inputSchema: SendButtonsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendButtons(data: z.infer<typeof SendButtonsInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendButtons',
data: data,
});
}
@Tool('send-list', {
title: 'Send a list message',
description: 'Send an interactive list message with sections and rows',
inputSchema: SendListInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendList(data: z.infer<typeof SendListInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendList',
data: data,
});
}
@Tool('send-seen', {
title: 'Mark messages as seen',
description: 'Mark one or more messages as seen (read)',
inputSchema: SendSeenInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async sendSeen(data: z.infer<typeof SendSeenInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendSeen',
data: data,
});
}
@Tool('send-poll', {
title: 'Send a poll',
description: 'Send a poll message with options',
inputSchema: SendPollInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendPoll(data: z.infer<typeof SendPollInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendPoll',
data: data,
});
}
@Tool('send-poll-vote', {
title: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
inputSchema: SendPollVoteInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendPollVote(data: z.infer<typeof SendPollVoteInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendPollVote',
data: data,
});
}
@Tool('send-location', {
title: 'Send a location',
description: 'Send a location with latitude, longitude, and title',
inputSchema: SendLocationInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendLocation(data: z.infer<typeof SendLocationInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendLocation',
data: data,
});
}
@Tool('send-contact-vcard', {
title: 'Send a contact vCard',
description: 'Send one or more contacts as a vCard',
inputSchema: SendContactVcardInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendContactVcard(data: z.infer<typeof SendContactVcardInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendContactVcard',
data: data,
});
}
@Tool('send-buttons-reply', {
title: 'Reply to a buttons message',
description: 'Send a reply to an interactive buttons message',
inputSchema: SendButtonsReplyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendButtonsReply(data: z.infer<typeof SendButtonsReplyInput>) {
return this.textRequest({
method: 'POST',
url: '/api/send/buttons/reply',
data: data,
});
}
@Tool('forward-message', {
title: 'Forward a message',
description: 'Forward an existing message to a chat',
inputSchema: ForwardMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async forwardMessage(data: z.infer<typeof ForwardMessageInput>) {
return this.textRequest({
method: 'POST',
url: '/api/forwardMessage',
data: data,
});
}
@Tool('start-typing', {
title: 'Start typing indicator',
description:
'Show a typing indicator in a chat (runs until stop-typing is called)',
inputSchema: TypingInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async startTyping(data: z.infer<typeof TypingInput>) {
return this.textRequest({
method: 'POST',
url: '/api/startTyping',
data: data,
});
}
@Tool('stop-typing', {
title: 'Stop typing indicator',
description: 'Stop the typing indicator in a chat',
inputSchema: TypingInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async stopTyping(data: z.infer<typeof TypingInput>) {
return this.textRequest({
method: 'POST',
url: '/api/stopTyping',
data: data,
});
}
@Tool('set-reaction', {
title: 'React to a message',
description:
'React to a message with an emoji. Send empty string to remove reaction',
inputSchema: SetReactionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setReaction(data: z.infer<typeof SetReactionInput>) {
return this.textRequest({
method: 'PUT',
url: '/api/reaction',
data: data,
});
}
@Tool('set-star', {
title: 'Star or unstar a message',
description: 'Star or unstar a message',
inputSchema: SetStarInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setStar(data: z.infer<typeof SetStarInput>) {
return this.textRequest({
method: 'PUT',
url: '/api/star',
data: data,
});
}
@Tool('send-event-message', {
title: 'Send an event message',
description: 'Send an event/appointment message to a chat',
inputSchema: SendEventInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendEvent({
session,
chatId,
reply_to,
...event
}: z.infer<typeof SendEventInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/events`,
data: { chatId: chatId, reply_to: reply_to, event: event },
});
}
@Tool('new-message-id', {
title: 'Generate a new message ID',
description: 'Generate a new unique message ID for use in send requests',
inputSchema: NewMessageIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async newMessageId({ session }: z.infer<typeof NewMessageIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/new-message-id`,
});
}
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChatRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
MessageImageRequest,
MessageLinkCustomPreviewRequest,
MessageLocationRequest,
MessagePollRequest,
MessagePollVoteRequest,
MessageReactionRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
} from '@waha/structures/chatting.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import { SendListRequest } from '@waha/structures/chatting.list.dto';
import { EventMessage } from '@waha/structures/events.dto';
export const SendTextInput = DtoToZod(MessageTextRequest);
export const SendImageInput = DtoToZod(MessageImageRequest);
export const SendFileInput = DtoToZod(MessageFileRequest);
export const SendVoiceInput = DtoToZod(MessageVoiceRequest);
export const SendVideoInput = DtoToZod(MessageVideoRequest);
export const SendLinkCustomPreviewInput = DtoToZod(
MessageLinkCustomPreviewRequest,
);
export const SendButtonsInput = DtoToZod(SendButtonsRequest);
export const SendListInput = DtoToZod(SendListRequest);
export const SendSeenInput = DtoToZod(SendSeenRequest);
export const SendPollInput = DtoToZod(MessagePollRequest);
export const SendPollVoteInput = DtoToZod(MessagePollVoteRequest);
export const SendLocationInput = DtoToZod(MessageLocationRequest);
export const SendContactVcardInput = DtoToZod(MessageContactVcardRequest);
export const SendButtonsReplyInput = DtoToZod(MessageButtonReply);
export const ForwardMessageInput = DtoToZod(MessageForwardRequest);
export const TypingInput = DtoToZod(ChatRequest);
export const SetReactionInput = DtoToZod(MessageReactionRequest);
export const SetStarInput = DtoToZod(MessageStarRequest);
export const NewMessageIdInput = z.object({
session: z.string().describe('Session name'),
});
export const SendEventInput = DtoToZod(EventMessage).extend({
session: z.string().describe('Session name'),
chatId: z.string().describe('Chat ID (e.g. 11111@c.us)'),
reply_to: z.string().optional().describe('Message ID to reply to'),
});
+81
View File
@@ -0,0 +1,81 @@
import { z } from 'zod';
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 { EnvironmentInput, StopInput } from '@waha/apps/mcp/tools/server.zod';
export class ServerTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('server-version', {
title: 'Get server version',
description: 'Get the version and build information of the WAHA server',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async version() {
return this.textRequest({
method: 'GET',
url: '/api/server/version',
});
}
@Tool('server-status', {
title: 'Get server status',
description: 'Get server uptime, start timestamp, and worker information',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async status() {
return this.textRequest({
method: 'GET',
url: '/api/server/status',
});
}
@Tool('server-environment', {
title: 'Get server environment',
description:
'Get environment variables from the server (WAHA_* and WHATSAPP_* by default)',
inputSchema: EnvironmentInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async environment(params: z.infer<typeof EnvironmentInput>) {
return this.textRequest({
method: 'GET',
url: '/api/server/environment',
params: params,
});
}
@Tool('server-stop', {
title: 'Stop the server',
description:
'Stop (and restart) the WAHA server. Use force=true for immediate termination.',
inputSchema: StopInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async stop(body: z.infer<typeof StopInput>) {
return this.textRequest({
method: 'POST',
url: '/api/server/stop',
data: body,
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const EnvironmentInput = z.object({
all: z
.boolean()
.optional()
.describe('Include all environment variables, not just WAHA_* ones'),
});
export const StopInput = z.object({
force: z
.boolean()
.optional()
.describe(
'Force-terminate immediately instead of graceful shutdown (SIGKILL vs SIGTERM)',
),
});
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod';
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 {
SessionCreateInput,
SessionListInput,
SessionNameInput,
SessionUpdateInput,
} from '@waha/apps/mcp/tools/sessions.zod';
export class SessionTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('sessions-list', {
title: 'List sessions',
description: 'List all WhatsApp sessions',
inputSchema: SessionListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list(params: z.infer<typeof SessionListInput>) {
// Send back all sessions
params.all = true;
return this.textRequest({
method: 'GET',
url: '/api/sessions',
params: params,
});
}
@Tool('sessions-get', {
title: 'Get session',
description: 'Get information about a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}`,
});
}
@Tool('sessions-create', {
title: 'Create session',
description: 'Create a new WhatsApp session',
inputSchema: SessionCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create(body: z.infer<typeof SessionCreateInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sessions',
data: body,
});
}
@Tool('sessions-update', {
title: 'Update session',
description: 'Update config of an existing WhatsApp session',
inputSchema: SessionUpdateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async update({ session, ...body }: z.infer<typeof SessionUpdateInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/sessions/${session}`,
data: body,
});
}
@Tool('sessions-delete', {
title: 'Delete session',
description: 'Delete a WhatsApp session (stops and logs out)',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/sessions/${session}`,
});
}
@Tool('sessions-start', {
title: 'Start session',
description: 'Start an existing WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async start({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/start`,
});
}
@Tool('sessions-stop', {
title: 'Stop session',
description: 'Stop a running WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async stop({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/stop`,
});
}
@Tool('sessions-logout', {
title: 'Logout session',
description: 'Logout from a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async logout({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/logout`,
});
}
@Tool('sessions-restart', {
title: 'Restart session',
description: 'Restart a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async restart({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/restart`,
});
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ListSessionsQuery,
SessionCreateRequest,
SessionUpdateRequest,
} from '@waha/structures/sessions.dto';
export const SessionNameInput = z.object({
session: z.string().describe('Session name'),
});
export const SessionListInput = DtoToZod(ListSessionsQuery);
export const SessionCreateInput = DtoToZod(SessionCreateRequest);
export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({
session: z.string().describe('Session name'),
});
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
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 {
DeleteStatusInput,
ImageStatusInput,
StatusSessionInput,
TextStatusInput,
VideoStatusInput,
VoiceStatusInput,
} from '@waha/apps/mcp/tools/status.zod';
const FileNote =
'Use file.url for remote HTTP/HTTPS URLs. ' +
'For local file paths - encode as base64 and pass via file.data instead.';
export class StatusTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('status-send-text', {
title: 'Send text status',
description: 'Post a text WhatsApp status update',
inputSchema: TextStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendText({ session, ...body }: z.infer<typeof TextStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/text`,
data: body,
});
}
@Tool('status-send-image', {
title: 'Send image status',
description: 'Post an image WhatsApp status update. ' + FileNote,
inputSchema: ImageStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendImage({ session, ...body }: z.infer<typeof ImageStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/image`,
data: body,
});
}
@Tool('status-send-voice', {
title: 'Send voice status',
description: 'Post a voice WhatsApp status update. ' + FileNote,
inputSchema: VoiceStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVoice({ session, ...body }: z.infer<typeof VoiceStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/voice`,
data: body,
});
}
@Tool('status-send-video', {
title: 'Send video status',
description: 'Post a video WhatsApp status update. ' + FileNote,
inputSchema: VideoStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVideo({ session, ...body }: z.infer<typeof VideoStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/video`,
data: body,
});
}
@Tool('status-delete', {
title: 'Delete status',
description: 'Delete a posted WhatsApp status update',
inputSchema: DeleteStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, ...body }: z.infer<typeof DeleteStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/delete`,
data: body,
});
}
@Tool('status-new-message-id', {
title: 'Generate status message ID',
description:
'Generate a new message ID to use when sending a status update',
inputSchema: StatusSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async newMessageId({ session }: z.infer<typeof StatusSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/status/new-message-id`,
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
const SessionField = z.string().describe('Session name');
export const StatusSessionInput = z.object({ session: SessionField });
export const TextStatusInput = DtoToZod(TextStatus).extend({
session: SessionField,
});
export const ImageStatusInput = DtoToZod(ImageStatus).extend({
session: SessionField,
});
export const VoiceStatusInput = DtoToZod(VoiceStatus).extend({
session: SessionField,
});
export const VideoStatusInput = DtoToZod(VideoStatus).extend({
session: SessionField,
});
export const DeleteStatusInput = DtoToZod(DeleteStatusRequest).extend({
session: SessionField,
});
+1
View File
@@ -190,6 +190,7 @@ export class SwaggerConfiguratorCore {
const config = this.app.get(WhatsappConfigService);
const exclude = lodash.uniq([
'/api/',
'/mcp',
dashboardConfig.dashboardUri,
'/health',
'/ping',
+68
View File
@@ -0,0 +1,68 @@
import { ReachoutTimelockData } from '@waha/structures/sessions.dto';
import { LongTimeout, setLongTimeout } from '@waha/utils/promiseTimeout';
import { EnsureMilliseconds } from '@waha/utils/timehelper';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Observable, Subject } from 'rxjs';
/**
* WhatsApp "reachout timelock"
* The account is restricted from messaging new contacts for a period of time (the cause of 463 errors on send).
* Keeps the latest known state, expires it when the enforcement ends and emits changes.
*/
export class ReachoutTimelockTracker {
private timelock: ReachoutTimelockData | null = null;
private timeout?: LongTimeout;
private changes: Subject<ReachoutTimelockData | null> = new Subject();
constructor(private logger: Logger) {}
get value(): ReachoutTimelockData | null {
return this.timelock;
}
get changes$(): Observable<ReachoutTimelockData | null> {
return this.changes;
}
update(timelock: ReachoutTimelockData | null) {
if (timelock && !timelock.isActive && !this.timelock) {
// No enforcement has been seen for the account - keep it null instead of storing an inactive record
// (startup fetch on a clean account lands here)
return;
}
if (lodash.isEqual(this.timelock, timelock)) {
return;
}
this.timeout?.clear();
if (timelock?.isActive && timelock.timeEnforcementEnds) {
const endsAtMs = EnsureMilliseconds(timelock.timeEnforcementEnds);
const delay = Math.max(endsAtMs - Date.now(), 0);
this.timeout = setLongTimeout(() => this.expire(), delay).unref();
}
this.apply(timelock);
}
/**
* Stop the expiry timer, keeping the last known state.
*/
stop() {
this.timeout?.clear();
}
private expire() {
if (!this.timelock?.isActive) {
return;
}
this.apply({ ...this.timelock, isActive: false });
}
private apply(timelock: ReachoutTimelockData | null) {
this.timelock = timelock;
this.logger.info(
{ reachoutTimelock: timelock },
'Reachout timelock updated',
);
this.changes.next(timelock);
}
}
+163
View File
@@ -0,0 +1,163 @@
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
import {
MeInfo,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { WASessionStatusBody } from '@waha/structures/webhooks.dto';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {
public getSessionMeInfo(): MeInfo | null {
// pushName and id must be set so the SESSION_STATUS pipeline does not delay WORKING statuses
return { id: '123@c.us', pushName: 'Test' };
}
public setStatusPublic(status: WAHASessionStatus) {
this.setStatus(status);
}
public updateReachoutTimelockPublic(timelock: ReachoutTimelockData | null) {
this.reachoutTimelock.update(timelock);
}
}
function buildSession(): TestSession {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
const ACTIVE_TIMELOCK: ReachoutTimelockData = {
enforcementType: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
isActive: true,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 3600,
};
describe('WhatsappSession reachout timelock', () => {
let session: TestSession;
let statuses: WASessionStatusBody[];
beforeEach(() => {
session = buildSession();
statuses = [];
session
.getEventObservable(WAHAEvents.SESSION_STATUS)
.subscribe((body: WASessionStatusBody) => statuses.push(body));
});
it('re-issues WORKING with data when a timelock arrives', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
expect(statuses).toHaveLength(2);
expect(statuses[1].status).toEqual(WAHASessionStatus.WORKING);
expect(statuses[1].data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('keeps attaching the timelock on plain WORKING assignments', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
// Reconnect flows assign 'status = WORKING' with no data
session.setStatusPublic(WAHASessionStatus.STARTING);
session.setStatusPublic(WAHASessionStatus.WORKING);
const last = statuses.at(-1);
expect(last.status).toEqual(WAHASessionStatus.WORKING);
expect(last.data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('collapses consecutive WORKING statuses with the same data', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.setStatusPublic(WAHASessionStatus.WORKING);
session.setStatusPublic(WAHASessionStatus.WORKING);
expect(statuses).toHaveLength(2);
});
it('ignores duplicate timelock updates', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.updateReachoutTimelockPublic({ ...ACTIVE_TIMELOCK });
expect(statuses).toHaveLength(2);
});
it('ignores an inactive timelock when none has been seen', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
isActive: false,
timeEnforcementEnds: null,
});
expect(statuses).toHaveLength(1);
expect(statuses[0].data).toBeNull();
});
it('re-issues WORKING when the timelock is lifted by an event', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
const lifted = { ...ACTIVE_TIMELOCK, isActive: false };
session.updateReachoutTimelockPublic(lifted);
expect(statuses).toHaveLength(3);
expect(statuses[2].data).toEqual({ reachoutTimelock: lifted });
});
it('marks the timelock inactive when the enforcement expires', () => {
jest.useFakeTimers();
try {
const endsAt = Math.floor(Date.now() / 1000) + 600;
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: endsAt,
});
jest.advanceTimersByTime(601 * 1000);
const last = statuses.at(-1);
expect(last.data.reachoutTimelock.isActive).toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not fire the expiry timer after the session stops', () => {
jest.useFakeTimers();
try {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 600,
});
session.setStatusPublic(WAHASessionStatus.STOPPED);
const count = statuses.length;
jest.advanceTimersByTime(601 * 1000);
expect(statuses).toHaveLength(count);
} finally {
jest.useRealTimers();
}
});
});
+122 -34
View File
@@ -1,10 +1,7 @@
import {
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
} from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
} from '@waha/core/media/IConverter';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
import { MessagesForRead } from '@waha/core/utils/convertors';
import {
IgnoreJidConfig,
@@ -67,6 +64,10 @@ import {
import { distinctUntilChanged, map } from 'rxjs/operators';
import { MessageId } from 'whatsapp-web.js';
import {
PasskeyChallenge,
PasskeyConfirmationResponse,
} from '../../structures/auth.dto';
import {
ChatRequest,
CheckNumberStatusQuery,
@@ -107,6 +108,7 @@ import {
GroupParticipant,
GroupsListFields,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '../../structures/groups.dto';
import { WAHAChatPresences } from '../../structures/presence.dto';
@@ -145,7 +147,6 @@ const qrcode = require('qrcode-terminal');
axiosRetry(axios, { retries: 3 });
export function ensureSuffix(phone) {
const suffix = '@c.us';
if (phone.includes('@')) {
@@ -168,6 +169,19 @@ export interface SessionParams {
ignore: IgnoreJidConfig;
}
/**
* A status change, along with the extra info that belongs to that status
* (if any) - like the passkey challenge for PASSKEY_REQUIRED.
*/
interface SessionStatusUpdate {
status: WAHASessionStatus;
data: any;
}
interface SessionStatusUpdatePoint extends SessionStatusUpdate {
timestamp: number;
}
export abstract class WhatsappSession {
public engine: WAHAEngine;
@@ -183,6 +197,8 @@ export abstract class WhatsappSession {
protected jids: JidFilter;
private _status: WAHASessionStatus;
private _statusData: any = null;
protected reachoutTimelock: ReachoutTimelockTracker;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
@@ -195,7 +211,7 @@ export abstract class WhatsappSession {
private shouldPrintQR: boolean;
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
private status$: Subject<WAHASessionStatus>;
private status$: Subject<SessionStatusUpdate>;
protected profilePictures: NodeCache = new NodeCache({
stdTTL: 24 * 60 * 60, // 1 day
});
@@ -208,7 +224,7 @@ export abstract class WhatsappSession {
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public mediaConverter: IMediaConverter;
public constructor({
name,
@@ -222,12 +238,22 @@ export abstract class WhatsappSession {
ignore,
}: SessionParams) {
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<WAHASessionStatus>();
this.status$ = new Subject<SessionStatusUpdate>();
this.name = name;
this.proxyConfig = proxyConfig;
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
this.reachoutTimelock.changes$.subscribe((timelock) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
reachoutTimelock: timelock,
});
}
});
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -256,46 +282,56 @@ export abstract class WhatsappSession {
// Wait for WORKING status to get all the info
// https://github.com/devlikeapro/waha/issues/409
.pipe(
switchMap((status: WAHASessionStatus) => {
switchMap((update: SessionStatusUpdate) => {
const me = this.getSessionMeInfo();
const hasMe = !!me?.pushName && !!me?.id;
// Delay WORKING by 1 second if condition is met
// Usually we get WORKING with all the info after
if (status === WAHASessionStatus.WORKING && !hasMe) {
return of(status).pipe(delay(2000));
if (update.status === WAHASessionStatus.WORKING && !hasMe) {
return of(update).pipe(delay(2000));
}
return of(status);
return of(update);
}),
// Remove consecutive duplicate WORKING statuses
// Remove consecutive duplicate WORKING statuses, but let through WORKING re-issued with new data
distinctUntilChanged(
(prev, curr) => prev === curr && curr === WAHASessionStatus.WORKING,
(prev, curr) =>
prev.status === curr.status &&
curr.status === WAHASessionStatus.WORKING &&
lodash.isEqual(prev.data, curr.data),
),
// attach current time (ms)
timestamp(),
map(
({ value, timestamp }) =>
({
status: value,
status: value.status,
timestamp: timestamp,
}) as SessionStatusPoint,
data: value.data,
}) as SessionStatusUpdatePoint,
),
// keep the last 3 entries
scan<SessionStatusPoint, SessionStatusPoint[]>(
(statuses, status: SessionStatusPoint) => {
const next = [...statuses, status];
scan<SessionStatusUpdatePoint, SessionStatusUpdatePoint[]>(
(points, point: SessionStatusUpdatePoint) => {
const next = [...points, point];
return next.length > 3 ? next.slice(-3) : next;
},
[],
),
// shape final payload
map(
(statuses) =>
({
name: this.name,
status: statuses.at(-1)?.status, // current
statuses: statuses,
}) as WASessionStatusBody,
),
map((points) => {
const current = points.at(-1); // current
return {
name: this.name,
status: current?.status,
statuses: points.map(
(point): SessionStatusPoint => ({
status: point.status,
timestamp: point.timestamp,
}),
),
data: current?.data ?? null,
} as WASessionStatusBody;
}),
),
);
@@ -315,20 +351,48 @@ export abstract class WhatsappSession {
return this.events2.get(event);
}
public set status(value: WAHASessionStatus) {
if (this.unpairing && value !== WAHASessionStatus.STOPPED) {
/**
* Set the status along with the extra info that belongs to it.
* Plain 'status = value' assignments go through here without data,
* so the data is dropped as soon as the session moves on.
*/
protected setStatus(status: WAHASessionStatus, data: any = null) {
if (this.unpairing && status !== WAHASessionStatus.STOPPED) {
// In case of unpairing
// wait for STOPPED event, ignore the rest
return;
}
this._status = value;
this.status$.next(value);
if (
status === WAHASessionStatus.WORKING &&
data == null &&
this.reachoutTimelock.value?.isActive
) {
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the active timelock info
data = { reachoutTimelock: this.reachoutTimelock.value };
}
if (
status === WAHASessionStatus.STOPPED ||
status === WAHASessionStatus.FAILED
) {
this.reachoutTimelock?.stop();
}
this._status = status;
this._statusData = data;
this.status$.next({ status: status, data: data });
}
public set status(value: WAHASessionStatus) {
this.setStatus(value);
}
public get status() {
return this._status;
}
public get statusData() {
return this._statusData;
}
protected set presence(value: WAHAPresenceStatus) {
switch (value) {
case null:
@@ -454,6 +518,22 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getPasskeyChallenge(): PasskeyChallenge {
throw new NotImplementedByEngineError();
}
public async sendPasskeyResponse(responseJson: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public async confirmPasskey(): Promise<void> {
throw new NotImplementedByEngineError();
}
public getPasskeyConfirmation(): PasskeyConfirmationResponse {
throw new NotImplementedByEngineError();
}
abstract getScreenshot(): Promise<Buffer>;
public getSessionMeInfo(): MeInfo | null {
@@ -952,6 +1032,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getMemberAddMode(id): Promise<SettingsMemberAddMode> {
throw new NotImplementedByEngineError();
}
public setMemberAddMode(id, value) {
throw new NotImplementedByEngineError();
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
+39 -8
View File
@@ -2,7 +2,7 @@ import * as process from 'node:process';
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConfigModule } from '@nestjs/config';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
@@ -22,10 +22,15 @@ import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { parseBool } from '@waha/helpers';
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
@@ -34,6 +39,8 @@ import {
getPinoHttpUseLevel,
getPinoLogLevel,
getPinoTransport,
isDebugEnabled,
redactUrlParams,
} from '@waha/utils/logging';
import * as Joi from 'joi';
import { LoggerModule } from 'nestjs-pino';
@@ -68,7 +75,8 @@ import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore'
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
import { ApiKeyAuthService } from './auth/ApiKeyAuthService';
import { SessionService } from '@waha/core/services/SessionService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
@@ -90,11 +98,15 @@ export const IMPORTS_CORE = [
);
},
},
redact: {
paths: ['req.query["x-api-key"]'],
censor: '[REDACTED]',
},
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: req.url,
url: redactUrlParams('x-api-key', req.url, req.query),
query: req.query,
params: req.params,
}),
@@ -138,10 +150,25 @@ const IMPORTS_MEDIA = [
.default('LOCAL'),
}),
}),
MediaLocalStorageModule,
ConditionalModule.registerWhen(
MediaLocalStorageModule,
(env: NodeJS.ProcessEnv) =>
!env['WAHA_MEDIA_STORAGE'] || env['WAHA_MEDIA_STORAGE'] == 'LOCAL',
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(
MediaS3StorageModule,
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'S3',
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(
MediaPsqlStorageModule,
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'POSTGRESQL',
{ debug: isDebugEnabled() },
),
];
const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
@@ -179,15 +206,19 @@ export const PROVIDERS_BASE: Provider[] = [
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
NowebEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
MongoStoreHealthIndicator,
CheckFreeDiskSpaceIndicator,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
ApiKeyAuthService,
CaslAbilityFactory,
PoliciesGuard,
SessionService,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -196,7 +227,7 @@ export const PROVIDERS_BASE: Provider[] = [
...AppsModuleExports.providers,
];
const PROVIDERS = [
export const PROVIDERS = [
{
provide: SessionManager,
useClass: SessionManagerCore,
@@ -250,7 +281,7 @@ export class AppModuleCore {
consumer
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health');
.forRoutes('api', 'health', 'mcp');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
@@ -3,7 +3,7 @@ import { User } from '@waha/core/auth/apiKey.strategy';
import { SessionManager } from '@waha/core/abc/manager.abc';
@Injectable()
export class ApiKeyService {
export class ApiKeyAuthService {
constructor(private manager: SessionManager) {}
async get(apikey: string): Promise<User | null> {
@@ -17,6 +17,7 @@ export class ApiKeyService {
return {
isAdmin: key.isAdmin,
session: key.session,
actions: key.actions,
};
}
}
@@ -0,0 +1,54 @@
import { Request } from 'express';
import { Strategy as PassportStrategy } from 'passport-strategy';
type VerifyCallback = (err: Error | null, user?: object, info?: object) => void;
type VerifyFunction = (
apiKey: string,
verified: VerifyCallback,
req?: Request,
) => void;
export class HeaderOrQueryApiKeyStrategy extends PassportStrategy {
// Declared here because passport injects these at runtime; the base type omits them.
declare fail: (info: object, status: unknown) => void;
declare error: (err: Error) => void;
declare success: (user: object, info?: object) => void;
name: string;
passReqToCallback: boolean;
verify: VerifyFunction;
constructor(passReqToCallback: boolean, verify: VerifyFunction) {
super();
this.name = 'headerapikey';
this.passReqToCallback = passReqToCallback;
this.verify = verify;
}
authenticate(req: Request): void {
const headerKey = req.headers['x-api-key'] as string | undefined;
const queryKey = req.query['x-api-key'] as string | undefined;
const apiKey = headerKey ?? queryKey;
if (!apiKey) {
return this.fail({ message: 'Missing API Key' }, null);
}
const verified: VerifyCallback = (err, user?, info?) => {
if (err) {
return this.error(err);
}
if (!user) {
return this.fail(info, null);
}
this.success(user, info);
};
if (this.passReqToCallback) {
this.verify(apiKey, verified, req);
} else {
this.verify(apiKey, verified);
}
}
}
+2
View File
@@ -12,6 +12,7 @@ export class ApiKeyAuthMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
if (this.auth instanceof NoAuth) {
delete req.query['x-api-key'];
next();
return;
}
@@ -31,6 +32,7 @@ export class ApiKeyAuthMiddleware implements NestMiddleware {
return;
}
req.user = user;
delete req.query['x-api-key'];
next();
})(req, res, next);
}
+10 -6
View File
@@ -1,12 +1,14 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
import { SessionActions } from '@waha/core/auth/casl.types';
import { HeaderOrQueryApiKeyStrategy } from '@waha/core/auth/HeaderOrQueryApiKeyStrategy';
import { ApiKeyAuthService } from './ApiKeyAuthService';
export interface User {
isAdmin: boolean;
session?: string;
actions?: SessionActions | null;
}
function AdminUser(): User {
@@ -17,13 +19,15 @@ function AdminUser(): User {
}
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
export class ApiKeyStrategy extends PassportStrategy(
HeaderOrQueryApiKeyStrategy,
) {
constructor(
private auth: IApiKeyAuth,
private apiKeyService: ApiKeyService,
private apiKeyService: ApiKeyAuthService,
) {
// @ts-ignore
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
// @ts-ignore — PassportStrategy mixin doesn't forward constructor arg types
super(true, (apikey, done) => {
return this.validate(apikey, done);
});
}
+3 -1
View File
@@ -17,7 +17,9 @@ export class CaslAbilityFactory {
return createMongoAbility(AdminRules());
}
if (user.session) {
return createMongoAbility(SessionRules(user.session));
return createMongoAbility(
SessionRules(user.session, user.actions ?? null),
);
}
return createMongoAbility([]);
}
+60 -21
View File
@@ -1,5 +1,5 @@
import { RawRuleOf } from '@casl/ability';
import { Action, AppAbility } from './casl.types';
import { Action, AppAbility, SessionActions } from './casl.types';
export function AdminRules(): RawRuleOf<AppAbility>[] {
return [
@@ -10,36 +10,75 @@ export function AdminRules(): RawRuleOf<AppAbility>[] {
];
}
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
return [
//
// Server
//
const DefaultSessionActions: SessionActions = {
delete: false,
setting: true,
control: true,
app: true,
read: true,
send: true,
};
export function SessionRules(
name: string,
rules: SessionActions | null = null,
): RawRuleOf<AppAbility>[] {
const actions = rules ?? DefaultSessionActions;
const result: RawRuleOf<AppAbility>[] = [
{
action: 'read',
action: 'retrieve',
subject: 'server',
},
//
// Session
//
{
action: Action.List,
subject: 'session',
// "conditions" is not required here, we filter session list dynamically later
},
{
{ action: Action.Retrieve, subject: 'session', conditions: { name: name } },
];
if (actions.read) {
result.push({
action: Action.Read,
subject: 'session',
conditions: { name: name },
},
// {
// action: Action.Delete,
// subject: 'session',
// conditions: { name: name },
// },
{
action: Action.Use,
});
}
if (actions.send) {
result.push({
action: Action.Send,
subject: 'session',
conditions: { name: name },
},
];
});
}
if (actions.control) {
result.push({
action: Action.Control,
subject: 'session',
conditions: { name: name },
});
}
if (actions.setting) {
result.push({
action: Action.Setting,
subject: 'session',
conditions: { name: name },
});
}
if (actions.app) {
result.push({
action: Action.App,
subject: 'session',
conditions: { name: name },
});
}
if (actions.delete) {
result.push({
action: Action.Delete,
subject: 'session',
conditions: { name: name },
});
}
return result;
}
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more