Compare commits

...
30 Commits
Author SHA1 Message Date
devlikepro 10f666ebdc [core] 2026.4.3
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-05-07 14:14:20 +07:00
devlikepro e815dae6a4 [core] Up Dashboard - MCP app, API Keys scopes 2026-05-07 14:14:20 +07:00
devlikepro b7b57f183a [core] Apps tag for /mcp 2026-05-07 14:14:20 +07:00
devlikepro 4f63da18dd [core] Up NOWEB 2026-05-07 14:14:20 +07:00
devlikepro 751f149573 [core] Up WPP 2026-05-07 14:14:20 +07:00
devlikepro a331fd11bd [core] MCP - fix #1925
Do not restart session when updating some apps - AppDefinition.restartOnChange
2026-05-07 14:14:19 +07:00
devlikepro 376676442e [core] Auth - allow using ?x-api-key as auth 2026-05-07 14:14:19 +07:00
devlikepro 21daf501be [core] Scopes for Api Key (read/send/etc) - fix #2035 2026-05-07 14:14:19 +07:00
devlikepro 2fe7e307f0 [core] detect mimetype function 2026-05-07 14:14:19 +07:00
devlikepro bae171bfe1 [core] Add image/jpeg mimetype 2026-05-07 14:14:19 +07:00
devlikepro e3979339b4 [core] SessionService 2026-05-07 14:14:19 +07:00
devlikepro 58d2de1229 [core] GOWS - Provide message id in request 2026-05-07 14:14:19 +07:00
devlikepro 0e5f38d4a4 [core] NOWEB - Apply DistinctMessages
fix #2054
2026-05-07 14:14:18 +07:00
devlikepro 0f6c6a4147 [core] NOWEB - Provide message id in request
fix #2054
2026-05-07 14:14:18 +07:00
devlikepro 958b96029d [core] Add GET /api/:session/new-message-id 2026-05-07 14:14:18 +07:00
devlikepro 02de0c0d32 [core] Up NOWEB
Fix missing Facebook and Instagram ADs messages - fix #1922
2026-05-07 14:14:18 +07:00
devlikepro 8fd01c2b56 [core] Up NOWEB 2026-05-07 14:14:18 +07:00
devlikepro 63cfe47c83 [core] no fail-fast in dev 2026-05-07 14:14:18 +07:00
devlikepro 40519eac8c [core] Up GOWS
Add tctoken lifecycle - fix #2050
Fix "Sessions take a long time to start after server restart" - fix #2012
Fix 403 on some media download - use re-upload request from the phone for this - fix #2049
2026-05-07 14:14:18 +07:00
devlikepro 2a8158cf8d [core] Up WPP 2026-05-07 14:14:18 +07:00
devlikepro 549551b2a7 [core] ignore openapi.json 2026-05-07 14:14:18 +07:00
devlikepro d589c03da7 [core] AGENTS.md 2026-05-07 14:14:18 +07:00
devlikepro 495857ee41 [core] 2026.4.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-04-17 12:48:34 +07:00
devlikepro e04e55c4c5 [core] Up Dashboard 2026-04-17 12:48:34 +07:00
devlikepro 743e740860 [core] ChatWoot - allow "queue stop check.version"
fix #2025
2026-04-17 12:48:33 +07:00
devlikepro 65623ec1ea [core] ChatWoot - sort queues 2026-04-17 12:48:33 +07:00
devlikepro 72a301c579 [core] ChatWoot - split check.version and check.tier
fix #2025
2026-04-17 12:48:33 +07:00
devlikepro f35fd8d7c6 Revert "[core] ChatWoot: conditional agent name prefix in WA to WhatsApp templates (#1988)"
This reverts commit 1fb82c7678.
fix #2030 - [CHATWOOT] - Native Chatwoot integration sends agent name even when disabled (GOWS)
2026-04-17 12:48:33 +07:00
devlikepro 0594ca0709 [core] Rebuild sharp so it doesn't require SSE4.2 in CPU
fix #1952 -  WebAssembly.Module(): Compiling function #99 failed: Wasm SIMD unsupported @+25226
fix #1961 - Unsupported CPU: Prebuilt binaries for linux-x64 require v2 microarchitecture
2026-04-17 12:48:33 +07:00
devlikepro 4e5ebab97e [core] CPU and OS notes 2026-04-17 12:48:33 +07:00
136 changed files with 6541 additions and 787 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 -168
View File
@@ -3,179 +3,69 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
only supports the default session plus minimal media features. Plus extends
core via `src/plus` to add multi-session orchestration, richer media handling,
and external storage integrations.
- Core code must remain free from Plus-only references. A pre-commit hook
rejects the word `plus` inside core files; reuse abstractions exposed through
`@waha/core/**` instead of importing Plus modules from Core.
- Commit subjects are validated: changes that touch `src/plus` require a
`[PLUS] …` prefix and must not include non-Plus files; everything else must
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
unsure.
- WAHA ships in **Core** and **Plus** editions
- Core lives under `src/core` and supports the default session with minimal
media features
- Plus extends core via `src/plus` to add multi-session orchestration, richer
media handling, and external storage integrations
- Core code must remain free from Plus-only references (pre-commit hook rejects
"plus" in core files)
- Commit subjects: changes that touch `src/plus` require `[PLUS] …` prefix;
everything else uses `[core] …`
## Tech Stack Snapshot
## Tech Stack
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
scripts with Yarn.
- **Framework**: NestJS v11 with dependency injection, modular controllers in
`src/api`, and Pino-based logging via `nestjs-pino`.
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
storage backends (Mongo/Postgres/SQLite).
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry)
- **Framework**: NestJS v11 with dependency injection and modular controllers in
`src/api`
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`,
`WPP`). Core uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus`
with extra storage backends (Mongo/Postgres/SQLite)
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
once.
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
before adding bespoke logic.
`src/vendor/esm.ts`
- **Utilities**: RxJS streams drive webhook event fan-out. Prefer existing
helpers in `src/utils` and `src/core/utils`
## Repository Landmarks
## Key Paths
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
(Core vs Plus) and configures global interceptors/filters.
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
always thread the session name through request DTOs and guards instead of
hardcoding `default`.
- `src/main.ts`: runtime entry point; dynamically loads AppModule (Core vs Plus)
- `src/api/**`: REST controllers and WebSocket gateway
- `src/core/**`: shared abstractions (config services, engine bootstrap,
storage, session management). Core only allows the `default` session and
cleans up storage on boot.
storage, session management)
- `src/plus/**`: multi-session orchestration, advanced media services, and
external persistence layers (Mongo, Postgres). Reuse this layer when adding
Plus-only capabilities.
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
services.
external persistence layers
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
`domain.action`), helper utilities. Maintain naming consistency when
introducing new events.
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
but keep existing tests working.
`domain.action`), helper utilities
## Coding Expectations
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
logic. Lodash ships with the project—prefer its utilities over hand-rolled
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
helpers like `lodash.camelCase` stay consistent across files. When a new
third-party library seems necessary, confirm with the user, especially if the
package looks stale.
- Favor composability and long-lived solutions
- Reuse existing helpers (`parseBool`, `DefaultMap`, media factories) instead of
reinventing logic
- Stick to NestJS patterns: inject dependencies through constructors, expose
provider tokens from modules, and keep controllers free from business logic.
provider tokens from modules
- Logging goes through injected `PinoLogger` or helpers in
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Prefer named function declarations over `const` arrow functions when possible.
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- Do not write verbose ternaries like
`condition !== undefined ? condition : default`; use idiomatic helpers such as
`??` (nullish coalescing) or existing boolean parsers so flags stay readable.
- Do not place `await` or other async calls inside ternary expressions (`?:`);
use explicit `if/else` blocks instead.
- For configs, prefer runtime configurability over constants. Environment keys
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
`session.config.*` for per-session overrides. If both env and config are
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
`src/utils/logging.ts`
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
- Prefer named function declarations over `const` arrow functions
- Avoid naming unused variables with a leading underscore
- Always use explicit property names in object literals — never shorthand: write
`{ key: value }`, not `{ value }` (even when the variable name matches the
key)
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
coalescing)
- Do not place `await` or other async calls inside ternary expressions (`?:`) or
nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign
the awaited value to a variable first
- For configs, prefer runtime configurability over constants (environment keys
follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`)
- Do not use decorative comment blocks (lines of dashes/underscores with a
label) such as `// ─────────── NAME ───────────`; use plain inline comments or
no comment at all
## Language & Localization
- Keep identifiers (classes, methods, variables) and code comments in English so
the codebase stays consistent for the global team.
- Route user-visible strings through the existing i18n structure rather than
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
English as the source locale before adding translations.
## ChatWoot Integration Notes
- Avoid introducing synthetic events; map onto existing webhook or engine events
whenever plausible. Always use the official ChatWoot API client located in
`src/apps/chatwoot/client`.
- When adding events, align consumer names with webhook/event identifiers (e.g.,
`message.any`).
## Workflow Checklist
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
isolate changes to `src/plus` and ensure the commit prefix matches.
2. Lean on existing services/managers; extend the appropriate session manager
rather than branching logic inline.
3. After edits run:
- `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
@@ -193,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`
+15
View File
@@ -28,6 +28,21 @@ ADD . /git
RUN yarn install
RUN yarn build && find ./dist -name "*.d.ts" -delete
# Rebuild sharp from source on x86-64 so it runs on pre-v2 CPUs (no SSE4.2 requirement).
# The prebuilt @img/sharp-linux-x64 binary requires x86-64-v2; -march=x86-64 limits
# code generation to baseline x86-64 (SSE2 only). ARM64 prebuilts have no such
# constraint, so no rebuild is needed there.
# We call sharp's own install/build.js (not npm rebuild) so it first generates config.gypi
# with include/lib paths pointing to its bundled @img/sharp-libvips-linux-x64 package,
# then invokes node-gyp with those paths. Without this step node-gyp cannot find vips headers.
RUN if [ "$(uname -m)" = "x86_64" ]; then \
LIBVIPS_DEV_VER=$(node -e "require('/git/node_modules/sharp/package.json').devDependencies['@img/sharp-libvips-dev']" | tr -d '^') && \
npm install --prefix /tmp/sharp-dev --no-package-lock "@img/sharp-libvips-dev@${LIBVIPS_DEV_VER}" && \
cp -r /tmp/sharp-dev/node_modules/@img/sharp-libvips-dev /git/node_modules/@img/ && \
cd /git/node_modules/sharp && \
PATH="/git/node_modules/.bin:$PATH" CFLAGS="-march=x86-64" CXXFLAGS="-march=x86-64" node install/build.js; \
fi
#
# Dashboard
#
+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
+6 -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,7 @@
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/axios": "^3.0.2",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.0",
@@ -57,8 +58,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.0.2",
"@wppconnect/wa-js": "^4.1.0",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
@@ -116,7 +117,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;
}
+6
View File
@@ -6,6 +6,7 @@ import {
NoRetriesJobOptions,
} from '@waha/apps/app_sdk/constants';
import { MessageCleanupConsumer } from '@waha/apps/chatwoot/consumers/scheduled/message.cleanup';
import { CheckTierConsumer } from '@waha/apps/chatwoot/consumers/scheduled/check.tier';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import * as lodash from 'lodash';
@@ -51,6 +52,10 @@ const IMPORTS = lodash.flatten([
name: QueueName.SCHEDULED_CHECK_VERSION,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.SCHEDULED_CHECK_TIER,
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.TASK_CONTACTS_PULL,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -145,6 +150,7 @@ const PROVIDERS = [
// Scheduled
MessageCleanupConsumer,
CheckVersionConsumer,
CheckTierConsumer,
// Services
ChatWootWAHAQueueService,
ChatWootQueueService,
+12 -2
View File
@@ -20,8 +20,18 @@ export async function QueueStatus(ctx: QueueCommandContext, name: string) {
for (const status of result) {
status.name = QueueNameRepr(status.name);
}
// locked: true - last
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
const categoryOrder = ['inbox', 'whatsapp', 'task', 'scheduled'];
const categoryIndex = (name: string) => {
const category = name.split(' | ')[0];
const index = categoryOrder.indexOf(category);
return index === -1 ? categoryOrder.length : index;
};
// locked: true - last, then by category (inbox/whatsapp/task/scheduled), then by name
result = lodash.sortBy(result, [
(x) => !!x.locked,
(x) => categoryIndex(x.name),
'name',
]);
const msg = ctx.l.r('cli.cmd.queue.status.result', {
queues: result,
});
+3
View File
@@ -17,6 +17,9 @@ export async function runText(
.map((line) => line.trimStart()) // remove indentation
.join(' ') // join with space
.trim(); // final cleanup
// Strip WhatsApp/markdown formatting characters so shell-quote doesn't
// treat them as glob operators and return non-string tokens
text = text.replace(/[*_~`]/g, '');
const output = new BufferedOutput();
const program = BuildProgram(commands, ctx, output);
const argv = parse(text);
+1
View File
@@ -4,6 +4,7 @@ export enum QueueName {
//
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
SCHEDULED_CHECK_TIER = 'chatwoot.scheduled | check.tier',
//
// Task
@@ -0,0 +1,42 @@
import { Processor } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SUPPORT_US_URL } from '@waha/core/constants';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { VERSION, WAHAVersion } from '@waha/version';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootScheduledConsumer } from './base';
@Processor(QueueName.SCHEDULED_CHECK_TIER, { concurrency: JOB_CONCURRENCY })
export class CheckTierConsumer extends ChatWootScheduledConsumer {
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
super(manager, log, rmutex, CheckTierConsumer.name);
}
protected ErrorHeaderKey(): TKey {
return TKey.JOB_SCHEDULED_ERROR_HEADER;
}
protected async Process(container: DIContainer, job: Job): Promise<any> {
const logger = container.Logger();
const locale = container.Locale();
const conversation = await container
.ContactConversationService()
.InboxNotifications();
if (VERSION.tier !== WAHAVersion.CORE) {
logger.info('WAHA is not using the CORE version');
return;
}
logger.info('WAHA is using the CORE version');
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
supportUrl: SUPPORT_US_URL,
});
await conversation.incoming(supportMessage);
}
}
@@ -1,24 +1,19 @@
import { Processor } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { ILogger } from '@waha/apps/app_sdk/ILogger';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CHANGELOG_URL, SUPPORT_US_URL } from '@waha/core/constants';
import { CHANGELOG_URL } from '@waha/core/constants';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { VERSION, WAHAVersion } from '@waha/version';
import { VERSION } from '@waha/version';
import axios from 'axios';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { ChatWootScheduledConsumer } from './base';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { ChatWootScheduledConsumer } from './base';
/**
* Scheduled consumer for version checking
* Periodically checks for new versions and notifies if updates are available
*/
@Processor(QueueName.SCHEDULED_CHECK_VERSION, { concurrency: JOB_CONCURRENCY })
export class CheckVersionConsumer extends ChatWootScheduledConsumer {
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
@@ -29,34 +24,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
return TKey.JOB_SCHEDULED_ERROR_HEADER;
}
/**
* Process the version check job
* Checks for new versions and notifies if updates are available
*/
protected async Process(container: DIContainer, job: Job): Promise<any> {
await this.CheckWAHACoreVersion(container);
await this.CheckNewVersionAvailable(container);
}
private async CheckWAHACoreVersion(container: DIContainer) {
const logger = container.Logger();
const locale = container.Locale();
const conversation = await container
.ContactConversationService()
.InboxNotifications();
// If using the CORE version, send a message encouraging support
if (VERSION.tier !== WAHAVersion.CORE) {
logger.info('WAHA is not using the CORE version');
return;
}
logger.info('WAHA is using the CORE version');
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
supportUrl: SUPPORT_US_URL,
});
await conversation.incoming(supportMessage);
}
private async CheckNewVersionAvailable(container: DIContainer) {
const logger = container.Logger();
logger.info('Processing version check job');
const currentVersion = VERSION.version;
@@ -72,7 +40,6 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
`New version available: ${latestVersion} (current: ${currentVersion})`,
);
// Send a message to Chatwoot conversation about a new version
const locale = container.Locale();
const message = locale.key(TKey.WAHA_NEW_VERSION_AVAILABLE).render({
currentVersion: currentVersion,
@@ -212,7 +212,11 @@ class MessagesPullHandler {
// Perform some actions before any job started
if (options.pause) {
await this.queueManager.pause();
const queues = [
...this.queueManager.resolve('inbox'),
...this.queueManager.resolve('waha'),
];
await this.queueManager.pause(queues);
await this.activity.queue(true);
}
}
@@ -228,7 +232,11 @@ class MessagesPullHandler {
// Resume queues if paused
if (options.pause) {
await this.queueManager.resume();
const queues = [
...this.queueManager.resolve('inbox'),
...this.queueManager.resolve('waha'),
];
await this.queueManager.resume(queues);
await this.activity.queue(false);
}
// Progress summary
+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;
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 تحقق مما هو جديد في [**🆕 سجل التغييرات**]({{{changelogUrl}}})
_لإيقاف هذه الإشعارات، أرسل_ `queue stop check.version`
waha.core.version.used: |-
❤️ **ادعم WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 ادعمنا**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 পরিবর্তন লগ**]({{{changelogUrl}}}) এ নতুন কি আছে তা দেখুন
_এই বিজ্ঞপ্তিগুলি অক্ষম করতে, পাঠান_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA সমর্থন করুন!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 আমাদের সমর্থন করুন**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -328,6 +328,8 @@ waha.new.version.available: |-
👉 Sehen Sie, was neu ist im [**🆕 Änderungsprotokoll**]({{{changelogUrl}}})
_Um diese Benachrichtigungen zu deaktivieren, senden Sie_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Unterstützen Sie WAHA!**
@@ -338,14 +340,12 @@ waha.core.version.used: |-
[**🎁 Unterstützen Sie uns**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -116,14 +116,12 @@ whatsapp.contact.broadcast.suffix: 'Broadcast List'
whatsapp.contact.status.name: 'Status'
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -716,6 +714,8 @@ waha.new.version.available: |-
👉 Check out what's new in the [**🆕 Changelog**]({{{changelogUrl}}})
_To disable these notifications, send_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Support WAHA!**
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Mira qué hay de nuevo en el [**🆕 Registro de Cambios**]({{{changelogUrl}}})
_Para desactivar estas notificaciones, envía_ `queue stop check.version`
waha.core.version.used: |-
❤️ **¡Apoya a WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Apóyanos**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 موارد جدید را در [**🆕 تغییرات**]({{{changelogUrl}}}) مشاهده کنید
_برای غیرفعال کردن این اعلان‌ها، ارسال کنید_ `queue stop check.version`
waha.core.version.used: |-
❤️ **از WAHA پشتیبانی کنید!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 از ما حمایت کنید**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Découvrez les nouveautés dans le [**🆕 Journal des modifications**]({{{changelogUrl}}})
_Pour désactiver ces notifications, envoyez_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Soutenez WAHA !**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Soutenez-nous**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 בדוק מה חדש ב[**🆕 יומן שינויים**]({{{changelogUrl}}})
_כדי לבטל התראות אלו, שלח_ `queue stop check.version`
waha.core.version.used: |-
❤️ **תמוך ב-WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 תמוך בנו**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 चेंजलॉग**]({{{changelogUrl}}}) में क्या नया है देखें
_इन सूचनाओं को अक्षम करने के लिए, भेजें_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA का समर्थन करें!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 हमें समर्थन दें**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Lihat apa yang baru di [**🆕 Changelog**]({{{changelogUrl}}})
_Untuk menonaktifkan notifikasi ini, kirim_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Dukung WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Dukung Kami**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -116,14 +116,12 @@ whatsapp.contact.broadcast.suffix: 'Lista Broadcast'
whatsapp.contact.status.name: 'Stato'
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -716,6 +714,8 @@ waha.new.version.available: |-
👉 Scopri le novità nel [**🆕 Changelog**]({{{changelogUrl}}})
_Per disattivare queste notifiche, invia_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Supporta WAHA!**
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 ਬਦਲਾਅ ਲੌਗ**]({{{changelogUrl}}}) ਵਿੱਚ ਨਵਾਂ ਕੀ ਹੈ ਦੇਖੋ
_ਇਹ ਸੂਚਨਾਵਾਂ ਬੰਦ ਕਰਨ ਲਈ, ਭੇਜੋ_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA ਦਾ ਸਮਰਥਨ ਕਰੋ!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 ਸਾਡਾ ਸਮਰਥਨ ਕਰੋ**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -326,6 +326,8 @@ waha.new.version.available: |-
👉 Confira o que há de novo no [**🆕 Registro de Alterações**]({{{changelogUrl}}})
_Para desativar essas notificações, envie_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Apoie o WAHA!**
@@ -336,14 +338,12 @@ waha.core.version.used: |-
[**🎁 Apoie-nos**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Узнайте, что нового в [**🆕 Списке изменений**]({{{changelogUrl}}})
_Чтобы отключить эти уведомления, отправьте_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Поддержите WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Поддержать нас**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 Değişiklik Günlüğü**]({{{changelogUrl}}}) içinde yenilikleri kontrol edin
_Bu bildirimleri devre dışı bırakmak için gönderin_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA'yı Destekleyin!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Bizi Destekleyin**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 Перевірте, що нового у [**🆕 Журналі змін**]({{{changelogUrl}}})
_Щоб вимкнути ці сповіщення, надішліть_ `queue stop check.version`
waha.core.version.used: |-
❤️ **Підтримайте WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 Підтримайте нас**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 [**🆕 تبدیلیوں کی فہرست**]({{{changelogUrl}}}) میں نیا کیا ہے دیکھیں
_ان اطلاعات کو بند کرنے کے لیے بھیجیں_ `queue stop check.version`
waha.core.version.used: |-
❤️ **WAHA کی حمایت کریں!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 ہماری حمایت کریں**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
+4 -4
View File
@@ -325,6 +325,8 @@ waha.new.version.available: |-
👉 查看[**🆕 更新日志**]({{{changelogUrl}}})
_要禁用这些通知,请发送_ `queue stop check.version`
waha.core.version.used: |-
❤️ **支持 WAHA!**
@@ -335,14 +337,12 @@ waha.core.version.used: |-
[**🎁 支持我们**]({{{supportUrl}}})
chatwoot.to.whatsapp.message.text: |-
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
chatwoot.to.whatsapp.message.media.caption: |-
{{#singleAttachment}}
{{#content}}
{{#chatwoot.sender.name}}*{{{chatwoot.sender.name}}}*:
{{/chatwoot.sender.name}}{{{ content }}}
{{{ content }}}
{{/content}}
{{/singleAttachment}}
@@ -6,6 +6,7 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
import { App } from '@waha/apps/chatwoot/storage';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@@ -38,27 +39,31 @@ export class ChatWootAppService implements IAppService {
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Enabling -> behave like created
await this.beforeCreated(newApp);
return;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Disabling -> behave like deleted
await this.beforeDeleted(savedApp);
return;
await this.beforeDeleted(manager, savedApp);
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
) {
void manager;
const isTheSameUrl = savedApp.config.url === newApp.config.url;
const isTheSameInboxId = savedApp.config.inboxId === newApp.config.inboxId;
const isTheSameInbox = isTheSameUrl && isTheSameInboxId;
@@ -76,7 +81,11 @@ export class ChatWootAppService implements IAppService {
}
}
async beforeDeleted(app: App<ChatWootAppConfig>): Promise<void> {
async beforeDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
await this.chatWootScheduleService.unschedule(app.id, app.session);
this.cleanCache(app);
this.sendDisconnectedMessage(app).catch((err) => {
@@ -86,6 +95,30 @@ export class ChatWootAppService implements IAppService {
});
}
async afterCreated(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
private async sendConnectedMessage(app: App<ChatWootAppConfig>) {
const di = new DIContainer(0, app.config, this.logger, null);
const repo = di.ContactConversationService();
@@ -54,6 +54,21 @@ export class ChatWootScheduleService {
},
},
);
// Check the tier
const checkTierQueue = this.queueRegistry.queue(
QueueName.SCHEDULED_CHECK_TIER,
);
await checkTierQueue.upsertJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
// Every Monday (1) at 14:00
{ pattern: '0 0 14 * * 1' },
{
data: {
app: appId,
session: sessionName,
},
},
);
}
async unschedule(appId: string, sessionName: string): Promise<void> {
@@ -71,6 +86,13 @@ export class ChatWootScheduleService {
await checkVersionQueue.removeJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_VERSION, appId),
);
// Check the tier
const checkTierQueue = this.queueRegistry.queue(
QueueName.SCHEDULED_CHECK_TIER,
);
await checkTierQueue.removeJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
);
// contacts
const contactsPullQueue = this.queueRegistry.queue(
+15 -2
View File
@@ -1,6 +1,7 @@
import { QueueName } from '../consumers/QueueName';
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
import { Injectable } from '@nestjs/common';
import { QueueNameRepr } from '@waha/apps/app_sdk/JobUtils';
const Managable = true;
const Locked = false;
@@ -18,7 +19,8 @@ export class QueueManager {
constructor(private readonly registry: QueueRegistry) {
this.queues = {
[QueueName.SCHEDULED_MESSAGE_CLEANUP]: Locked,
[QueueName.SCHEDULED_CHECK_VERSION]: Locked,
[QueueName.SCHEDULED_CHECK_VERSION]: Managable,
[QueueName.SCHEDULED_CHECK_TIER]: Locked,
[QueueName.TASK_CONTACTS_PULL]: Locked,
[QueueName.TASK_MESSAGES_PULL]: Locked,
[QueueName.WAHA_SESSION_STATUS]: Locked,
@@ -75,8 +77,19 @@ export class QueueManager {
case undefined:
return queues;
default:
default: {
const matches = queues.filter((q) => q.split(' | ')[1] === shortcut);
if (matches.length === 1) {
return [matches[0]];
}
if (matches.length > 1) {
const names = matches.map(QueueNameRepr).join(', ');
throw new Error(
`Ambiguous queue name "${shortcut}", matches: ${names}`,
);
}
return [shortcut as QueueName];
}
}
}
@@ -16,6 +16,8 @@ export class QueueRegistry {
private readonly scheduledMessageCleanupQueue: Queue,
@InjectQueue(QueueName.SCHEDULED_CHECK_VERSION)
private readonly scheduledCheckVersionQueue: Queue,
@InjectQueue(QueueName.SCHEDULED_CHECK_TIER)
private readonly scheduledCheckTierQueue: Queue,
@InjectQueue(QueueName.TASK_CONTACTS_PULL)
private readonly taskContactsPullQueue: Queue,
@InjectQueue(QueueName.TASK_MESSAGES_PULL)
@@ -55,6 +57,7 @@ export class QueueRegistry {
this.queues = {
[QueueName.SCHEDULED_MESSAGE_CLEANUP]: this.scheduledMessageCleanupQueue,
[QueueName.SCHEDULED_CHECK_VERSION]: this.scheduledCheckVersionQueue,
[QueueName.SCHEDULED_CHECK_TIER]: this.scheduledCheckTierQueue,
[QueueName.TASK_CONTACTS_PULL]: this.taskContactsPullQueue,
[QueueName.TASK_MESSAGES_PULL]: this.taskMessagesPullQueue,
[QueueName.WAHA_SESSION_STATUS]: this.wahaSessionStatusQueue,
+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`,
});
}
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more