Compare commits

..
41 Commits
Author SHA1 Message Date
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
197 changed files with 14515 additions and 1022 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ jobs:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
${{ matrix.tag }}
strategy:
fail-fast: false
matrix:
include:
# Chromium - x86
+1
View File
@@ -1,3 +1,4 @@
openapi.json
tmp/*
src/core/engines/gows/proto
.env
+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`
+1 -1
View File
@@ -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
+1 -1
View File
@@ -35,7 +35,7 @@ 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
+1
View File
@@ -3,6 +3,7 @@
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
"assets": [
"dashboard/**",
"core/engines/webjs/*",
+10 -4
View File
@@ -29,7 +29,7 @@
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-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": "^2.2.1",
"@wppconnect/wa-js": "^4.3.0",
"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",
@@ -116,7 +121,8 @@
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
"yaml": "^2.7.1",
"zod": "^4.3.6"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
+9 -86
View File
@@ -3,11 +3,9 @@ import {
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
@@ -17,10 +15,9 @@ import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { 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';
@ApiSecurity('api_key')
@Controller('api/keys')
@@ -30,57 +27,21 @@ 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();
}
@Put('/:id')
@@ -90,50 +51,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,
};
}
+1 -1
View File
@@ -35,7 +35,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) {}
+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) {}
+26 -1
View File
@@ -51,12 +51,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 +67,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 +80,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 +93,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 +108,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 +124,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 +134,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 +146,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 +159,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 +171,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 +186,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 +200,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 +218,7 @@ export class GroupsController {
})
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
setDescription(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@@ -218,6 +230,7 @@ export class GroupsController {
@Put(':id/subject')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Updates the group subject',
description:
@@ -234,6 +247,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 +264,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 +280,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 +297,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.',
@@ -295,6 +312,7 @@ export class GroupsController {
@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 +325,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 +340,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 +352,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 +365,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 +379,7 @@ export class GroupsController {
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Remove participants',
})
@@ -372,6 +395,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 +409,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;
}
+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);
}
}
+34
View File
@@ -0,0 +1,34 @@
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));
}
}
+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();
}
}
+58
View File
@@ -0,0 +1,58 @@
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';
@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),
]);
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'),
});
+97
View File
@@ -0,0 +1,97 @@
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 {
AuthQRInput,
AuthRequestCodeInput,
ScreenshotInput,
} from '@waha/apps/mcp/tools/auth.zod';
function AuthContent(key: string): any {
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, add "?x-api-key=${key}" to the query params.
`,
};
}
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`);
result.content.push(AuthContent(this.api.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}`,
);
result.content.push(AuthContent(this.api.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;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { 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(),
});
+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,
});
+382
View File
@@ -0,0 +1,382 @@
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 FetchMediaUsingApiKeyContent(key: string) {
return {
type: 'text' as const,
text: `To fetch media use "X-Api-Key: ${key}" HTTP header. If the user wants to open it - add "?x-api-key=${key}" to query params`,
};
}
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) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
}
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) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
}
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,
});
+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',
+5 -9
View File
@@ -1,10 +1,6 @@
import {
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
} from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
} from '@waha/core/media/IConverter';
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,
@@ -145,7 +141,6 @@ const qrcode = require('qrcode-terminal');
axiosRetry(axios, { retries: 3 });
export function ensureSuffix(phone) {
const suffix = '@c.us';
if (phone.includes('@')) {
@@ -208,7 +203,7 @@ export abstract class WhatsappSession {
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public mediaConverter: IMediaConverter;
public constructor({
name,
@@ -228,6 +223,7 @@ export abstract class WhatsappSession {
this.proxyConfig = proxyConfig;
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
+37 -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';
@@ -26,6 +26,10 @@ 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 +38,8 @@ import {
getPinoHttpUseLevel,
getPinoLogLevel,
getPinoTransport,
isDebugEnabled,
redactUrlParams,
} from '@waha/utils/logging';
import * as Joi from 'joi';
import { LoggerModule } from 'nestjs-pino';
@@ -68,7 +74,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 +97,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 +149,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,
@@ -183,11 +209,14 @@ export const PROVIDERS_BASE: Provider[] = [
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
MongoStoreHealthIndicator,
CheckFreeDiskSpaceIndicator,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
ApiKeyAuthService,
CaslAbilityFactory,
PoliciesGuard,
SessionService,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -196,7 +225,7 @@ export const PROVIDERS_BASE: Provider[] = [
...AppsModuleExports.providers,
];
const PROVIDERS = [
export const PROVIDERS = [
{
provide: SessionManager,
useClass: SessionManagerCore,
@@ -250,7 +279,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;
}
+25 -3
View File
@@ -12,15 +12,37 @@ export enum Action {
//
Manage = 'manage', // it's a special action in casl, meaning "any actions"
//
// Session
// Session Management
//
List = 'list', // list sessions
Read = 'read', // read session info (not messages)
Retrieve = 'retrieve', // retrieve session info (not messages)
Create = 'create', // create a new session
Delete = 'delete', // delete a session
Use = 'use', // all actions - send a message, retrieve, manage session status
Setting = 'setting', // session config: update session settings
Control = 'control', // session lifecycle: start, stop, restart, logout, authenticate
App = 'app', // manage apps
//
// Session Operations
//
Read = 'read', // read session data (messages, contacts, chats, groups, etc.)
Send = 'send', // send messages + manage session entities (groups, labels, channels, contacts, profile)
}
export type SessionActions = Partial<
Record<
Extract<
Action,
| Action.Delete
| Action.Setting
| Action.Control
| Action.App
| Action.Read
| Action.Send
>,
boolean
>
>;
type Subjects =
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
| 'all';
+46
View File
@@ -3000,6 +3000,8 @@ export namespace messages {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
duration?: number;
gifPlayback?: boolean;
externalShareFullVideoDurationInSeconds?: number;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -3007,6 +3009,12 @@ export namespace messages {
if ("duration" in data && data.duration != undefined) {
this.duration = data.duration;
}
if ("gifPlayback" in data && data.gifPlayback != undefined) {
this.gifPlayback = data.gifPlayback;
}
if ("externalShareFullVideoDurationInSeconds" in data && data.externalShareFullVideoDurationInSeconds != undefined) {
this.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
}
}
}
get duration() {
@@ -3015,22 +3023,50 @@ export namespace messages {
set duration(value: number) {
pb_1.Message.setField(this, 1, value);
}
get gifPlayback() {
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
}
set gifPlayback(value: boolean) {
pb_1.Message.setField(this, 2, value);
}
get externalShareFullVideoDurationInSeconds() {
return pb_1.Message.getFieldWithDefault(this, 3, 0) as number;
}
set externalShareFullVideoDurationInSeconds(value: number) {
pb_1.Message.setField(this, 3, value);
}
static fromObject(data: {
duration?: number;
gifPlayback?: boolean;
externalShareFullVideoDurationInSeconds?: number;
}): VideoInfo {
const message = new VideoInfo({});
if (data.duration != null) {
message.duration = data.duration;
}
if (data.gifPlayback != null) {
message.gifPlayback = data.gifPlayback;
}
if (data.externalShareFullVideoDurationInSeconds != null) {
message.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
}
return message;
}
toObject() {
const data: {
duration?: number;
gifPlayback?: boolean;
externalShareFullVideoDurationInSeconds?: number;
} = {};
if (this.duration != null) {
data.duration = this.duration;
}
if (this.gifPlayback != null) {
data.gifPlayback = this.gifPlayback;
}
if (this.externalShareFullVideoDurationInSeconds != null) {
data.externalShareFullVideoDurationInSeconds = this.externalShareFullVideoDurationInSeconds;
}
return data;
}
serialize(): Uint8Array;
@@ -3039,6 +3075,10 @@ export namespace messages {
const writer = w || new pb_1.BinaryWriter();
if (this.duration != 0)
writer.writeFloat(1, this.duration);
if (this.gifPlayback != false)
writer.writeBool(2, this.gifPlayback);
if (this.externalShareFullVideoDurationInSeconds != 0)
writer.writeUint32(3, this.externalShareFullVideoDurationInSeconds);
if (!w)
return writer.getResultBuffer();
}
@@ -3051,6 +3091,12 @@ export namespace messages {
case 1:
message.duration = reader.readFloat();
break;
case 2:
message.gifPlayback = reader.readBool();
break;
case 3:
message.externalShareFullVideoDurationInSeconds = reader.readUint32();
break;
default: reader.skipField();
}
}
+61 -1
View File
@@ -7749,7 +7749,9 @@ proto.messages.VideoInfo.prototype.toObject = function(opt_includeInstance) {
*/
proto.messages.VideoInfo.toObject = function(includeInstance, msg) {
var f, obj = {
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0)
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0),
gifplayback: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
externalsharefullvideodurationinseconds: jspb.Message.getFieldWithDefault(msg, 3, 0)
};
if (includeInstance) {
@@ -7790,6 +7792,14 @@ proto.messages.VideoInfo.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {number} */ (reader.readFloat());
msg.setDuration(value);
break;
case 2:
var value = /** @type {boolean} */ (reader.readBool());
msg.setGifplayback(value);
break;
case 3:
var value = /** @type {number} */ (reader.readUint32());
msg.setExternalsharefullvideodurationinseconds(value);
break;
default:
reader.skipField();
break;
@@ -7826,6 +7836,20 @@ proto.messages.VideoInfo.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getGifplayback();
if (f) {
writer.writeBool(
2,
f
);
}
f = message.getExternalsharefullvideodurationinseconds();
if (f !== 0) {
writer.writeUint32(
3,
f
);
}
};
@@ -7847,6 +7871,42 @@ proto.messages.VideoInfo.prototype.setDuration = function(value) {
};
/**
* optional bool gifPlayback = 2;
* @return {boolean}
*/
proto.messages.VideoInfo.prototype.getGifplayback = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.VideoInfo} returns this
*/
proto.messages.VideoInfo.prototype.setGifplayback = function(value) {
return jspb.Message.setProto3BooleanField(this, 2, value);
};
/**
* optional uint32 externalShareFullVideoDurationInSeconds = 3;
* @return {number}
*/
proto.messages.VideoInfo.prototype.getExternalsharefullvideodurationinseconds = function() {
return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0));
};
/**
* @param {number} value
* @return {!proto.messages.VideoInfo} returns this
*/
proto.messages.VideoInfo.prototype.setExternalsharefullvideodurationinseconds = function(value) {
return jspb.Message.setProto3IntField(this, 3, value);
};
+483 -26
View File
@@ -34,17 +34,16 @@ import {
getDestination,
} from '@waha/core/engines/noweb/session.noweb.core';
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import {
isJidBroadcast,
isJidGroup,
isJidNewsletter,
normalizeJid,
toCusFormat,
toJID,
@@ -77,6 +76,7 @@ import {
ChatRequest,
CheckNumberStatusQuery,
EditMessageRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
@@ -88,6 +88,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -135,7 +136,10 @@ import {
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -191,10 +195,17 @@ import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { TmpDir } from '@waha/utils/tmpdir';
import { detectMimetype } from '@waha/utils/files';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { sortObjectByValues } from '@waha/helpers';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import * as path from 'path';
import MessageServiceClient = messages.MessageServiceClient;
import * as fsp from 'fs/promises';
axiosRetry(axios, { retries: 3 });
function getGowsStorageConfig(
sessionConfig?: SessionConfig,
): messages.SessionStorageConfig {
@@ -898,12 +909,46 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
private async fileToMedia(
file: RemoteFile | BinaryFile,
): Promise<messages.Media> {
let content: Buffer;
if ('url' in file) {
// fetch file
content = await this.fetch(file.url);
} else {
// base64 to bytes
content = Buffer.from(file.data, 'base64');
}
return new messages.Media({
content: content,
mimetype: file.mimetype,
filename: file.filename,
});
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
@Activity()
protected async setProfilePicture(
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
const request = new messages.SetProfilePictureRequest({
session: this.session,
picture: media.content,
});
const response = await promisify(this.client.SetProfilePicture)(request);
response.toObject();
return true;
}
protected async deleteProfilePicture(): Promise<boolean> {
const request = new messages.SetProfilePictureRequest({
session: this.session,
});
const response = await promisify(this.client.SetProfilePicture)(request);
response.toObject();
return true;
}
/**
@@ -931,6 +976,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
async sendText(request: MessageTextRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
text: request.text,
session: this.session,
@@ -976,6 +1022,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
vcard: toVcardV3(el),
}));
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
@@ -990,6 +1037,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
async sendPoll(request: MessagePollRequest) {
const jid = normalizeJid(toJID(request.chatId));
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
@@ -1004,12 +1052,53 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(jid, data);
}
sendPollVote(request: MessagePollVoteRequest) {
throw new AvailableInPlusVersion('Poll voting');
@Activity()
async sendPollVote(request: MessagePollVoteRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const key = parseMessageIdSerialized(request.pollMessageId, true);
const pollVote = new messages.PollVoteMessage({
pollMessageId: key.id,
options: request.votes,
});
if (request.pollServerId != null) {
// protobuf expects int64 number
pollVote.pollServerId = request.pollServerId;
}
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
pollVote: pollVote,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
sendList(request: SendListRequest): Promise<any> {
throw new AvailableInPlusVersion();
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
);
}
const m = request.message;
const list = messages.ListMessage.fromObject({
title: m.title,
description: m.description,
footer: m.footer,
button: m.button,
sections: m.sections,
});
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
list: list,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
@@ -1109,6 +1198,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
async sendLocation(request: MessageLocationRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
@@ -1127,22 +1217,209 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
throw new NotImplementedByEngineError();
}
sendImage(request: MessageImageRequest) {
throw new AvailableInPlusVersion();
private async sendMedia(type: messages.MediaType, request: any) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const media = await this.fileToMedia(request.file);
media.type = type;
if (type === messages.MediaType.IMAGE) {
media.mimetype = media.mimetype || WAMimeType.IMAGE;
} else if (type === messages.MediaType.AUDIO) {
media.mimetype = media.mimetype || WAMimeType.VOICE;
} else if (
type === messages.MediaType.VIDEO ||
type === messages.MediaType.PTV
) {
media.mimetype = media.mimetype || WAMimeType.VIDEO;
} else if (type === messages.MediaType.DOCUMENT) {
if (!media.mimetype) {
media.mimetype = await detectMimetype(media.content as Buffer);
}
}
if (request.convert) {
switch (type) {
case messages.MediaType.AUDIO:
media.content = await this.mediaConverter.voice(
media.content as Buffer,
);
media.mimetype = WAMimeType.VOICE;
break;
case messages.MediaType.VIDEO:
media.content = await this.mediaConverter.video(
media.content as Buffer,
);
media.mimetype = WAMimeType.VIDEO;
break;
case messages.MediaType.PTV:
media.content = await this.mediaConverter.video(
media.content as Buffer,
);
media.mimetype = WAMimeType.VIDEO;
break;
default:
this.logger.warn(`No conversion for ${type}`);
break;
}
}
// Only for Voice Status
let backgroundColor: messages.OptionalString | null = null;
if (request.backgroundColor) {
backgroundColor = new messages.OptionalString({
value: request.backgroundColor,
});
}
const participants = await this.prepareJidsForStatus(request.contacts);
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
text: request.caption,
session: this.session,
media: media,
backgroundColor: backgroundColor,
mentions: request.mentions?.map((mention) =>
normalizeJid(toJID(mention)),
),
participants: participants,
});
if (media.type == messages.MediaType.AUDIO) {
const logger: any = this.loggerBuilder.child({});
const buffer = Buffer.from(media.content);
const waveform = await esm.b.getAudioWaveform(buffer, logger);
const duration = await esm.b.getAudioDuration(buffer);
media.audio = new messages.AudioInfo({
waveform: waveform,
duration: duration,
});
}
if (
media.type == messages.MediaType.VIDEO ||
media.type == messages.MediaType.PTV
) {
const buffer = Buffer.from(media.content);
const duration = await esm.b.getAudioDuration(buffer).catch((err) => {
this.logger.warn({ error: err }, 'Failed to get video duration');
return undefined;
});
const isGif = request.file?.mimetype === 'image/gif';
media.video = new messages.VideoInfo({
duration: duration,
gifPlayback: isGif,
externalShareFullVideoDurationInSeconds: isGif ? 0 : undefined,
});
}
message.replyTo = getMessageIdFromSerialized(request.reply_to);
const tmpdir = new TmpDir(this.logger, `waha-smedia-${this.name}-`);
return await tmpdir.use(async (dir) => {
const file = path.join(dir, 'send-media.tmp');
// Try to write to the file
try {
await fsp.writeFile(file, Buffer.from(media.content));
media.contentPath = file;
media.content = null;
} catch (e) {
this.logger.error(`Failed to write media to temp file: ${e.message}`);
}
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
});
}
sendFile(request: MessageFileRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
return await this.sendMedia(messages.MediaType.IMAGE, request);
}
sendVoice(request: MessageVoiceRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
return await this.sendMedia(messages.MediaType.DOCUMENT, request);
}
sendLinkCustomPreview(
@Activity()
async sendVoice(request: MessageVoiceRequest) {
return await this.sendMedia(messages.MediaType.AUDIO, request);
}
@Activity()
async sendVideo(request: MessageVideoRequest) {
const type = request.asNote
? messages.MediaType.PTV
: messages.MediaType.VIDEO;
return await this.sendMedia(type, request);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
throw new AvailableInPlusVersion();
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const media = await this.fileToMedia(request.preview.image as RemoteFile);
const preview = new messages.LinkPreview({
url: request.preview.url,
title: request.preview.title,
description: request.preview.description,
image: media.content,
});
const message = new messages.MessageRequest({
jid: jid,
text: request.text,
session: this.session,
linkPreview: true,
linkPreviewHighQuality: request.linkPreviewHighQuality,
replyTo: getMessageIdFromSerialized(request.reply_to),
preview: preview,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
async sendButtonsReply(request: MessageButtonReply) {
throw new NotImplementedByEngineError();
// Doesn't work yet
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.ButtonReplyRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.replyTo),
selectedDisplayText: request.selectedDisplayText,
selectedButtonID: request.selectedButtonID,
});
const response = await promisify(this.client.SendButtonReply)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
public async sendImageStatus(status: ImageStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.IMAGE, request);
}
@Activity()
public async sendVoiceStatus(status: VoiceStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.AUDIO, request);
}
@Activity()
public async sendVideoStatus(status: VideoStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.VIDEO, request);
}
@Activity()
@@ -1334,6 +1611,33 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
await promisify(this.client.SetGroupName)(req);
}
@Activity()
protected async setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
const request = new messages.SetPictureRequest({
session: this.session,
jid: id,
picture: media.content,
});
const response = await promisify(this.client.SetGroupPicture)(request);
response.toObject();
return true;
}
@Activity()
protected async deleteGroupPicture(id: string): Promise<boolean> {
const request = new messages.SetPictureRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.SetGroupPicture)(request);
response.toObject();
return true;
}
@Activity()
public async getInviteCode(id): Promise<string> {
const req = new messages.JidRequest({
@@ -1607,23 +1911,122 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
@Activity()
public async searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const request = new messages.SearchNewslettersByViewRequest({
session: this.session,
view: query.view,
categories: query.categories,
countries: query.countries,
page: new messages.SearchPage({
limit: query.limit,
startCursor: query.startCursor,
}),
});
const response = await promisify(this.client.SearchNewslettersByView)(
request,
);
return this.channelsRawDataToResponse(response);
}
public searchChannelsByText(
@Activity()
public async searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const request = new messages.SearchNewslettersByTextRequest({
session: this.session,
text: query.text,
categories: query.categories,
page: new messages.SearchPage({
limit: query.limit,
startCursor: query.startCursor,
}),
});
const response = await promisify(this.client.SearchNewslettersByText)(
request,
);
return this.channelsRawDataToResponse(response);
}
private channelsRawDataToResponse(
data: messages.NewsletterSearchPageResult,
): ChannelListResult {
const channels: Channel[] = data.newsletters.newsletters.map(
this.toChannel.bind(this),
);
channels.forEach((channel) => {
delete channel.role;
});
return {
page: {
startCursor: data.page.startCursor,
endCursor: data.page.endCursor,
hasNextPage: data.page.hasNextPage,
hasPreviousPage: data.page.hasPreviousPage,
},
channels: channels,
};
}
@Activity()
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
const downloadMedia = query.downloadMedia;
const request = new messages.GetNewsletterMessagesByInviteRequest({
session: this.session,
invite: inviteCode,
limit: query.limit,
});
const response = await promisify(this.client.GetNewsletterMessagesByInvite)(
request,
);
const resp = parseJson(response);
const promises = [];
if (!resp.Messages) {
return [];
}
for (const msg of resp.Messages) {
promises.push(
this.GowsChannelMessageToChannelMessage(
resp.NewsletterJID,
msg,
downloadMedia,
),
);
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
return result;
}
private async GowsChannelMessageToChannelMessage(
jid: string,
channelMessage: any,
downloadMedia: boolean,
): Promise<ChannelMessage> {
const msg = {
Info: {
ID: channelMessage.MessageID,
ServerID: channelMessage.MessageServerID,
Chat: jid,
Sender: jid,
IsFromMe: false,
Timestamp: channelMessage.Timestamp,
},
Message: channelMessage.Message,
};
const message = await this.processIncomingMessage(msg, downloadMedia);
const reactions: any =
sortObjectByValues(channelMessage.ReactionCounts) || {};
return {
message: message,
reactions: reactions,
viewCount: channelMessage.ViewsCount,
};
}
protected toChannel(newsletter: messages.Newsletter): Channel {
@@ -1671,10 +2074,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async channelsCreateChannel(
request: CreateChannelRequest,
): Promise<Channel> {
let media: messages.Media;
if (request.picture) {
media = await this.fileToMedia(request.picture);
}
const req = new messages.CreateNewsletterRequest({
session: this.session,
name: request.name,
description: request.description,
picture: media?.content,
});
const response = await promisify(this.client.CreateNewsletter)(req);
const newsletter = response.toObject() as messages.Newsletter;
@@ -2220,7 +2628,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
protected async downloadMedia(message) {
const processor = new GOWSEngineMediaProcessor(this);
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
this,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
@@ -2547,6 +2958,50 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
}
/**
* Many encrypted stickers carry URL "https://a.whatsapp.net" with no path. If
* that string is passed to DownloadMedia, the Go client may attempt HTTP GET to
* that host instead of decrypting via directPath. Real CDN links use hosts
* such as mmg.whatsapp.net with a full path.
*
* GOWS also exposes stickerMessage.URL (uppercase); some paths expect url
* (lowercase). We mirror URL -> url only for real HTTP-style URLs.
*/
function isPlaceholderWhatsAppMediaUrl(url: unknown): boolean {
if (typeof url !== 'string' || url.length === 0) {
return false;
}
try {
const parsed = new URL(url.trim());
if (parsed.hostname !== 'a.whatsapp.net') {
return false;
}
const path = parsed.pathname.replace(/\/+$/, '');
return path === '';
} catch {
return false;
}
}
function normalizeGowsStickerUrlForDownload(message: any): any {
const sticker = message?.Message?.stickerMessage;
if (!sticker) {
return message;
}
if (isPlaceholderWhatsAppMediaUrl(sticker.URL)) {
delete sticker.URL;
}
if (isPlaceholderWhatsAppMediaUrl(sticker.url)) {
delete sticker.url;
}
if (sticker.URL && !sticker.url) {
sticker.url = sticker.URL;
}
return message;
}
export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
constructor(public session: WhatsappSessionGoWSCore) {}
@@ -2570,6 +3025,8 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
async getMediaBuffer(message: any): Promise<Buffer | null> {
const mediaDownloadTimeoutMs = 600_000; // 10 minutes
message = normalizeGowsStickerUrlForDownload(message);
const data = JSON.stringify(message.Message);
const tmpdir = new TmpDir(
this.session.logger,
@@ -3,15 +3,23 @@ import * as path from 'node:path';
import { DataStore } from '@waha/core/abc/DataStore';
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { GowsAuth } from './GowsAuth';
export class GowsAuthFactoryCore {
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
await store.init(name);
const connection = store.getSessionDbURL(name);
return new GowsAuthSimple(connection, 'postgres');
}
protected async buildSqlite3(
store: LocalStore,
name: string,
+41 -2
View File
@@ -1,17 +1,56 @@
import { DataStore } from '../../abc/DataStore';
import { LocalStore } from '../../storage/LocalStore';
import { DataStore } from '@waha/core/abc/DataStore';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
import { join } from 'path';
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
import { useMultiFileAuthState } from './useMultiFileAuthState';
export class NowebAuthFactoryCore {
buildAuth(store: DataStore, name: string) {
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
private async buildMongoAuth(store: MongoStore, name: string) {
const db = store.getSessionDb(name);
const authStore = new NoWebMongoDbAuth(db);
await authStore.init();
return authStore.methods();
}
async buildPsql(store: PsqlStore, name: string) {
const knex = store.buildSessionKnex(name, 'Session/Auth');
const authStore = new NoWebPsqlAuth(knex);
await authStore.init();
return authStore.methods();
}
protected async buildLocalAuth(store: LocalStore, name: string) {
// Quick fix for
// https://github.com/devlikeapro/waha/issues/347
// We can remove it after some time and the fix
// https://github.com/WhiskeySockets/Baileys/pull/824
await this.makeCredsJsonValid(store, name);
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
return { state, saveCreds, close };
}
private async makeCredsJsonValid(store: LocalStore, name: string) {
try {
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const credsPath = join(authFolder, 'creds.json');
await makeSureJsonFile(credsPath);
} catch (error) {
console.error('Failed to fix "creds.json" file, continue...', error);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import type makeWASocket from '@adiwajshing/baileys';
import type { QueryIds } from '@adiwajshing/baileys';
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
import {
ChannelPagination,
ChannelSearchByText,
ChannelSearchByView,
} from '@waha/structures/channels.dto';
enum NewsletterMexQueryIds {
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
}
enum NewsletterXWAPaths {
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
}
/**
* NowebClient - wrapper around baileys to have more methods
*/
export interface NewsletterSearchResponse {
page: ChannelPagination;
newsletters: NewsletterMetadata[];
}
export class NowebClient {
constructor(private sock: ReturnType<typeof makeWASocket>) {}
async searchChannelsByView(
query: ChannelSearchByView,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
view: query.view,
filters: {
country_codes: query.countries,
categories: query.categories,
},
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
async searchChannelsByText(
query: ChannelSearchByText,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
search_text: query.text,
categories: query.categories,
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
}
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
const pageInfo = response.page_info;
const page: ChannelPagination = {
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
};
const newsletterResult: any[] = response.result;
const newsletters = newsletterResult.map(toNewsletterMetadata);
return {
page: page,
newsletters: newsletters as any,
};
}
+542 -39
View File
@@ -36,6 +36,10 @@ import {
LabelAssociationType,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message';
import type {
MediaGenerationOptions,
NewsletterFetchedUpdate,
} from '@adiwajshing/baileys/lib/Types';
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { UnprocessableEntityException } from '@nestjs/common';
@@ -58,22 +62,25 @@ import {
} from '@waha/core/engines/noweb/noweb.newsletter';
import { NowebAuthFactoryCore } from '@waha/core/engines/noweb/NowebAuthFactoryCore';
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { toVcardV3 } from '@waha/core/vcard';
import { createAgentProxy } from '@waha/core/helpers.proxy';
import type { Agent } from 'https';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
import { pairs } from '@waha/utils/pairs';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { isJidNewsletter, toCusFormat, toJID } from '@waha/core/utils/jids';
import { DistinctAck } from '@waha/core/utils/reactive';
import { flipObject, splitAt } from '@waha/helpers';
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
import {
flipObject,
parseBool,
sortObjectByValues,
splitAt,
} from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import { CallData } from '@waha/structures/calls.dto';
import {
@@ -117,6 +124,7 @@ import {
MessageReplyRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -136,7 +144,7 @@ import {
WAHASessionStatus,
WAMessageAck,
} from '@waha/structures/enums.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupParticipant,
@@ -162,8 +170,11 @@ import { MeInfo } from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
StatusRequest,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -196,6 +207,7 @@ import {
} from 'rxjs';
import { debounceTime, map } from 'rxjs/operators';
import { NowebClient } from './NowebClient';
import { INowebStore } from './store/INowebStore';
import { NowebPersistentStore } from './store/NowebPersistentStore';
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
@@ -204,17 +216,30 @@ import { Agents } from '@waha/core/engines/noweb/types';
import {
IsEditedMessage,
IsHistorySyncNotification,
IsSecretEncryptedMessageEdit,
} from '@waha/core/utils/pwa';
import {
decryptSecretEncryptedMessageEditProto,
getOrigSenderJidForMsgSecret,
jidToNonAD,
} from '@waha/core/utils/secretEncryptedMessageEdit';
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import {
WAHA_CLIENT_BROWSER_NAME,
WAHA_CLIENT_DEVICE_NAME,
} from '@waha/core/env';
import { detectMimetype } from '@waha/utils/files';
import esm from '@waha/vendor/esm';
import axios from 'axios';
import axiosRetry from 'axios-retry';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
axiosRetry(axios, { retries: 3 });
export const BaileysEvents = {
CONNECTION_UPDATE: 'connection.update',
CREDS_UPDATE: 'creds.update',
@@ -898,12 +923,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
@Activity()
protected async setProfilePicture(
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const content: Buffer = await this.fileToBuffer(file);
const me = this.getSessionMeInfo();
await this.sock.updateProfilePicture(me.id, content);
return true;
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
protected async deleteProfilePicture(): Promise<boolean> {
const me = this.getSessionMeInfo();
await this.sock.removeProfilePicture(me.id);
return true;
}
/**
@@ -1054,32 +1087,209 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return await this.sock.sendMessage(request.chatId, message, options);
}
sendImage(request: MessageImageRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
const message: any = await this.fileToMessage(
request.file,
'image',
request.caption,
);
message.mimetype = message.mimetype || WAMimeType.IMAGE;
const chatId = toJID(this.ensureSuffix(request.chatId));
// Baileys' newsletter media path skips thumbnail and dimension computation.
// Pre-compute them so iOS renders the image with the correct aspect ratio.
if (isJidNewsletter(chatId)) {
try {
const thumb = await esm.b.extractImageThumb(message.image, 72);
message.jpegThumbnail = thumb.buffer;
message.width = thumb.original.width;
message.height = thumb.original.height;
} catch (err) {
this.logger.warn(
{ error: err },
'Failed to generate thumbnail for newsletter image',
);
}
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendFile(request: MessageFileRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
const message: any = await this.fileToMessage(
request.file,
'document',
request.caption,
);
if (!message.mimetype) {
message.mimetype = await detectMimetype(message['document']);
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendVoice(request: MessageVoiceRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendVoice(request: MessageVoiceRequest) {
const message: any = await this.fileToMessage(request.file, 'audio');
message.mimetype = message.mimetype || WAMimeType.VOICE;
if (request.convert) {
message['audio'] = await this.mediaConverter.voice(message['audio']);
message.mimetype = WAMimeType.VOICE;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendLinkCustomPreview(
@Activity()
async sendVideo(request: MessageVideoRequest) {
const message: any = await this.fileToMessage(
request.file,
'video',
request.caption,
);
message.mimetype = message.mimetype || WAMimeType.VIDEO;
if (request.convert) {
message['video'] = await this.mediaConverter.video(message['video']);
message.mimetype = WAMimeType.VIDEO;
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const duration = await esm.b
.getAudioDuration(message['video'])
.catch((err) => {
this.logger.warn({ error: err }, 'Failed to get video duration');
return undefined;
});
message.seconds = duration;
const isGif = request.file?.mimetype === 'image/gif';
if (isGif) {
message.gifPlayback = true;
message.externalShareFullVideoDurationInSeconds = 0;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
message.ptv = parseBool(request.asNote);
return this.sock.sendMessage(chatId, message, options);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
throw new AvailableInPlusVersion();
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
const preview = request.preview;
const urlInfo = {
'matched-text': preview.url,
title: preview.title,
description: preview.description,
jpegThumbnail: null,
highQualityThumbnail: null,
};
if (request.preview.image) {
const content: Buffer = await this.fileToBuffer(request.preview.image);
if (!request.linkPreviewHighQuality) {
// generate built-in thumbnail
const thumbnail = await esm.b.extractImageThumb(content, 192);
urlInfo.jpegThumbnail = thumbnail.buffer;
} else {
// upload HQ thumbnail
const { imageMessage } = await esm.b.prepareWAMessageMedia(
{ image: content },
{
upload: this.sock.waUploadToServer,
mediaTypeOverride: 'thumbnail-link',
options: { signal: AbortSignal.timeout(10_000) },
},
);
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
? Buffer.from(imageMessage.jpegThumbnail)
: undefined;
urlInfo.highQualityThumbnail = imageMessage;
}
}
const message = {
text: request.text,
linkPreview: urlInfo,
};
return this.sock.sendMessage(chatId, message as any, options);
}
protected async uploadMedia(
file: RemoteFile | BinaryFile,
type,
type: any,
): Promise<any> {
if (file && ('url' in file || 'data' in file)) {
throw new AvailableInPlusVersion('Sending media (image, video, pdf)');
if (!file) {
return;
}
return;
if (!('url' in file || 'data' in file)) {
return;
}
const message: any = await this.fileToMessage(file, type);
const options: MediaGenerationOptions = {
logger: this.engineLogger,
upload: this.sock.waUploadToServer,
};
const { imageMessage } = await esm.b.prepareWAMessageMedia(
message,
options,
);
return imageMessage;
}
get client(): NowebClient {
return new NowebClient(this.sock);
}
protected async fileToMessage(
file: RemoteFile | BinaryFile,
type: any,
caption = '',
) {
let content: Buffer;
if ('url' in file) {
content = await this.fetch(file.url);
} else if ('data' in file) {
content = Buffer.from(file.data, 'base64');
} else {
throw new UnprocessableEntityException(
'Either "file.url" or "file.data" must be specified.',
);
}
return {
[type]: content,
mimetype: file.mimetype,
caption: caption,
fileName: file.filename,
ptt: type === 'audio',
};
}
private async fileToBuffer(file: FileType): Promise<Buffer> {
let content: Buffer;
if ('data' in file) {
content = Buffer.from(file.data, 'base64');
} else if ('url' in file) {
content = await this.fetch(file.url);
} else {
throw new UnprocessableEntityException(
'Either file.url or file.data must be specified.',
);
}
return content;
}
@Activity()
@@ -1097,8 +1307,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
}
sendList(request: SendListRequest): Promise<any> {
throw new AvailableInPlusVersion();
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = toJID(this.ensureSuffix(request.chatId));
if (!isLidUser(jid) && !isPnUser(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
);
}
const message = request.message;
const msg = {
text: message.description || '',
title: message.title,
buttonText: message.button,
footer: message.footer,
sections: message.sections,
} as any;
const options = await this.getMessageOptions(request);
return await this.sock.sendMessage(jid, msg, options);
}
@Activity()
@@ -1618,6 +1844,22 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Group methods
*/
@Activity()
protected async setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const content: Buffer = await this.fileToBuffer(file);
await this.sock.updateProfilePicture(id, content);
return true;
}
@Activity()
protected async deleteGroupPicture(id: string): Promise<boolean> {
await this.sock.removeProfilePicture(id);
return true;
}
@Activity()
public createGroup(request: CreateGroupRequest) {
const participants = request.participants.map(getId);
@@ -1889,6 +2131,84 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
}
@Activity()
public async sendImageStatus(status: ImageStatus) {
const message: any = await this.fileToMessage(
status.file,
'image',
status.caption,
);
message.mimetype = message.mimetype || WAMimeType.IMAGE;
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
@Activity()
public async sendVoiceStatus(status: VoiceStatus) {
const message: any = await this.fileToMessage(status.file, 'audio');
message.mimetype = message.mimetype || WAMimeType.VOICE;
if (status.convert) {
message['audio'] = await this.mediaConverter.voice(message['audio']);
message.mimetype = WAMimeType.VOICE;
}
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
backgroundColor: status.backgroundColor,
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
@Activity()
public async sendVideoStatus(status: VideoStatus) {
const message: any = await this.fileToMessage(
status.file,
'video',
status.caption,
);
message.mimetype = message.mimetype || WAMimeType.VIDEO;
if (status.convert) {
message['video'] = await this.mediaConverter.video(message['video']);
message.mimetype = WAMimeType.VIDEO;
}
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
statusJidList: jids,
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
protected prepareMessageIdForStatus(status: StatusRequest) {
if (status.id) {
this.saveSentMessageId(status.id);
@@ -1948,23 +2268,74 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
@Activity()
public async searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const response = await this.client.searchChannelsByView(query);
const channels: Channel[] = response.newsletters.map(
this.toChannel.bind(this),
);
return {
page: response.page,
channels: channels,
};
}
public searchChannelsByText(
@Activity()
public async searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const response = await this.client.searchChannelsByText(query);
const channels: Channel[] = response.newsletters.map(
this.toChannel.bind(this),
);
return {
page: response.page,
channels: channels,
};
}
@Activity()
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
const downloadMedia = query.downloadMedia;
const updates = await this.sock.newsletterFetchPreviewMessages(
'invite',
inviteCode,
query.limit,
null,
);
const promises = [];
for (const update of updates) {
promises.push(
this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia),
);
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
return result;
}
private async NewsletterFetchedUpdateToChannelMessage(
update: NewsletterFetchedUpdate,
downloadMedia: boolean,
): Promise<ChannelMessage> {
let reactions: any = Object.fromEntries(
update.reactions.map(({ code, count }) => [code, count]),
);
reactions = sortObjectByValues(reactions) || {};
const message = await this.processIncomingMessage(
update.message,
downloadMedia,
);
return {
message: message,
reactions: reactions,
viewCount: update.views,
};
}
protected toChannel(newsletter: NOWEBNewsletterMetadata): Channel {
@@ -2010,7 +2381,23 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
request.name,
request.description,
);
return this.toChannel(toNewsletterMetadata(newsletter));
const channel = this.toChannel(toNewsletterMetadata(newsletter));
if (request.picture) {
let file = request.picture;
let picture: any;
// @ts-ignore
if (file.url) {
file = file as RemoteFile;
picture = await esm.b.getStream({ url: file.url });
// @ts-ignore
} else if (file.data) {
file = file as BinaryFile;
picture = Buffer.from(file.data, 'base64');
}
await this.sock.newsletterUpdatePicture(channel.id, picture);
}
return channel;
}
public async channelsGetChannel(id: string) {
@@ -2079,10 +2466,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
messagesFromMe$ = messagesFromMe$.pipe(
mergeMap((msg) => this.processIncomingMessage(msg, true)),
filter(Boolean),
DistinctMessages(),
share(), // share it so we don't process twice in message.any
);
messagesFromOthers$ = messagesFromOthers$.pipe(
mergeMap((msg) => this.processIncomingMessage(msg, true)),
filter(Boolean),
DistinctMessages(),
share(), // share it so we don't process twice in message.any
);
const messagesFromAll$ = merge(messagesFromMe$, messagesFromOthers$);
@@ -2112,14 +2503,25 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
// Handle edited messages
const messagesEdited$ = messagesUpsert$.pipe(
filter((message) => IsEditedMessage(message.message)),
filter(
(message) =>
IsEditedMessage(message.message) ||
IsSecretEncryptedMessageEdit(message.message),
),
mergeMap(async (message): Promise<WAMessageEditedBody> => {
const waMessage = this.toWAMessage(message);
// Extract the body from editedMessage using extractBody function
const content = normalizeMessageContent(message.message);
const body = extractBody(content.protocolMessage.editedMessage) || '';
// Extract the original message ID from protocolMessage.key
const editedMessageId = content.protocolMessage.key?.id;
let body = '';
let editedMessageId: string | undefined;
if (IsEditedMessage(message.message)) {
const content = normalizeMessageContent(message.message);
body = extractBody(content.protocolMessage.editedMessage) || '';
editedMessageId = content.protocolMessage.key?.id;
} else if (IsSecretEncryptedMessageEdit(message.message)) {
const sem = message.message.secretEncryptedMessage;
editedMessageId = sem.targetMessageKey?.id;
body =
(await this.tryDecryptNOWEBSecretMessageEdit(message, sem)) || '';
}
return {
...waMessage,
body: body,
@@ -2439,6 +2841,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return;
// Ignore edit, we have a dedicated event for that
if (IsEditedMessage(message.message)) return;
// Ignore secret-encrypted message edits (mobile app format), dedicated handler routes them
if (IsSecretEncryptedMessageEdit(message.message)) return;
// Ignore history sync notifications
if (IsHistorySyncNotification(message.message)) return;
@@ -2469,6 +2873,99 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return true;
}
protected async tryDecryptNOWEBSecretMessageEdit(
editMessage: proto.IWebMessageInfo,
sem: proto.Message.ISecretEncryptedMessage,
): Promise<string> {
const targetKey = sem.targetMessageKey;
const origMsgId = targetKey?.id;
if (!origMsgId) {
return '';
}
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
Boolean,
);
let stored: proto.IWebMessageInfo | undefined;
for (const jid of jidsToTry) {
stored = await this.store?.loadMessage(jid, origMsgId);
if (stored) {
break;
}
}
if (!stored) {
this.logger.debug(
{ origMsgId: origMsgId },
'NOWEB message edit decrypt: original message not found in store',
);
return '';
}
const secretBytes =
normalizeMessageContent(stored.message)?.messageContextInfo
?.messageSecret ?? stored.message?.messageContextInfo?.messageSecret;
if (!secretBytes || secretBytes.length !== 32) {
this.logger.debug(
{ origMsgId: origMsgId },
'NOWEB message edit decrypt: missing messageSecret on original',
);
return '';
}
const origSecret = Buffer.from(secretBytes);
const encPayload = sem.encPayload ? Buffer.from(sem.encPayload) : null;
const encIv = sem.encIv ? Buffer.from(sem.encIv) : null;
if (!encPayload || !encIv) {
return '';
}
const editInfo = {
Chat: editMessage.key?.remoteJid,
Sender:
editMessage.key?.participant ||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
};
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
fromMe: targetKey.fromMe,
remoteJID: targetKey.remoteJid,
participant: targetKey.participant,
});
const candidates: string[] = [primaryOrig];
const remoteNonAD = targetKey.remoteJid
? jidToNonAD(targetKey.remoteJid)
: '';
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
candidates.push(remoteNonAD);
}
const participantNonAD = targetKey.participant
? jidToNonAD(targetKey.participant)
: '';
if (participantNonAD && !candidates.includes(participantNonAD)) {
candidates.push(participantNonAD);
}
let lastErr: unknown;
for (const origSenderJid of candidates) {
try {
const decoded = decryptSecretEncryptedMessageEditProto({
encPayload: encPayload,
encIv: encIv,
origMsgId: origMsgId,
origSenderJid: origSenderJid,
modificationSenderJid: modificationSenderJid,
origMsgSecret: origSecret,
});
const text = extractBody(decoded) || '';
if (text) {
return text;
}
} catch (err) {
lastErr = err;
}
}
this.logger.debug(
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
);
return '';
}
protected async processIncomingMessage(
message,
downloadMedia: boolean,
@@ -2766,11 +3263,16 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
protected async downloadMedia(message): Promise<WAMedia | null> {
const processor = new NOWEBEngineMediaProcessor(this, this.loggerBuilder);
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
this,
this.loggerBuilder,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
return this.mediaManager.processMedia(processor, message, this.name);
}
protected async getMessageOptions(request: {
id?: string;
chatId: string;
reply_to?: string;
}) {
@@ -2782,7 +3284,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
quoted = await this.store.loadMessage(jid, key.id);
}
const chat = await this.store.getChat(jid);
const messageId = this.generateMessageID();
const messageId = request.id ? request.id : this.generateMessageID();
this.saveSentMessageId(messageId);
return {
quoted: quoted,
ephemeralExpiration: chat?.ephemeralExpiration,
@@ -1,17 +1,37 @@
import { LocalStore } from '@waha/core/storage/LocalStore';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { DataStore } from '../../../abc/DataStore';
import { MongoStorage } from './mongodb/MongoStorage';
import { INowebStorage } from './INowebStorage';
import { PsqlStorage } from './psql/PsqlStorage';
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
export class NowebStorageFactoryCore {
createStorage(store: DataStore, name: string): INowebStorage {
if (store instanceof MongoStore) {
return this.buildStorageMongo(store, name);
}
if (store instanceof PsqlStore) {
return this.buildPsql(store, name);
}
if (store instanceof LocalStore) {
return this.buildStorageSqlite3(store, name);
}
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
private buildStorageMongo(store: MongoStore, name: string) {
const db = store.getSessionDb(name);
return new MongoStorage(db);
}
private buildPsql(store: PsqlStore, name: string) {
const knex = store.buildSessionKnex(name, 'Session/Storage');
return new PsqlStorage(knex);
}
private buildStorageSqlite3(store: LocalStore, name: string) {
const filePath = store.getFilePath(name, 'store.sqlite3');
return new Sqlite3Storage(filePath);
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more