Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ed34a171 | ||
|
|
1db8ae3423 | ||
|
|
8a06ee3d44 | ||
|
|
9e11312b75 | ||
|
|
1c7ce47dbb | ||
|
|
1d72e2666d | ||
|
|
7ae2f7999e | ||
|
|
68dc4ad642 | ||
|
|
e8f63aeaad | ||
|
|
f4e19c7664 | ||
|
|
6d8ef2e375 | ||
|
|
024cbffb13 | ||
|
|
ad399d9b01 | ||
|
|
6053c162c0 | ||
|
|
6654a7b3d0 | ||
|
|
4ff1c01e38 | ||
|
|
dfe0a3c8c4 | ||
|
|
04121bda52 | ||
|
|
7b6f522f92 | ||
|
|
10f666ebdc | ||
|
|
e815dae6a4 | ||
|
|
b7b57f183a | ||
|
|
4f63da18dd | ||
|
|
751f149573 | ||
|
|
a331fd11bd | ||
|
|
376676442e | ||
|
|
21daf501be | ||
|
|
2fe7e307f0 | ||
|
|
bae171bfe1 | ||
|
|
e3979339b4 | ||
|
|
58d2de1229 | ||
|
|
0e5f38d4a4 | ||
|
|
0f6c6a4147 | ||
|
|
958b96029d | ||
|
|
02de0c0d32 | ||
|
|
8fd01c2b56 | ||
|
|
63cfe47c83 | ||
|
|
40519eac8c | ||
|
|
2a8158cf8d | ||
|
|
549551b2a7 | ||
|
|
d589c03da7 |
No files matched your search
@@ -42,6 +42,7 @@ jobs:
|
||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||
${{ matrix.tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Chromium - x86
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
openapi.json
|
||||
tmp/*
|
||||
src/core/engines/gows/proto
|
||||
.env
|
||||
|
||||
@@ -3,183 +3,69 @@
|
||||
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
|
||||
(WAHA) codebase when assisting as an automation or coding agent.
|
||||
|
||||
- When you asked to refactor or "apply new lib" instead of current one - do not
|
||||
change the behavior, make a minimum amount of changes possible. If you see
|
||||
some edge case during that process that haven't been covered - tell it,
|
||||
suggest fix, but don't change the code.
|
||||
|
||||
## Product & Variants
|
||||
|
||||
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
|
||||
only supports the default session plus minimal media features. Plus extends
|
||||
core via `src/plus` to add multi-session orchestration, richer media handling,
|
||||
and external storage integrations.
|
||||
- Core code must remain free from Plus-only references. A pre-commit hook
|
||||
rejects the word `plus` inside core files; reuse abstractions exposed through
|
||||
`@waha/core/**` instead of importing Plus modules from Core.
|
||||
- Commit subjects are validated: changes that touch `src/plus` require a
|
||||
`[PLUS] …` prefix and must not include non-Plus files; everything else must
|
||||
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
|
||||
unsure.
|
||||
- WAHA ships in **Core** and **Plus** editions
|
||||
- Core lives under `src/core` and supports the default session with minimal
|
||||
media features
|
||||
- Plus extends core via `src/plus` to add multi-session orchestration, richer
|
||||
media handling, and external storage integrations
|
||||
- Core code must remain free from Plus-only references (pre-commit hook rejects
|
||||
"plus" in core files)
|
||||
- Commit subjects: changes that touch `src/plus` require `[PLUS] …` prefix;
|
||||
everything else uses `[core] …`
|
||||
|
||||
## Tech Stack Snapshot
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
|
||||
scripts with Yarn.
|
||||
- **Framework**: NestJS v11 with dependency injection, modular controllers in
|
||||
`src/api`, and Pino-based logging via `nestjs-pino`.
|
||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
|
||||
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
|
||||
storage backends (Mongo/Postgres/SQLite).
|
||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry)
|
||||
- **Framework**: NestJS v11 with dependency injection and modular controllers in
|
||||
`src/api`
|
||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`,
|
||||
`WPP`). Core uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus`
|
||||
with extra storage backends (Mongo/Postgres/SQLite)
|
||||
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
|
||||
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
|
||||
once.
|
||||
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
|
||||
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
|
||||
before adding bespoke logic.
|
||||
- **OS** - the project works on any OS inside the Docker container; the base
|
||||
image is defined in `Dockerfile`.
|
||||
- **CPU** - the image must run on both `x86_64` (including pre-v2 CPUs without
|
||||
SSE4.2) and `aarch64`.
|
||||
`src/vendor/esm.ts`
|
||||
- **Utilities**: RxJS streams drive webhook event fan-out. Prefer existing
|
||||
helpers in `src/utils` and `src/core/utils`
|
||||
|
||||
## Repository Landmarks
|
||||
## Key Paths
|
||||
|
||||
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
|
||||
(Core vs Plus) and configures global interceptors/filters.
|
||||
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
|
||||
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
|
||||
always thread the session name through request DTOs and guards instead of
|
||||
hardcoding `default`.
|
||||
- `src/main.ts`: runtime entry point; dynamically loads AppModule (Core vs Plus)
|
||||
- `src/api/**`: REST controllers and WebSocket gateway
|
||||
- `src/core/**`: shared abstractions (config services, engine bootstrap,
|
||||
storage, session management). Core only allows the `default` session and
|
||||
cleans up storage on boot.
|
||||
storage, session management)
|
||||
- `src/plus/**`: multi-session orchestration, advanced media services, and
|
||||
external persistence layers (Mongo, Postgres). Reuse this layer when adding
|
||||
Plus-only capabilities.
|
||||
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
|
||||
services.
|
||||
external persistence layers
|
||||
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
|
||||
`domain.action`), helper utilities. Maintain naming consistency when
|
||||
introducing new events.
|
||||
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
|
||||
but keep existing tests working.
|
||||
`domain.action`), helper utilities
|
||||
|
||||
## Coding Expectations
|
||||
|
||||
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
|
||||
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
|
||||
logic. Lodash ships with the project—prefer its utilities over hand-rolled
|
||||
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
|
||||
helpers like `lodash.camelCase` stay consistent across files. When a new
|
||||
third-party library seems necessary, confirm with the user, especially if the
|
||||
package looks stale.
|
||||
- Favor composability and long-lived solutions
|
||||
- Reuse existing helpers (`parseBool`, `DefaultMap`, media factories) instead of
|
||||
reinventing logic
|
||||
- Stick to NestJS patterns: inject dependencies through constructors, expose
|
||||
provider tokens from modules, and keep controllers free from business logic.
|
||||
provider tokens from modules
|
||||
- Logging goes through injected `PinoLogger` or helpers in
|
||||
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
|
||||
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
|
||||
consistent (use absolute aliases, not relative `../../../`).
|
||||
- Prefer named function declarations over `const` arrow functions when possible.
|
||||
- Avoid naming unused variables with a leading underscore; if a parameter is
|
||||
required by a signature, explicitly `void` it instead.
|
||||
- Do not write verbose ternaries like
|
||||
`condition !== undefined ? condition : default`; use idiomatic helpers such as
|
||||
`??` (nullish coalescing) or existing boolean parsers so flags stay readable.
|
||||
- Do not place `await` or other async calls inside ternary expressions (`?:`);
|
||||
use explicit `if/else` blocks instead.
|
||||
- For configs, prefer runtime configurability over constants. Environment keys
|
||||
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
|
||||
`session.config.*` for per-session overrides. If both env and config are
|
||||
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
|
||||
`src/utils/logging.ts`
|
||||
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
|
||||
- Prefer named function declarations over `const` arrow functions
|
||||
- Avoid naming unused variables with a leading underscore
|
||||
- Always use explicit property names in object literals — never shorthand: write
|
||||
`{ key: value }`, not `{ value }` (even when the variable name matches the
|
||||
key)
|
||||
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
|
||||
coalescing)
|
||||
- Do not place `await` or other async calls inside ternary expressions (`?:`) or
|
||||
nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign
|
||||
the awaited value to a variable first
|
||||
- For configs, prefer runtime configurability over constants (environment keys
|
||||
follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`)
|
||||
- Do not use decorative comment blocks (lines of dashes/underscores with a
|
||||
label) such as `// ─────────── NAME ───────────`; use plain inline comments or
|
||||
no comment at all
|
||||
|
||||
## Language & Localization
|
||||
|
||||
- Keep identifiers (classes, methods, variables) and code comments in English so
|
||||
the codebase stays consistent for the global team.
|
||||
- Route user-visible strings through the existing i18n structure rather than
|
||||
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
|
||||
English as the source locale before adding translations.
|
||||
|
||||
## ChatWoot Integration Notes
|
||||
|
||||
- Avoid introducing synthetic events; map onto existing webhook or engine events
|
||||
whenever plausible. Always use the official ChatWoot API client located in
|
||||
`src/apps/chatwoot/client`.
|
||||
- When adding events, align consumer names with webhook/event identifiers (e.g.,
|
||||
`message.any`).
|
||||
|
||||
## Workflow Checklist
|
||||
|
||||
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
|
||||
isolate changes to `src/plus` and ensure the commit prefix matches.
|
||||
2. Lean on existing services/managers; extend the appropriate session manager
|
||||
rather than branching logic inline.
|
||||
3. After edits run:
|
||||
- `pre-commit run --all-files`
|
||||
- `yarn build`
|
||||
- `yarn test --watchman=false`
|
||||
4. Do **not** start the application yourself; ask the user to run it if runtime
|
||||
validation is required.
|
||||
5. Capture any assumptions or open questions for the user, especially when
|
||||
touching configs, introducing dependencies, or modifying public APIs.
|
||||
|
||||
## Additional Tips
|
||||
|
||||
- Concurrency-sensitive sections (session start/stop) rely on `async-lock`. When
|
||||
adding async flows, reuse `SessionManager.withLock` or existing retry
|
||||
utilities (`promiseTimeout`, `waitUntil`).
|
||||
- Media features centralize through `MediaManager` and `MediaStorageFactory`.
|
||||
When altering media behavior, update both Core and Plus variants where
|
||||
applicable.
|
||||
- Keep docs and code ASCII unless a file already uses other characters. When
|
||||
updating documentation, mirror the concise, actionable tone used here.
|
||||
|
||||
## Yes No
|
||||
|
||||
Always define key for objects (and in return too)
|
||||
|
||||
```js
|
||||
// NO
|
||||
const variable = 123;
|
||||
const b = { variable };
|
||||
|
||||
// YES
|
||||
const variable = 123;
|
||||
const b = { variable: variable };
|
||||
```
|
||||
|
||||
Use the three-line comment style for section headers inside files:
|
||||
|
||||
```ts
|
||||
// NO
|
||||
// ─── Section name ─────────────────────────────────────────────────────────────
|
||||
|
||||
// YES
|
||||
//
|
||||
// Section name
|
||||
//
|
||||
```
|
||||
|
||||
## Related Sources Code
|
||||
|
||||
You can find and read related source code in the following paths:
|
||||
|
||||
- WEBJS: `../whatsapp-web.js`
|
||||
- NOWEB: `../WhiskeySockets-Baileys`
|
||||
- whatsapp-rust-bridge - `../whatsapp-rust-bridge`
|
||||
- GOWS: `../gows`
|
||||
- whatsmeow - `../whatsmeow`
|
||||
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
|
||||
- ChatWoot: `../chatwoot`
|
||||
|
||||
Following this playbook keeps contributions aligned with WAHA’s structure,
|
||||
automation hooks, and release process.
|
||||
|
||||
## How to run API
|
||||
|
||||
You can run the project outside of sandbox using the below command, then run
|
||||
queries against `default` session (if not asked to do something different) using
|
||||
`curl` and `X-Api-Key: 666` header.
|
||||
## How to Run API
|
||||
|
||||
```bash
|
||||
export DEBUG=1
|
||||
@@ -197,17 +83,49 @@ export WHATSAPP_FILES_FOLDER=./.media
|
||||
npm run start
|
||||
```
|
||||
|
||||
- Ask user before running the server.
|
||||
- Before executing some queries make sure the session is in `WORKING` status.
|
||||
- If it's `FAILED` or `SCAN_QR_CODE` ask user to scan QR code or fix failed
|
||||
session.
|
||||
## Code Guidelines
|
||||
|
||||
## Code
|
||||
- Add `@Activity()` (from `src/core/abc/activity.ts`) to every engine method
|
||||
that makes a network call to WhatsApp servers
|
||||
- It triggers `maintainPresenceOnline()` before the method runs, keeping the
|
||||
session ONLINE during API activity and scheduling an OFFLINE transition after
|
||||
an idle period
|
||||
- Skip it on methods that only throw `NotImplementedByEngineError` /
|
||||
`AvailableInPlusVersion`
|
||||
|
||||
### @Activity Decorator and Presence Tracking
|
||||
## MCP Tools
|
||||
|
||||
Add `@Activity()` (from `src/core/abc/activity.ts`) to every engine method that
|
||||
makes a network call to WhatsApp servers. It triggers `maintainPresenceOnline()`
|
||||
before the method runs, keeping the session ONLINE during API activity and
|
||||
scheduling an OFFLINE transition after an idle period. Skip it on methods that
|
||||
only throw `NotImplementedByEngineError` / `AvailableInPlusVersion`.
|
||||
MCP tools live in `src/apps/mcp/tools/` and expose the HTTP API to AI clients.
|
||||
Each tool file mirrors an API domain (e.g. `chats.tools.ts` → chats endpoints).
|
||||
|
||||
**When you change an existing API endpoint:**
|
||||
|
||||
- Check the corresponding `*.tools.ts` file and update the tool's `inputSchema`,
|
||||
description, or behavior if the API signature changed.
|
||||
|
||||
**When you add a new API endpoint:**
|
||||
|
||||
- Ask the user whether an MCP tool is needed for the new endpoint before
|
||||
creating one.
|
||||
- If yes, add the tool to the matching `*.tools.ts` file (or create a new file
|
||||
for a new domain).
|
||||
- Every `@Tool` decorator must include an `annotations` block with all three
|
||||
fields:
|
||||
```typescript
|
||||
annotations: {
|
||||
readOnlyHint: true | false, // true = no side effects (GET-style)
|
||||
destructiveHint: true | false, // true = irreversible deletion/logout
|
||||
idempotentHint: true | false, // true = safe to repeat with same args
|
||||
}
|
||||
```
|
||||
- Input schemas live in the matching `*.zod.ts` file.
|
||||
- Tools call the API via `this.textRequest({ method, url, ... })` inherited from
|
||||
`McpController`.
|
||||
|
||||
## Related Sources
|
||||
|
||||
- WEBJS: `../whatsapp-web.js`
|
||||
- NOWEB: `../WhiskeySockets-Baileys` and `../whatsapp-rust-bridge`
|
||||
- GOWS: `../gows` and `../whatsmeow`
|
||||
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
|
||||
- ChatWoot: `../chatwoot`
|
||||
+1
-1
@@ -247,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
|
||||
ENV WAHA_ZIPPER=ZIPUNZIP
|
||||
|
||||
# GOWS - use libc DNS resolver
|
||||
ENV GODEBUG netdns=cgo
|
||||
ENV GODEBUG=netdns=cgo
|
||||
|
||||
# Run command, etc
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
|
||||
"assets": [
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
|
||||
+10
-4
@@ -29,7 +29,7 @@
|
||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-02-11",
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-04-28",
|
||||
"@adiwajshing/keyed-db": "^0.2.4",
|
||||
"@aws-sdk/client-s3": "^3.633.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
||||
@@ -39,6 +39,9 @@
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||
"@liaoliaots/nestjs-redis": "^9",
|
||||
"@lottiefiles/dotlottie-web": "^0.72.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -57,8 +60,8 @@
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@wppconnect-team/wppconnect": "^1.41.1",
|
||||
"@wppconnect/wa-js": "^3.23.3",
|
||||
"@wppconnect-team/wppconnect": "^2.2.1",
|
||||
"@wppconnect/wa-js": "^4.3.0",
|
||||
"adm-zip": "0.5.10",
|
||||
"agentkeepalive": "^4.5.0",
|
||||
"async-lock": "^1.4.1",
|
||||
@@ -93,9 +96,11 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-webpmux": "^3.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-copy-streams": "^6.0.0",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@@ -116,7 +121,8 @@
|
||||
"user-agents": "^1.1.669",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
|
||||
"write-file-atomic": "^6.0.0",
|
||||
"yaml": "^2.7.1"
|
||||
"yaml": "^2.7.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export enum AppName {
|
||||
chatwoot = 'chatwoot',
|
||||
calls = 'calls',
|
||||
mcp = 'mcp',
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ConversationSelector,
|
||||
ConversationSort,
|
||||
} from '@waha/apps/chatwoot/services/ConversationSelector';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
|
||||
/**
|
||||
* Dependency Injection Container for ChatWoot
|
||||
@@ -194,7 +195,7 @@ export class DIContainer {
|
||||
*/
|
||||
@CacheSync()
|
||||
public WAHASelf(): WAHASelf {
|
||||
const self = new WAHASelf();
|
||||
const self = new WAHASelf(Auth.keyplain.value);
|
||||
const logging = new AxiosLogging(this.Logger());
|
||||
logging.applyTo(self.client);
|
||||
return self;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
|
||||
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
|
||||
import { App } from '@waha/apps/chatwoot/storage';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@@ -38,27 +39,31 @@ export class ChatWootAppService implements IAppService {
|
||||
}
|
||||
|
||||
async beforeEnabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App<ChatWootAppConfig>,
|
||||
newApp: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
// Enabling -> behave like created
|
||||
await this.beforeCreated(newApp);
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeDisabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App<ChatWootAppConfig>,
|
||||
newApp: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
// Disabling -> behave like deleted
|
||||
await this.beforeDeleted(savedApp);
|
||||
return;
|
||||
await this.beforeDeleted(manager, savedApp);
|
||||
}
|
||||
|
||||
async beforeUpdated(
|
||||
manager: SessionManager,
|
||||
savedApp: App<ChatWootAppConfig>,
|
||||
newApp: App<ChatWootAppConfig>,
|
||||
) {
|
||||
void manager;
|
||||
const isTheSameUrl = savedApp.config.url === newApp.config.url;
|
||||
const isTheSameInboxId = savedApp.config.inboxId === newApp.config.inboxId;
|
||||
const isTheSameInbox = isTheSameUrl && isTheSameInboxId;
|
||||
@@ -76,7 +81,11 @@ export class ChatWootAppService implements IAppService {
|
||||
}
|
||||
}
|
||||
|
||||
async beforeDeleted(app: App<ChatWootAppConfig>): Promise<void> {
|
||||
async beforeDeleted(
|
||||
manager: SessionManager,
|
||||
app: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
await this.chatWootScheduleService.unschedule(app.id, app.session);
|
||||
this.cleanCache(app);
|
||||
this.sendDisconnectedMessage(app).catch((err) => {
|
||||
@@ -86,6 +95,30 @@ export class ChatWootAppService implements IAppService {
|
||||
});
|
||||
}
|
||||
|
||||
async afterCreated(
|
||||
manager: SessionManager,
|
||||
app: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
async beforeSessionDeleted(
|
||||
manager: SessionManager,
|
||||
app: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
async enrich(
|
||||
manager: SessionManager,
|
||||
app: App<ChatWootAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
private async sendConnectedMessage(app: App<ChatWootAppConfig>) {
|
||||
const di = new DIContainer(0, app.config, this.logger, null);
|
||||
const repo = di.ContactConversationService();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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] ?? [];
|
||||
}
|
||||
@@ -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] ?? [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)'),
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import {
|
||||
ProfileNameRequest,
|
||||
ProfilePictureRequest,
|
||||
ProfileStatusRequest,
|
||||
} from '@waha/structures/profile.dto';
|
||||
|
||||
const SessionField = z.string().describe('Session name');
|
||||
|
||||
export const ProfileSessionInput = z.object({ session: SessionField });
|
||||
|
||||
export const ProfileNameInput = DtoToZod(ProfileNameRequest).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const ProfileStatusInput = DtoToZod(ProfileStatusRequest).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const ProfilePictureInput = DtoToZod(ProfilePictureRequest).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { z } from 'zod';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import {
|
||||
ForwardMessageInput,
|
||||
NewMessageIdInput,
|
||||
SendEventInput,
|
||||
SendButtonsInput,
|
||||
SendButtonsReplyInput,
|
||||
SendContactVcardInput,
|
||||
SendFileInput,
|
||||
SendImageInput,
|
||||
SendLinkCustomPreviewInput,
|
||||
SendListInput,
|
||||
SendLocationInput,
|
||||
SendPollInput,
|
||||
SendPollVoteInput,
|
||||
SendSeenInput,
|
||||
SendTextInput,
|
||||
SendVideoInput,
|
||||
SendVoiceInput,
|
||||
SetReactionInput,
|
||||
SetStarInput,
|
||||
TypingInput,
|
||||
} from '@waha/apps/mcp/tools/send.zod';
|
||||
|
||||
const FileNote =
|
||||
'Use file.url for remote HTTP/HTTPS URLs. ' +
|
||||
'For local file paths (e.g. /tmp/file.ext) - upload it to S3 or other remote server first, or read the file, encode it as base64, and pass it via file.data instead.';
|
||||
|
||||
export class SendTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('send-text', {
|
||||
title: 'Send a text message',
|
||||
description:
|
||||
'Send a text message to a number. ' +
|
||||
'Always call start-typing first, wait a few seconds, call stop-typing, then send the message.',
|
||||
inputSchema: SendTextInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendText(data: z.infer<typeof SendTextInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendText',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-image', {
|
||||
title: 'Send an image',
|
||||
description: 'Send an image to a chat. ' + FileNote,
|
||||
inputSchema: SendImageInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendImage(data: z.infer<typeof SendImageInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendImage',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-file', {
|
||||
title: 'Send a file',
|
||||
description: 'Send a file (document) to a chat. ' + FileNote,
|
||||
inputSchema: SendFileInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendFile(data: z.infer<typeof SendFileInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendFile',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-voice', {
|
||||
title: 'Send a voice message',
|
||||
description: 'Send a voice message to a chat. ' + FileNote,
|
||||
inputSchema: SendVoiceInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendVoice(data: z.infer<typeof SendVoiceInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendVoice',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-video', {
|
||||
title: 'Send a video',
|
||||
description: 'Send a video to a chat. ' + FileNote,
|
||||
inputSchema: SendVideoInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendVideo(data: z.infer<typeof SendVideoInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendVideo',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-link-custom-preview', {
|
||||
title: 'Send a text message with a custom link preview',
|
||||
description:
|
||||
'Send a text message with a custom link preview (title, description, image)',
|
||||
inputSchema: SendLinkCustomPreviewInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendLinkCustomPreview(
|
||||
data: z.infer<typeof SendLinkCustomPreviewInput>,
|
||||
) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/send/link-custom-preview',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-buttons', {
|
||||
title: 'Send a buttons message',
|
||||
description: 'Send an interactive buttons message',
|
||||
inputSchema: SendButtonsInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendButtons(data: z.infer<typeof SendButtonsInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendButtons',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-list', {
|
||||
title: 'Send a list message',
|
||||
description: 'Send an interactive list message with sections and rows',
|
||||
inputSchema: SendListInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendList(data: z.infer<typeof SendListInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendList',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-seen', {
|
||||
title: 'Mark messages as seen',
|
||||
description: 'Mark one or more messages as seen (read)',
|
||||
inputSchema: SendSeenInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async sendSeen(data: z.infer<typeof SendSeenInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendSeen',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-poll', {
|
||||
title: 'Send a poll',
|
||||
description: 'Send a poll message with options',
|
||||
inputSchema: SendPollInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendPoll(data: z.infer<typeof SendPollInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendPoll',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-poll-vote', {
|
||||
title: 'Vote on a poll',
|
||||
description: 'Cast vote(s) on an existing poll message',
|
||||
inputSchema: SendPollVoteInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendPollVote(data: z.infer<typeof SendPollVoteInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendPollVote',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-location', {
|
||||
title: 'Send a location',
|
||||
description: 'Send a location with latitude, longitude, and title',
|
||||
inputSchema: SendLocationInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendLocation(data: z.infer<typeof SendLocationInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendLocation',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-contact-vcard', {
|
||||
title: 'Send a contact vCard',
|
||||
description: 'Send one or more contacts as a vCard',
|
||||
inputSchema: SendContactVcardInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendContactVcard(data: z.infer<typeof SendContactVcardInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sendContactVcard',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-buttons-reply', {
|
||||
title: 'Reply to a buttons message',
|
||||
description: 'Send a reply to an interactive buttons message',
|
||||
inputSchema: SendButtonsReplyInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendButtonsReply(data: z.infer<typeof SendButtonsReplyInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/send/buttons/reply',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('forward-message', {
|
||||
title: 'Forward a message',
|
||||
description: 'Forward an existing message to a chat',
|
||||
inputSchema: ForwardMessageInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async forwardMessage(data: z.infer<typeof ForwardMessageInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/forwardMessage',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('start-typing', {
|
||||
title: 'Start typing indicator',
|
||||
description:
|
||||
'Show a typing indicator in a chat (runs until stop-typing is called)',
|
||||
inputSchema: TypingInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async startTyping(data: z.infer<typeof TypingInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/startTyping',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('stop-typing', {
|
||||
title: 'Stop typing indicator',
|
||||
description: 'Stop the typing indicator in a chat',
|
||||
inputSchema: TypingInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async stopTyping(data: z.infer<typeof TypingInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/stopTyping',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('set-reaction', {
|
||||
title: 'React to a message',
|
||||
description:
|
||||
'React to a message with an emoji. Send empty string to remove reaction',
|
||||
inputSchema: SetReactionInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async setReaction(data: z.infer<typeof SetReactionInput>) {
|
||||
return this.textRequest({
|
||||
method: 'PUT',
|
||||
url: '/api/reaction',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('set-star', {
|
||||
title: 'Star or unstar a message',
|
||||
description: 'Star or unstar a message',
|
||||
inputSchema: SetStarInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async setStar(data: z.infer<typeof SetStarInput>) {
|
||||
return this.textRequest({
|
||||
method: 'PUT',
|
||||
url: '/api/star',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('send-event-message', {
|
||||
title: 'Send an event message',
|
||||
description: 'Send an event/appointment message to a chat',
|
||||
inputSchema: SendEventInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendEvent({
|
||||
session,
|
||||
chatId,
|
||||
reply_to,
|
||||
...event
|
||||
}: z.infer<typeof SendEventInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/events`,
|
||||
data: { chatId: chatId, reply_to: reply_to, event: event },
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('new-message-id', {
|
||||
title: 'Generate a new message ID',
|
||||
description: 'Generate a new unique message ID for use in send requests',
|
||||
inputSchema: NewMessageIdInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async newMessageId({ session }: z.infer<typeof NewMessageIdInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/new-message-id`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import {
|
||||
ChatRequest,
|
||||
MessageButtonReply,
|
||||
MessageContactVcardRequest,
|
||||
MessageFileRequest,
|
||||
MessageForwardRequest,
|
||||
MessageImageRequest,
|
||||
MessageLinkCustomPreviewRequest,
|
||||
MessageLocationRequest,
|
||||
MessagePollRequest,
|
||||
MessagePollVoteRequest,
|
||||
MessageReactionRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
|
||||
import { SendListRequest } from '@waha/structures/chatting.list.dto';
|
||||
import { EventMessage } from '@waha/structures/events.dto';
|
||||
|
||||
export const SendTextInput = DtoToZod(MessageTextRequest);
|
||||
export const SendImageInput = DtoToZod(MessageImageRequest);
|
||||
export const SendFileInput = DtoToZod(MessageFileRequest);
|
||||
export const SendVoiceInput = DtoToZod(MessageVoiceRequest);
|
||||
export const SendVideoInput = DtoToZod(MessageVideoRequest);
|
||||
export const SendLinkCustomPreviewInput = DtoToZod(
|
||||
MessageLinkCustomPreviewRequest,
|
||||
);
|
||||
export const SendButtonsInput = DtoToZod(SendButtonsRequest);
|
||||
export const SendListInput = DtoToZod(SendListRequest);
|
||||
export const SendSeenInput = DtoToZod(SendSeenRequest);
|
||||
export const SendPollInput = DtoToZod(MessagePollRequest);
|
||||
export const SendPollVoteInput = DtoToZod(MessagePollVoteRequest);
|
||||
export const SendLocationInput = DtoToZod(MessageLocationRequest);
|
||||
export const SendContactVcardInput = DtoToZod(MessageContactVcardRequest);
|
||||
export const SendButtonsReplyInput = DtoToZod(MessageButtonReply);
|
||||
export const ForwardMessageInput = DtoToZod(MessageForwardRequest);
|
||||
export const TypingInput = DtoToZod(ChatRequest);
|
||||
export const SetReactionInput = DtoToZod(MessageReactionRequest);
|
||||
export const SetStarInput = DtoToZod(MessageStarRequest);
|
||||
export const NewMessageIdInput = z.object({
|
||||
session: z.string().describe('Session name'),
|
||||
});
|
||||
|
||||
export const SendEventInput = DtoToZod(EventMessage).extend({
|
||||
session: z.string().describe('Session name'),
|
||||
chatId: z.string().describe('Chat ID (e.g. 11111@c.us)'),
|
||||
reply_to: z.string().optional().describe('Message ID to reply to'),
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import { EnvironmentInput, StopInput } from '@waha/apps/mcp/tools/server.zod';
|
||||
|
||||
export class ServerTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('server-version', {
|
||||
title: 'Get server version',
|
||||
description: 'Get the version and build information of the WAHA server',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async version() {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: '/api/server/version',
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('server-status', {
|
||||
title: 'Get server status',
|
||||
description: 'Get server uptime, start timestamp, and worker information',
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async status() {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: '/api/server/status',
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('server-environment', {
|
||||
title: 'Get server environment',
|
||||
description:
|
||||
'Get environment variables from the server (WAHA_* and WHATSAPP_* by default)',
|
||||
inputSchema: EnvironmentInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async environment(params: z.infer<typeof EnvironmentInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: '/api/server/environment',
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('server-stop', {
|
||||
title: 'Stop the server',
|
||||
description:
|
||||
'Stop (and restart) the WAHA server. Use force=true for immediate termination.',
|
||||
inputSchema: StopInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async stop(body: z.infer<typeof StopInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/server/stop',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const EnvironmentInput = z.object({
|
||||
all: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Include all environment variables, not just WAHA_* ones'),
|
||||
});
|
||||
|
||||
export const StopInput = z.object({
|
||||
force: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Force-terminate immediately instead of graceful shutdown (SIGKILL vs SIGTERM)',
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z } from 'zod';
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import {
|
||||
SessionCreateInput,
|
||||
SessionListInput,
|
||||
SessionNameInput,
|
||||
SessionUpdateInput,
|
||||
} from '@waha/apps/mcp/tools/sessions.zod';
|
||||
|
||||
export class SessionTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('sessions-list', {
|
||||
title: 'List sessions',
|
||||
description: 'List all WhatsApp sessions',
|
||||
inputSchema: SessionListInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async list(params: z.infer<typeof SessionListInput>) {
|
||||
// Send back all sessions
|
||||
params.all = true;
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: '/api/sessions',
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-get', {
|
||||
title: 'Get session',
|
||||
description: 'Get information about a WhatsApp session',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async get({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${session}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-create', {
|
||||
title: 'Create session',
|
||||
description: 'Create a new WhatsApp session',
|
||||
inputSchema: SessionCreateInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async create(body: z.infer<typeof SessionCreateInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-update', {
|
||||
title: 'Update session',
|
||||
description: 'Update config of an existing WhatsApp session',
|
||||
inputSchema: SessionUpdateInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async update({ session, ...body }: z.infer<typeof SessionUpdateInput>) {
|
||||
return this.textRequest({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session}`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-delete', {
|
||||
title: 'Delete session',
|
||||
description: 'Delete a WhatsApp session (stops and logs out)',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async delete({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${session}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-start', {
|
||||
title: 'Start session',
|
||||
description: 'Start an existing WhatsApp session',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async start({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${session}/start`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-stop', {
|
||||
title: 'Stop session',
|
||||
description: 'Stop a running WhatsApp session',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async stop({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${session}/stop`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-logout', {
|
||||
title: 'Logout session',
|
||||
description: 'Logout from a WhatsApp session',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async logout({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${session}/logout`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('sessions-restart', {
|
||||
title: 'Restart session',
|
||||
description: 'Restart a WhatsApp session',
|
||||
inputSchema: SessionNameInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async restart({ session }: z.infer<typeof SessionNameInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${session}/restart`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import {
|
||||
ListSessionsQuery,
|
||||
SessionCreateRequest,
|
||||
SessionUpdateRequest,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
|
||||
export const SessionNameInput = z.object({
|
||||
session: z.string().describe('Session name'),
|
||||
});
|
||||
|
||||
export const SessionListInput = DtoToZod(ListSessionsQuery);
|
||||
|
||||
export const SessionCreateInput = DtoToZod(SessionCreateRequest);
|
||||
|
||||
export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({
|
||||
session: z.string().describe('Session name'),
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from 'zod';
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import {
|
||||
DeleteStatusInput,
|
||||
ImageStatusInput,
|
||||
StatusSessionInput,
|
||||
TextStatusInput,
|
||||
VideoStatusInput,
|
||||
VoiceStatusInput,
|
||||
} from '@waha/apps/mcp/tools/status.zod';
|
||||
|
||||
const FileNote =
|
||||
'Use file.url for remote HTTP/HTTPS URLs. ' +
|
||||
'For local file paths - encode as base64 and pass via file.data instead.';
|
||||
|
||||
export class StatusTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('status-send-text', {
|
||||
title: 'Send text status',
|
||||
description: 'Post a text WhatsApp status update',
|
||||
inputSchema: TextStatusInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendText({ session, ...body }: z.infer<typeof TextStatusInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/status/text`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('status-send-image', {
|
||||
title: 'Send image status',
|
||||
description: 'Post an image WhatsApp status update. ' + FileNote,
|
||||
inputSchema: ImageStatusInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendImage({ session, ...body }: z.infer<typeof ImageStatusInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/status/image`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('status-send-voice', {
|
||||
title: 'Send voice status',
|
||||
description: 'Post a voice WhatsApp status update. ' + FileNote,
|
||||
inputSchema: VoiceStatusInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendVoice({ session, ...body }: z.infer<typeof VoiceStatusInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/status/voice`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('status-send-video', {
|
||||
title: 'Send video status',
|
||||
description: 'Post a video WhatsApp status update. ' + FileNote,
|
||||
inputSchema: VideoStatusInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async sendVideo({ session, ...body }: z.infer<typeof VideoStatusInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/status/video`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('status-delete', {
|
||||
title: 'Delete status',
|
||||
description: 'Delete a posted WhatsApp status update',
|
||||
inputSchema: DeleteStatusInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async delete({ session, ...body }: z.infer<typeof DeleteStatusInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/status/delete`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('status-new-message-id', {
|
||||
title: 'Generate status message ID',
|
||||
description:
|
||||
'Generate a new message ID to use when sending a status update',
|
||||
inputSchema: StatusSessionInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async newMessageId({ session }: z.infer<typeof StatusSessionInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/status/new-message-id`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import {
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
|
||||
const SessionField = z.string().describe('Session name');
|
||||
|
||||
export const StatusSessionInput = z.object({ session: SessionField });
|
||||
|
||||
export const TextStatusInput = DtoToZod(TextStatus).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const ImageStatusInput = DtoToZod(ImageStatus).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const VoiceStatusInput = DtoToZod(VoiceStatus).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const VideoStatusInput = DtoToZod(VideoStatus).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
|
||||
export const DeleteStatusInput = DtoToZod(DeleteStatusRequest).extend({
|
||||
session: SessionField,
|
||||
});
|
||||
@@ -190,6 +190,7 @@ export class SwaggerConfiguratorCore {
|
||||
const config = this.app.get(WhatsappConfigService);
|
||||
const exclude = lodash.uniq([
|
||||
'/api/',
|
||||
'/mcp',
|
||||
dashboardConfig.dashboardUri,
|
||||
'/health',
|
||||
'/ping',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
|
||||
} from '@waha/core/abc/session.browser';
|
||||
import {
|
||||
CoreMediaConverter,
|
||||
IMediaConverter,
|
||||
} from '@waha/core/media/IConverter';
|
||||
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
|
||||
import { IMediaConverter } from '@waha/core/media/IConverter';
|
||||
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
|
||||
import { MessagesForRead } from '@waha/core/utils/convertors';
|
||||
import {
|
||||
IgnoreJidConfig,
|
||||
@@ -145,7 +141,6 @@ const qrcode = require('qrcode-terminal');
|
||||
|
||||
axiosRetry(axios, { retries: 3 });
|
||||
|
||||
|
||||
export function ensureSuffix(phone) {
|
||||
const suffix = '@c.us';
|
||||
if (phone.includes('@')) {
|
||||
@@ -208,7 +203,7 @@ export abstract class WhatsappSession {
|
||||
|
||||
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
public mediaConverter: IMediaConverter = new CoreMediaConverter();
|
||||
public mediaConverter: IMediaConverter;
|
||||
|
||||
public constructor({
|
||||
name,
|
||||
@@ -228,6 +223,7 @@ export abstract class WhatsappSession {
|
||||
this.proxyConfig = proxyConfig;
|
||||
this.loggerBuilder = loggerBuilder;
|
||||
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
|
||||
this.mediaConverter = new Ffmpeg(this.name, this.logger);
|
||||
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
|
||||
(key) =>
|
||||
new SwitchObservable((obs$) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as process from 'node:process';
|
||||
|
||||
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
|
||||
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConditionalModule, ConfigModule } from '@nestjs/config';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
@@ -26,6 +26,10 @@ import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService
|
||||
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
|
||||
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
|
||||
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
|
||||
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
|
||||
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
|
||||
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
|
||||
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
|
||||
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
|
||||
@@ -34,6 +38,8 @@ import {
|
||||
getPinoHttpUseLevel,
|
||||
getPinoLogLevel,
|
||||
getPinoTransport,
|
||||
isDebugEnabled,
|
||||
redactUrlParams,
|
||||
} from '@waha/utils/logging';
|
||||
import * as Joi from 'joi';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
@@ -68,7 +74,8 @@ import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore'
|
||||
import { SessionManagerCore } from './manager.core';
|
||||
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
|
||||
import { ApiKeyAuthService } from './auth/ApiKeyAuthService';
|
||||
import { SessionService } from '@waha/core/services/SessionService';
|
||||
|
||||
export const IMPORTS_CORE = [
|
||||
...AppsModuleExports.imports,
|
||||
@@ -90,11 +97,15 @@ export const IMPORTS_CORE = [
|
||||
);
|
||||
},
|
||||
},
|
||||
redact: {
|
||||
paths: ['req.query["x-api-key"]'],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({
|
||||
id: req.id,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
url: redactUrlParams('x-api-key', req.url, req.query),
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
}),
|
||||
@@ -138,10 +149,25 @@ const IMPORTS_MEDIA = [
|
||||
.default('LOCAL'),
|
||||
}),
|
||||
}),
|
||||
MediaLocalStorageModule,
|
||||
ConditionalModule.registerWhen(
|
||||
MediaLocalStorageModule,
|
||||
(env: NodeJS.ProcessEnv) =>
|
||||
!env['WAHA_MEDIA_STORAGE'] || env['WAHA_MEDIA_STORAGE'] == 'LOCAL',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
ConditionalModule.registerWhen(
|
||||
MediaS3StorageModule,
|
||||
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'S3',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
ConditionalModule.registerWhen(
|
||||
MediaPsqlStorageModule,
|
||||
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'POSTGRESQL',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
];
|
||||
|
||||
const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
|
||||
export const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
|
||||
|
||||
export const CONTROLLERS = [
|
||||
AuthController,
|
||||
@@ -183,11 +209,14 @@ export const PROVIDERS_BASE: Provider[] = [
|
||||
EngineConfigService,
|
||||
WebsocketGatewayCore,
|
||||
MediaLocalStorageConfig,
|
||||
MongoStoreHealthIndicator,
|
||||
CheckFreeDiskSpaceIndicator,
|
||||
WebSocketAuth,
|
||||
ApiKeyStrategy,
|
||||
ApiKeyService,
|
||||
ApiKeyAuthService,
|
||||
CaslAbilityFactory,
|
||||
PoliciesGuard,
|
||||
SessionService,
|
||||
{
|
||||
provide: IApiKeyAuth,
|
||||
useFactory: ApiKeyAuthFactory,
|
||||
@@ -196,7 +225,7 @@ export const PROVIDERS_BASE: Provider[] = [
|
||||
...AppsModuleExports.providers,
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
export const PROVIDERS = [
|
||||
{
|
||||
provide: SessionManager,
|
||||
useClass: SessionManagerCore,
|
||||
@@ -250,7 +279,7 @@ export class AppModuleCore {
|
||||
consumer
|
||||
.apply(ApiKeyAuthMiddleware)
|
||||
.exclude(...exclude)
|
||||
.forRoutes('api', 'health');
|
||||
.forRoutes('api', 'health', 'mcp');
|
||||
|
||||
// Dashboard
|
||||
const dashboardCredentials = this.dashboardConfig.credentials;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { User } from '@waha/core/auth/apiKey.strategy';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
export class ApiKeyAuthService {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
async get(apikey: string): Promise<User | null> {
|
||||
@@ -17,6 +17,7 @@ export class ApiKeyService {
|
||||
return {
|
||||
isAdmin: key.isAdmin,
|
||||
session: key.session,
|
||||
actions: key.actions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Request } from 'express';
|
||||
import { Strategy as PassportStrategy } from 'passport-strategy';
|
||||
|
||||
type VerifyCallback = (err: Error | null, user?: object, info?: object) => void;
|
||||
|
||||
type VerifyFunction = (
|
||||
apiKey: string,
|
||||
verified: VerifyCallback,
|
||||
req?: Request,
|
||||
) => void;
|
||||
|
||||
export class HeaderOrQueryApiKeyStrategy extends PassportStrategy {
|
||||
// Declared here because passport injects these at runtime; the base type omits them.
|
||||
declare fail: (info: object, status: unknown) => void;
|
||||
declare error: (err: Error) => void;
|
||||
declare success: (user: object, info?: object) => void;
|
||||
|
||||
name: string;
|
||||
passReqToCallback: boolean;
|
||||
verify: VerifyFunction;
|
||||
|
||||
constructor(passReqToCallback: boolean, verify: VerifyFunction) {
|
||||
super();
|
||||
this.name = 'headerapikey';
|
||||
this.passReqToCallback = passReqToCallback;
|
||||
this.verify = verify;
|
||||
}
|
||||
|
||||
authenticate(req: Request): void {
|
||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
||||
const queryKey = req.query['x-api-key'] as string | undefined;
|
||||
const apiKey = headerKey ?? queryKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return this.fail({ message: 'Missing API Key' }, null);
|
||||
}
|
||||
|
||||
const verified: VerifyCallback = (err, user?, info?) => {
|
||||
if (err) {
|
||||
return this.error(err);
|
||||
}
|
||||
if (!user) {
|
||||
return this.fail(info, null);
|
||||
}
|
||||
this.success(user, info);
|
||||
};
|
||||
|
||||
if (this.passReqToCallback) {
|
||||
this.verify(apiKey, verified, req);
|
||||
} else {
|
||||
this.verify(apiKey, verified);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export class ApiKeyAuthMiddleware implements NestMiddleware {
|
||||
|
||||
use(req: any, res: any, next: () => void) {
|
||||
if (this.auth instanceof NoAuth) {
|
||||
delete req.query['x-api-key'];
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export class ApiKeyAuthMiddleware implements NestMiddleware {
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
delete req.query['x-api-key'];
|
||||
next();
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IApiKeyAuth } from '@waha/core/auth/auth';
|
||||
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
|
||||
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
|
||||
import { SessionActions } from '@waha/core/auth/casl.types';
|
||||
import { HeaderOrQueryApiKeyStrategy } from '@waha/core/auth/HeaderOrQueryApiKeyStrategy';
|
||||
import { ApiKeyAuthService } from './ApiKeyAuthService';
|
||||
|
||||
export interface User {
|
||||
isAdmin: boolean;
|
||||
session?: string;
|
||||
actions?: SessionActions | null;
|
||||
}
|
||||
|
||||
function AdminUser(): User {
|
||||
@@ -17,13 +19,15 @@ function AdminUser(): User {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
|
||||
export class ApiKeyStrategy extends PassportStrategy(
|
||||
HeaderOrQueryApiKeyStrategy,
|
||||
) {
|
||||
constructor(
|
||||
private auth: IApiKeyAuth,
|
||||
private apiKeyService: ApiKeyService,
|
||||
private apiKeyService: ApiKeyAuthService,
|
||||
) {
|
||||
// @ts-ignore
|
||||
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
|
||||
// @ts-ignore — PassportStrategy mixin doesn't forward constructor arg types
|
||||
super(true, (apikey, done) => {
|
||||
return this.validate(apikey, done);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export class CaslAbilityFactory {
|
||||
return createMongoAbility(AdminRules());
|
||||
}
|
||||
if (user.session) {
|
||||
return createMongoAbility(SessionRules(user.session));
|
||||
return createMongoAbility(
|
||||
SessionRules(user.session, user.actions ?? null),
|
||||
);
|
||||
}
|
||||
return createMongoAbility([]);
|
||||
}
|
||||
|
||||
+60
-21
@@ -1,5 +1,5 @@
|
||||
import { RawRuleOf } from '@casl/ability';
|
||||
import { Action, AppAbility } from './casl.types';
|
||||
import { Action, AppAbility, SessionActions } from './casl.types';
|
||||
|
||||
export function AdminRules(): RawRuleOf<AppAbility>[] {
|
||||
return [
|
||||
@@ -10,36 +10,75 @@ export function AdminRules(): RawRuleOf<AppAbility>[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
|
||||
return [
|
||||
//
|
||||
// Server
|
||||
//
|
||||
const DefaultSessionActions: SessionActions = {
|
||||
delete: false,
|
||||
setting: true,
|
||||
control: true,
|
||||
app: true,
|
||||
read: true,
|
||||
send: true,
|
||||
};
|
||||
|
||||
export function SessionRules(
|
||||
name: string,
|
||||
rules: SessionActions | null = null,
|
||||
): RawRuleOf<AppAbility>[] {
|
||||
const actions = rules ?? DefaultSessionActions;
|
||||
const result: RawRuleOf<AppAbility>[] = [
|
||||
{
|
||||
action: 'read',
|
||||
action: 'retrieve',
|
||||
subject: 'server',
|
||||
},
|
||||
//
|
||||
// Session
|
||||
//
|
||||
{
|
||||
action: Action.List,
|
||||
subject: 'session',
|
||||
// "conditions" is not required here, we filter session list dynamically later
|
||||
},
|
||||
{
|
||||
{ action: Action.Retrieve, subject: 'session', conditions: { name: name } },
|
||||
];
|
||||
|
||||
if (actions.read) {
|
||||
result.push({
|
||||
action: Action.Read,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
},
|
||||
// {
|
||||
// action: Action.Delete,
|
||||
// subject: 'session',
|
||||
// conditions: { name: name },
|
||||
// },
|
||||
{
|
||||
action: Action.Use,
|
||||
});
|
||||
}
|
||||
if (actions.send) {
|
||||
result.push({
|
||||
action: Action.Send,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
if (actions.control) {
|
||||
result.push({
|
||||
action: Action.Control,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
});
|
||||
}
|
||||
if (actions.setting) {
|
||||
result.push({
|
||||
action: Action.Setting,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
});
|
||||
}
|
||||
if (actions.app) {
|
||||
result.push({
|
||||
action: Action.App,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
});
|
||||
}
|
||||
if (actions.delete) {
|
||||
result.push({
|
||||
action: Action.Delete,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -12,15 +12,37 @@ export enum Action {
|
||||
//
|
||||
Manage = 'manage', // it's a special action in casl, meaning "any actions"
|
||||
//
|
||||
// Session
|
||||
// Session Management
|
||||
//
|
||||
List = 'list', // list sessions
|
||||
Read = 'read', // read session info (not messages)
|
||||
Retrieve = 'retrieve', // retrieve session info (not messages)
|
||||
Create = 'create', // create a new session
|
||||
Delete = 'delete', // delete a session
|
||||
Use = 'use', // all actions - send a message, retrieve, manage session status
|
||||
Setting = 'setting', // session config: update session settings
|
||||
Control = 'control', // session lifecycle: start, stop, restart, logout, authenticate
|
||||
App = 'app', // manage apps
|
||||
//
|
||||
// Session Operations
|
||||
//
|
||||
Read = 'read', // read session data (messages, contacts, chats, groups, etc.)
|
||||
Send = 'send', // send messages + manage session entities (groups, labels, channels, contacts, profile)
|
||||
}
|
||||
|
||||
export type SessionActions = Partial<
|
||||
Record<
|
||||
Extract<
|
||||
Action,
|
||||
| Action.Delete
|
||||
| Action.Setting
|
||||
| Action.Control
|
||||
| Action.App
|
||||
| Action.Read
|
||||
| Action.Send
|
||||
>,
|
||||
boolean
|
||||
>
|
||||
>;
|
||||
|
||||
type Subjects =
|
||||
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
|
||||
| 'all';
|
||||
|
||||
@@ -3000,6 +3000,8 @@ export namespace messages {
|
||||
#one_of_decls: number[][] = [];
|
||||
constructor(data?: any[] | {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
}) {
|
||||
super();
|
||||
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
|
||||
@@ -3007,6 +3009,12 @@ export namespace messages {
|
||||
if ("duration" in data && data.duration != undefined) {
|
||||
this.duration = data.duration;
|
||||
}
|
||||
if ("gifPlayback" in data && data.gifPlayback != undefined) {
|
||||
this.gifPlayback = data.gifPlayback;
|
||||
}
|
||||
if ("externalShareFullVideoDurationInSeconds" in data && data.externalShareFullVideoDurationInSeconds != undefined) {
|
||||
this.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
get duration() {
|
||||
@@ -3015,22 +3023,50 @@ export namespace messages {
|
||||
set duration(value: number) {
|
||||
pb_1.Message.setField(this, 1, value);
|
||||
}
|
||||
get gifPlayback() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
|
||||
}
|
||||
set gifPlayback(value: boolean) {
|
||||
pb_1.Message.setField(this, 2, value);
|
||||
}
|
||||
get externalShareFullVideoDurationInSeconds() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 3, 0) as number;
|
||||
}
|
||||
set externalShareFullVideoDurationInSeconds(value: number) {
|
||||
pb_1.Message.setField(this, 3, value);
|
||||
}
|
||||
static fromObject(data: {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
}): VideoInfo {
|
||||
const message = new VideoInfo({});
|
||||
if (data.duration != null) {
|
||||
message.duration = data.duration;
|
||||
}
|
||||
if (data.gifPlayback != null) {
|
||||
message.gifPlayback = data.gifPlayback;
|
||||
}
|
||||
if (data.externalShareFullVideoDurationInSeconds != null) {
|
||||
message.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
toObject() {
|
||||
const data: {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
} = {};
|
||||
if (this.duration != null) {
|
||||
data.duration = this.duration;
|
||||
}
|
||||
if (this.gifPlayback != null) {
|
||||
data.gifPlayback = this.gifPlayback;
|
||||
}
|
||||
if (this.externalShareFullVideoDurationInSeconds != null) {
|
||||
data.externalShareFullVideoDurationInSeconds = this.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
serialize(): Uint8Array;
|
||||
@@ -3039,6 +3075,10 @@ export namespace messages {
|
||||
const writer = w || new pb_1.BinaryWriter();
|
||||
if (this.duration != 0)
|
||||
writer.writeFloat(1, this.duration);
|
||||
if (this.gifPlayback != false)
|
||||
writer.writeBool(2, this.gifPlayback);
|
||||
if (this.externalShareFullVideoDurationInSeconds != 0)
|
||||
writer.writeUint32(3, this.externalShareFullVideoDurationInSeconds);
|
||||
if (!w)
|
||||
return writer.getResultBuffer();
|
||||
}
|
||||
@@ -3051,6 +3091,12 @@ export namespace messages {
|
||||
case 1:
|
||||
message.duration = reader.readFloat();
|
||||
break;
|
||||
case 2:
|
||||
message.gifPlayback = reader.readBool();
|
||||
break;
|
||||
case 3:
|
||||
message.externalShareFullVideoDurationInSeconds = reader.readUint32();
|
||||
break;
|
||||
default: reader.skipField();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7749,7 +7749,9 @@ proto.messages.VideoInfo.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.messages.VideoInfo.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0)
|
||||
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0),
|
||||
gifplayback: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
externalsharefullvideodurationinseconds: jspb.Message.getFieldWithDefault(msg, 3, 0)
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -7790,6 +7792,14 @@ proto.messages.VideoInfo.deserializeBinaryFromReader = function(msg, reader) {
|
||||
var value = /** @type {number} */ (reader.readFloat());
|
||||
msg.setDuration(value);
|
||||
break;
|
||||
case 2:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setGifplayback(value);
|
||||
break;
|
||||
case 3:
|
||||
var value = /** @type {number} */ (reader.readUint32());
|
||||
msg.setExternalsharefullvideodurationinseconds(value);
|
||||
break;
|
||||
default:
|
||||
reader.skipField();
|
||||
break;
|
||||
@@ -7826,6 +7836,20 @@ proto.messages.VideoInfo.serializeBinaryToWriter = function(message, writer) {
|
||||
f
|
||||
);
|
||||
}
|
||||
f = message.getGifplayback();
|
||||
if (f) {
|
||||
writer.writeBool(
|
||||
2,
|
||||
f
|
||||
);
|
||||
}
|
||||
f = message.getExternalsharefullvideodurationinseconds();
|
||||
if (f !== 0) {
|
||||
writer.writeUint32(
|
||||
3,
|
||||
f
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7847,6 +7871,42 @@ proto.messages.VideoInfo.prototype.setDuration = function(value) {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool gifPlayback = 2;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.getGifplayback = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.VideoInfo} returns this
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.setGifplayback = function(value) {
|
||||
return jspb.Message.setProto3BooleanField(this, 2, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional uint32 externalShareFullVideoDurationInSeconds = 3;
|
||||
* @return {number}
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.getExternalsharefullvideodurationinseconds = function() {
|
||||
return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @return {!proto.messages.VideoInfo} returns this
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.setExternalsharefullvideodurationinseconds = function(value) {
|
||||
return jspb.Message.setProto3IntField(this, 3, value);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,17 +34,16 @@ import {
|
||||
getDestination,
|
||||
} from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { QR } from '@waha/core/QR';
|
||||
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import {
|
||||
isJidBroadcast,
|
||||
isJidGroup,
|
||||
isJidNewsletter,
|
||||
normalizeJid,
|
||||
toCusFormat,
|
||||
toJID,
|
||||
@@ -77,6 +76,7 @@ import {
|
||||
ChatRequest,
|
||||
CheckNumberStatusQuery,
|
||||
EditMessageRequest,
|
||||
MessageButtonReply,
|
||||
MessageContactVcardRequest,
|
||||
MessageFileRequest,
|
||||
MessageForwardRequest,
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -135,7 +136,10 @@ import {
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
@@ -191,10 +195,17 @@ import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
|
||||
import { extractVCards } from '@waha/core/engines/waproto/vcards';
|
||||
import { Activity } from '@waha/core/abc/activity';
|
||||
import { TmpDir } from '@waha/utils/tmpdir';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import { sortObjectByValues } from '@waha/helpers';
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
import * as path from 'path';
|
||||
import MessageServiceClient = messages.MessageServiceClient;
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
axiosRetry(axios, { retries: 3 });
|
||||
|
||||
function getGowsStorageConfig(
|
||||
sessionConfig?: SessionConfig,
|
||||
): messages.SessionStorageConfig {
|
||||
@@ -898,12 +909,46 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async fileToMedia(
|
||||
file: RemoteFile | BinaryFile,
|
||||
): Promise<messages.Media> {
|
||||
let content: Buffer;
|
||||
if ('url' in file) {
|
||||
// fetch file
|
||||
content = await this.fetch(file.url);
|
||||
} else {
|
||||
// base64 to bytes
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
}
|
||||
|
||||
return new messages.Media({
|
||||
content: content,
|
||||
mimetype: file.mimetype,
|
||||
filename: file.filename,
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const request = new messages.SetProfilePictureRequest({
|
||||
session: this.session,
|
||||
picture: media.content,
|
||||
});
|
||||
const response = await promisify(this.client.SetProfilePicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
const request = new messages.SetProfilePictureRequest({
|
||||
session: this.session,
|
||||
});
|
||||
const response = await promisify(this.client.SetProfilePicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -931,6 +976,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
async sendText(request: MessageTextRequest) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
text: request.text,
|
||||
session: this.session,
|
||||
@@ -976,6 +1022,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
vcard: toVcardV3(el),
|
||||
}));
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
@@ -990,6 +1037,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
async sendPoll(request: MessagePollRequest) {
|
||||
const jid = normalizeJid(toJID(request.chatId));
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
@@ -1004,12 +1052,53 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
sendPollVote(request: MessagePollVoteRequest) {
|
||||
throw new AvailableInPlusVersion('Poll voting');
|
||||
@Activity()
|
||||
async sendPollVote(request: MessagePollVoteRequest) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const key = parseMessageIdSerialized(request.pollMessageId, true);
|
||||
const pollVote = new messages.PollVoteMessage({
|
||||
pollMessageId: key.id,
|
||||
options: request.votes,
|
||||
});
|
||||
if (request.pollServerId != null) {
|
||||
// protobuf expects int64 number
|
||||
pollVote.pollServerId = request.pollServerId;
|
||||
}
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
pollVote: pollVote,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
sendList(request: SendListRequest): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendList(request: SendListRequest): Promise<any> {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`List message can only be sent to a direct message chat.`,
|
||||
);
|
||||
}
|
||||
const m = request.message;
|
||||
const list = messages.ListMessage.fromObject({
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
footer: m.footer,
|
||||
button: m.button,
|
||||
sections: m.sections,
|
||||
});
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
list: list,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1109,6 +1198,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
async sendLocation(request: MessageLocationRequest) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
@@ -1127,22 +1217,209 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async sendMedia(type: messages.MediaType, request: any) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.type = type;
|
||||
if (type === messages.MediaType.IMAGE) {
|
||||
media.mimetype = media.mimetype || WAMimeType.IMAGE;
|
||||
} else if (type === messages.MediaType.AUDIO) {
|
||||
media.mimetype = media.mimetype || WAMimeType.VOICE;
|
||||
} else if (
|
||||
type === messages.MediaType.VIDEO ||
|
||||
type === messages.MediaType.PTV
|
||||
) {
|
||||
media.mimetype = media.mimetype || WAMimeType.VIDEO;
|
||||
} else if (type === messages.MediaType.DOCUMENT) {
|
||||
if (!media.mimetype) {
|
||||
media.mimetype = await detectMimetype(media.content as Buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.convert) {
|
||||
switch (type) {
|
||||
case messages.MediaType.AUDIO:
|
||||
media.content = await this.mediaConverter.voice(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VOICE;
|
||||
break;
|
||||
case messages.MediaType.VIDEO:
|
||||
media.content = await this.mediaConverter.video(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
break;
|
||||
case messages.MediaType.PTV:
|
||||
media.content = await this.mediaConverter.video(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`No conversion for ${type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only for Voice Status
|
||||
let backgroundColor: messages.OptionalString | null = null;
|
||||
if (request.backgroundColor) {
|
||||
backgroundColor = new messages.OptionalString({
|
||||
value: request.backgroundColor,
|
||||
});
|
||||
}
|
||||
const participants = await this.prepareJidsForStatus(request.contacts);
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
text: request.caption,
|
||||
session: this.session,
|
||||
media: media,
|
||||
backgroundColor: backgroundColor,
|
||||
mentions: request.mentions?.map((mention) =>
|
||||
normalizeJid(toJID(mention)),
|
||||
),
|
||||
participants: participants,
|
||||
});
|
||||
|
||||
if (media.type == messages.MediaType.AUDIO) {
|
||||
const logger: any = this.loggerBuilder.child({});
|
||||
const buffer = Buffer.from(media.content);
|
||||
const waveform = await esm.b.getAudioWaveform(buffer, logger);
|
||||
const duration = await esm.b.getAudioDuration(buffer);
|
||||
media.audio = new messages.AudioInfo({
|
||||
waveform: waveform,
|
||||
duration: duration,
|
||||
});
|
||||
}
|
||||
if (
|
||||
media.type == messages.MediaType.VIDEO ||
|
||||
media.type == messages.MediaType.PTV
|
||||
) {
|
||||
const buffer = Buffer.from(media.content);
|
||||
const duration = await esm.b.getAudioDuration(buffer).catch((err) => {
|
||||
this.logger.warn({ error: err }, 'Failed to get video duration');
|
||||
return undefined;
|
||||
});
|
||||
const isGif = request.file?.mimetype === 'image/gif';
|
||||
media.video = new messages.VideoInfo({
|
||||
duration: duration,
|
||||
gifPlayback: isGif,
|
||||
externalShareFullVideoDurationInSeconds: isGif ? 0 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
message.replyTo = getMessageIdFromSerialized(request.reply_to);
|
||||
const tmpdir = new TmpDir(this.logger, `waha-smedia-${this.name}-`);
|
||||
return await tmpdir.use(async (dir) => {
|
||||
const file = path.join(dir, 'send-media.tmp');
|
||||
// Try to write to the file
|
||||
try {
|
||||
await fsp.writeFile(file, Buffer.from(media.content));
|
||||
media.contentPath = file;
|
||||
media.content = null;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to write media to temp file: ${e.message}`);
|
||||
}
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
});
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
return await this.sendMedia(messages.MediaType.IMAGE, request);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
return await this.sendMedia(messages.MediaType.DOCUMENT, request);
|
||||
}
|
||||
|
||||
sendLinkCustomPreview(
|
||||
@Activity()
|
||||
async sendVoice(request: MessageVoiceRequest) {
|
||||
return await this.sendMedia(messages.MediaType.AUDIO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
const type = request.asNote
|
||||
? messages.MediaType.PTV
|
||||
: messages.MediaType.VIDEO;
|
||||
return await this.sendMedia(type, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendLinkCustomPreview(
|
||||
request: MessageLinkCustomPreviewRequest,
|
||||
): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const media = await this.fileToMedia(request.preview.image as RemoteFile);
|
||||
const preview = new messages.LinkPreview({
|
||||
url: request.preview.url,
|
||||
title: request.preview.title,
|
||||
description: request.preview.description,
|
||||
image: media.content,
|
||||
});
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
text: request.text,
|
||||
session: this.session,
|
||||
linkPreview: true,
|
||||
linkPreviewHighQuality: request.linkPreviewHighQuality,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
preview: preview,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendButtonsReply(request: MessageButtonReply) {
|
||||
throw new NotImplementedByEngineError();
|
||||
|
||||
// Doesn't work yet
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const message = new messages.ButtonReplyRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.replyTo),
|
||||
selectedDisplayText: request.selectedDisplayText,
|
||||
selectedButtonID: request.selectedButtonID,
|
||||
});
|
||||
const response = await promisify(this.client.SendButtonReply)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.IMAGE, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.AUDIO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.VIDEO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1334,6 +1611,33 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
await promisify(this.client.SetGroupName)(req);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const request = new messages.SetPictureRequest({
|
||||
session: this.session,
|
||||
jid: id,
|
||||
picture: media.content,
|
||||
});
|
||||
const response = await promisify(this.client.SetGroupPicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
const request = new messages.SetPictureRequest({
|
||||
session: this.session,
|
||||
jid: id,
|
||||
});
|
||||
const response = await promisify(this.client.SetGroupPicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async getInviteCode(id): Promise<string> {
|
||||
const req = new messages.JidRequest({
|
||||
@@ -1607,23 +1911,122 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const request = new messages.SearchNewslettersByViewRequest({
|
||||
session: this.session,
|
||||
view: query.view,
|
||||
categories: query.categories,
|
||||
countries: query.countries,
|
||||
page: new messages.SearchPage({
|
||||
limit: query.limit,
|
||||
startCursor: query.startCursor,
|
||||
}),
|
||||
});
|
||||
const response = await promisify(this.client.SearchNewslettersByView)(
|
||||
request,
|
||||
);
|
||||
return this.channelsRawDataToResponse(response);
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const request = new messages.SearchNewslettersByTextRequest({
|
||||
session: this.session,
|
||||
text: query.text,
|
||||
categories: query.categories,
|
||||
page: new messages.SearchPage({
|
||||
limit: query.limit,
|
||||
startCursor: query.startCursor,
|
||||
}),
|
||||
});
|
||||
const response = await promisify(this.client.SearchNewslettersByText)(
|
||||
request,
|
||||
);
|
||||
return this.channelsRawDataToResponse(response);
|
||||
}
|
||||
|
||||
private channelsRawDataToResponse(
|
||||
data: messages.NewsletterSearchPageResult,
|
||||
): ChannelListResult {
|
||||
const channels: Channel[] = data.newsletters.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
channels.forEach((channel) => {
|
||||
delete channel.role;
|
||||
});
|
||||
return {
|
||||
page: {
|
||||
startCursor: data.page.startCursor,
|
||||
endCursor: data.page.endCursor,
|
||||
hasNextPage: data.page.hasNextPage,
|
||||
hasPreviousPage: data.page.hasPreviousPage,
|
||||
},
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const downloadMedia = query.downloadMedia;
|
||||
const request = new messages.GetNewsletterMessagesByInviteRequest({
|
||||
session: this.session,
|
||||
invite: inviteCode,
|
||||
limit: query.limit,
|
||||
});
|
||||
const response = await promisify(this.client.GetNewsletterMessagesByInvite)(
|
||||
request,
|
||||
);
|
||||
const resp = parseJson(response);
|
||||
const promises = [];
|
||||
if (!resp.Messages) {
|
||||
return [];
|
||||
}
|
||||
for (const msg of resp.Messages) {
|
||||
promises.push(
|
||||
this.GowsChannelMessageToChannelMessage(
|
||||
resp.NewsletterJID,
|
||||
msg,
|
||||
downloadMedia,
|
||||
),
|
||||
);
|
||||
}
|
||||
let result = await Promise.all(promises);
|
||||
result = result.filter(Boolean);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async GowsChannelMessageToChannelMessage(
|
||||
jid: string,
|
||||
channelMessage: any,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
const msg = {
|
||||
Info: {
|
||||
ID: channelMessage.MessageID,
|
||||
ServerID: channelMessage.MessageServerID,
|
||||
Chat: jid,
|
||||
Sender: jid,
|
||||
IsFromMe: false,
|
||||
Timestamp: channelMessage.Timestamp,
|
||||
},
|
||||
Message: channelMessage.Message,
|
||||
};
|
||||
const message = await this.processIncomingMessage(msg, downloadMedia);
|
||||
const reactions: any =
|
||||
sortObjectByValues(channelMessage.ReactionCounts) || {};
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: channelMessage.ViewsCount,
|
||||
};
|
||||
}
|
||||
|
||||
protected toChannel(newsletter: messages.Newsletter): Channel {
|
||||
@@ -1671,10 +2074,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
public async channelsCreateChannel(
|
||||
request: CreateChannelRequest,
|
||||
): Promise<Channel> {
|
||||
let media: messages.Media;
|
||||
if (request.picture) {
|
||||
media = await this.fileToMedia(request.picture);
|
||||
}
|
||||
const req = new messages.CreateNewsletterRequest({
|
||||
session: this.session,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
picture: media?.content,
|
||||
});
|
||||
const response = await promisify(this.client.CreateNewsletter)(req);
|
||||
const newsletter = response.toObject() as messages.Newsletter;
|
||||
@@ -2220,7 +2628,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message) {
|
||||
const processor = new GOWSEngineMediaProcessor(this);
|
||||
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
|
||||
this,
|
||||
);
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
const media = await this.mediaManager.processMedia(
|
||||
processor,
|
||||
message,
|
||||
@@ -2547,6 +2958,50 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Many encrypted stickers carry URL "https://a.whatsapp.net" with no path. If
|
||||
* that string is passed to DownloadMedia, the Go client may attempt HTTP GET to
|
||||
* that host instead of decrypting via directPath. Real CDN links use hosts
|
||||
* such as mmg.whatsapp.net with a full path.
|
||||
*
|
||||
* GOWS also exposes stickerMessage.URL (uppercase); some paths expect url
|
||||
* (lowercase). We mirror URL -> url only for real HTTP-style URLs.
|
||||
*/
|
||||
function isPlaceholderWhatsAppMediaUrl(url: unknown): boolean {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
if (parsed.hostname !== 'a.whatsapp.net') {
|
||||
return false;
|
||||
}
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
return path === '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGowsStickerUrlForDownload(message: any): any {
|
||||
const sticker = message?.Message?.stickerMessage;
|
||||
if (!sticker) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (isPlaceholderWhatsAppMediaUrl(sticker.URL)) {
|
||||
delete sticker.URL;
|
||||
}
|
||||
if (isPlaceholderWhatsAppMediaUrl(sticker.url)) {
|
||||
delete sticker.url;
|
||||
}
|
||||
|
||||
if (sticker.URL && !sticker.url) {
|
||||
sticker.url = sticker.URL;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
constructor(public session: WhatsappSessionGoWSCore) {}
|
||||
|
||||
@@ -2570,6 +3025,8 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
async getMediaBuffer(message: any): Promise<Buffer | null> {
|
||||
const mediaDownloadTimeoutMs = 600_000; // 10 minutes
|
||||
|
||||
message = normalizeGowsStickerUrlForDownload(message);
|
||||
|
||||
const data = JSON.stringify(message.Message);
|
||||
const tmpdir = new TmpDir(
|
||||
this.session.logger,
|
||||
|
||||
@@ -3,15 +3,23 @@ import * as path from 'node:path';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { GowsAuth } from './GowsAuth';
|
||||
|
||||
export class GowsAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
|
||||
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
|
||||
await store.init(name);
|
||||
const connection = store.getSessionDbURL(name);
|
||||
return new GowsAuthSimple(connection, 'postgres');
|
||||
}
|
||||
|
||||
protected async buildSqlite3(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { DataStore } from '../../abc/DataStore';
|
||||
import { LocalStore } from '../../storage/LocalStore';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
|
||||
import { join } from 'path';
|
||||
|
||||
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
|
||||
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
|
||||
import { useMultiFileAuthState } from './useMultiFileAuthState';
|
||||
|
||||
export class NowebAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string) {
|
||||
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private async buildMongoAuth(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
const authStore = new NoWebMongoDbAuth(db);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
async buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Auth');
|
||||
const authStore = new NoWebPsqlAuth(knex);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
protected async buildLocalAuth(store: LocalStore, name: string) {
|
||||
// Quick fix for
|
||||
// https://github.com/devlikeapro/waha/issues/347
|
||||
// We can remove it after some time and the fix
|
||||
// https://github.com/WhiskeySockets/Baileys/pull/824
|
||||
await this.makeCredsJsonValid(store, name);
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
|
||||
return { state, saveCreds, close };
|
||||
}
|
||||
|
||||
private async makeCredsJsonValid(store: LocalStore, name: string) {
|
||||
try {
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const credsPath = join(authFolder, 'creds.json');
|
||||
await makeSureJsonFile(credsPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to fix "creds.json" file, continue...', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type makeWASocket from '@adiwajshing/baileys';
|
||||
import type { QueryIds } from '@adiwajshing/baileys';
|
||||
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
|
||||
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
|
||||
import {
|
||||
ChannelPagination,
|
||||
ChannelSearchByText,
|
||||
ChannelSearchByView,
|
||||
} from '@waha/structures/channels.dto';
|
||||
|
||||
enum NewsletterMexQueryIds {
|
||||
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
|
||||
}
|
||||
|
||||
enum NewsletterXWAPaths {
|
||||
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
|
||||
}
|
||||
|
||||
/**
|
||||
* NowebClient - wrapper around baileys to have more methods
|
||||
*/
|
||||
|
||||
export interface NewsletterSearchResponse {
|
||||
page: ChannelPagination;
|
||||
newsletters: NewsletterMetadata[];
|
||||
}
|
||||
|
||||
export class NowebClient {
|
||||
constructor(private sock: ReturnType<typeof makeWASocket>) {}
|
||||
|
||||
async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
view: query.view,
|
||||
filters: {
|
||||
country_codes: query.countries,
|
||||
categories: query.categories,
|
||||
},
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
|
||||
async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
search_text: query.text,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
|
||||
const pageInfo = response.page_info;
|
||||
const page: ChannelPagination = {
|
||||
startCursor: pageInfo.startCursor,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
hasPreviousPage: pageInfo.hasPreviousPage,
|
||||
};
|
||||
const newsletterResult: any[] = response.result;
|
||||
const newsletters = newsletterResult.map(toNewsletterMetadata);
|
||||
|
||||
return {
|
||||
page: page,
|
||||
newsletters: newsletters as any,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message';
|
||||
import type {
|
||||
MediaGenerationOptions,
|
||||
NewsletterFetchedUpdate,
|
||||
} from '@adiwajshing/baileys/lib/Types';
|
||||
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
@@ -58,22 +62,25 @@ import {
|
||||
} from '@waha/core/engines/noweb/noweb.newsletter';
|
||||
import { NowebAuthFactoryCore } from '@waha/core/engines/noweb/NowebAuthFactoryCore';
|
||||
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { toVcardV3 } from '@waha/core/vcard';
|
||||
import { createAgentProxy } from '@waha/core/helpers.proxy';
|
||||
import type { Agent } from 'https';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { QR } from '@waha/core/QR';
|
||||
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
|
||||
import { pairs } from '@waha/utils/pairs';
|
||||
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { isJidNewsletter, toCusFormat, toJID } from '@waha/core/utils/jids';
|
||||
import { DistinctAck } from '@waha/core/utils/reactive';
|
||||
import { flipObject, splitAt } from '@waha/helpers';
|
||||
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
|
||||
import {
|
||||
flipObject,
|
||||
parseBool,
|
||||
sortObjectByValues,
|
||||
splitAt,
|
||||
} from '@waha/helpers';
|
||||
import { PairingCodeResponse } from '@waha/structures/auth.dto';
|
||||
import { CallData } from '@waha/structures/calls.dto';
|
||||
import {
|
||||
@@ -117,6 +124,7 @@ import {
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -136,7 +144,7 @@ import {
|
||||
WAHASessionStatus,
|
||||
WAMessageAck,
|
||||
} from '@waha/structures/enums.dto';
|
||||
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
|
||||
import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto';
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
GroupParticipant,
|
||||
@@ -162,8 +170,11 @@ import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
StatusRequest,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
@@ -196,6 +207,7 @@ import {
|
||||
} from 'rxjs';
|
||||
import { debounceTime, map } from 'rxjs/operators';
|
||||
|
||||
import { NowebClient } from './NowebClient';
|
||||
import { INowebStore } from './store/INowebStore';
|
||||
import { NowebPersistentStore } from './store/NowebPersistentStore';
|
||||
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
|
||||
@@ -204,17 +216,30 @@ import { Agents } from '@waha/core/engines/noweb/types';
|
||||
import {
|
||||
IsEditedMessage,
|
||||
IsHistorySyncNotification,
|
||||
IsSecretEncryptedMessageEdit,
|
||||
} from '@waha/core/utils/pwa';
|
||||
import {
|
||||
decryptSecretEncryptedMessageEditProto,
|
||||
getOrigSenderJidForMsgSecret,
|
||||
jidToNonAD,
|
||||
} from '@waha/core/utils/secretEncryptedMessageEdit';
|
||||
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
|
||||
import { extractVCards } from '@waha/core/engines/waproto/vcards';
|
||||
import { Activity } from '@waha/core/abc/activity';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import {
|
||||
WAHA_CLIENT_BROWSER_NAME,
|
||||
WAHA_CLIENT_DEVICE_NAME,
|
||||
} from '@waha/core/env';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import esm from '@waha/vendor/esm';
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const promiseRetry = require('promise-retry');
|
||||
|
||||
axiosRetry(axios, { retries: 3 });
|
||||
|
||||
export const BaileysEvents = {
|
||||
CONNECTION_UPDATE: 'connection.update',
|
||||
CREDS_UPDATE: 'creds.update',
|
||||
@@ -898,12 +923,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const content: Buffer = await this.fileToBuffer(file);
|
||||
const me = this.getSessionMeInfo();
|
||||
await this.sock.updateProfilePicture(me.id, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
const me = this.getSessionMeInfo();
|
||||
await this.sock.removeProfilePicture(me.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1054,32 +1087,209 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return await this.sock.sendMessage(request.chatId, message, options);
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'image',
|
||||
request.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.IMAGE;
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
// Baileys' newsletter media path skips thumbnail and dimension computation.
|
||||
// Pre-compute them so iOS renders the image with the correct aspect ratio.
|
||||
if (isJidNewsletter(chatId)) {
|
||||
try {
|
||||
const thumb = await esm.b.extractImageThumb(message.image, 72);
|
||||
message.jpegThumbnail = thumb.buffer;
|
||||
message.width = thumb.original.width;
|
||||
message.height = thumb.original.height;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
{ error: err },
|
||||
'Failed to generate thumbnail for newsletter image',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'document',
|
||||
request.caption,
|
||||
);
|
||||
if (!message.mimetype) {
|
||||
message.mimetype = await detectMimetype(message['document']);
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVoice(request: MessageVoiceRequest) {
|
||||
const message: any = await this.fileToMessage(request.file, 'audio');
|
||||
message.mimetype = message.mimetype || WAMimeType.VOICE;
|
||||
if (request.convert) {
|
||||
message['audio'] = await this.mediaConverter.voice(message['audio']);
|
||||
message.mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendLinkCustomPreview(
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'video',
|
||||
request.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.VIDEO;
|
||||
if (request.convert) {
|
||||
message['video'] = await this.mediaConverter.video(message['video']);
|
||||
message.mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const duration = await esm.b
|
||||
.getAudioDuration(message['video'])
|
||||
.catch((err) => {
|
||||
this.logger.warn({ error: err }, 'Failed to get video duration');
|
||||
return undefined;
|
||||
});
|
||||
message.seconds = duration;
|
||||
const isGif = request.file?.mimetype === 'image/gif';
|
||||
if (isGif) {
|
||||
message.gifPlayback = true;
|
||||
message.externalShareFullVideoDurationInSeconds = 0;
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
message.ptv = parseBool(request.asNote);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendLinkCustomPreview(
|
||||
request: MessageLinkCustomPreviewRequest,
|
||||
): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
const preview = request.preview;
|
||||
const urlInfo = {
|
||||
'matched-text': preview.url,
|
||||
title: preview.title,
|
||||
description: preview.description,
|
||||
jpegThumbnail: null,
|
||||
highQualityThumbnail: null,
|
||||
};
|
||||
|
||||
if (request.preview.image) {
|
||||
const content: Buffer = await this.fileToBuffer(request.preview.image);
|
||||
if (!request.linkPreviewHighQuality) {
|
||||
// generate built-in thumbnail
|
||||
const thumbnail = await esm.b.extractImageThumb(content, 192);
|
||||
urlInfo.jpegThumbnail = thumbnail.buffer;
|
||||
} else {
|
||||
// upload HQ thumbnail
|
||||
const { imageMessage } = await esm.b.prepareWAMessageMedia(
|
||||
{ image: content },
|
||||
{
|
||||
upload: this.sock.waUploadToServer,
|
||||
mediaTypeOverride: 'thumbnail-link',
|
||||
options: { signal: AbortSignal.timeout(10_000) },
|
||||
},
|
||||
);
|
||||
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
|
||||
? Buffer.from(imageMessage.jpegThumbnail)
|
||||
: undefined;
|
||||
urlInfo.highQualityThumbnail = imageMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
text: request.text,
|
||||
linkPreview: urlInfo,
|
||||
};
|
||||
return this.sock.sendMessage(chatId, message as any, options);
|
||||
}
|
||||
|
||||
protected async uploadMedia(
|
||||
file: RemoteFile | BinaryFile,
|
||||
type,
|
||||
type: any,
|
||||
): Promise<any> {
|
||||
if (file && ('url' in file || 'data' in file)) {
|
||||
throw new AvailableInPlusVersion('Sending media (image, video, pdf)');
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
if (!('url' in file || 'data' in file)) {
|
||||
return;
|
||||
}
|
||||
const message: any = await this.fileToMessage(file, type);
|
||||
const options: MediaGenerationOptions = {
|
||||
logger: this.engineLogger,
|
||||
upload: this.sock.waUploadToServer,
|
||||
};
|
||||
const { imageMessage } = await esm.b.prepareWAMessageMedia(
|
||||
message,
|
||||
options,
|
||||
);
|
||||
return imageMessage;
|
||||
}
|
||||
|
||||
get client(): NowebClient {
|
||||
return new NowebClient(this.sock);
|
||||
}
|
||||
|
||||
protected async fileToMessage(
|
||||
file: RemoteFile | BinaryFile,
|
||||
type: any,
|
||||
caption = '',
|
||||
) {
|
||||
let content: Buffer;
|
||||
if ('url' in file) {
|
||||
content = await this.fetch(file.url);
|
||||
} else if ('data' in file) {
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
} else {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either "file.url" or "file.data" must be specified.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[type]: content,
|
||||
mimetype: file.mimetype,
|
||||
caption: caption,
|
||||
fileName: file.filename,
|
||||
ptt: type === 'audio',
|
||||
};
|
||||
}
|
||||
|
||||
private async fileToBuffer(file: FileType): Promise<Buffer> {
|
||||
let content: Buffer;
|
||||
if ('data' in file) {
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
} else if ('url' in file) {
|
||||
content = await this.fetch(file.url);
|
||||
} else {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either file.url or file.data must be specified.',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1097,8 +1307,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
sendList(request: SendListRequest): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendList(request: SendListRequest): Promise<any> {
|
||||
const jid = toJID(this.ensureSuffix(request.chatId));
|
||||
if (!isLidUser(jid) && !isPnUser(jid)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`List message can only be sent to a direct message chat.`,
|
||||
);
|
||||
}
|
||||
const message = request.message;
|
||||
const msg = {
|
||||
text: message.description || '',
|
||||
title: message.title,
|
||||
buttonText: message.button,
|
||||
footer: message.footer,
|
||||
sections: message.sections,
|
||||
} as any;
|
||||
const options = await this.getMessageOptions(request);
|
||||
return await this.sock.sendMessage(jid, msg, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1618,6 +1844,22 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
/**
|
||||
* Group methods
|
||||
*/
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const content: Buffer = await this.fileToBuffer(file);
|
||||
await this.sock.updateProfilePicture(id, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
await this.sock.removeProfilePicture(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public createGroup(request: CreateGroupRequest) {
|
||||
const participants = request.participants.map(getId);
|
||||
@@ -1889,6 +2131,84 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
const message: any = await this.fileToMessage(
|
||||
status.file,
|
||||
'image',
|
||||
status.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.IMAGE;
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
const message: any = await this.fileToMessage(status.file, 'audio');
|
||||
message.mimetype = message.mimetype || WAMimeType.VOICE;
|
||||
if (status.convert) {
|
||||
message['audio'] = await this.mediaConverter.voice(message['audio']);
|
||||
message.mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
backgroundColor: status.backgroundColor,
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
const message: any = await this.fileToMessage(
|
||||
status.file,
|
||||
'video',
|
||||
status.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.VIDEO;
|
||||
if (status.convert) {
|
||||
message['video'] = await this.mediaConverter.video(message['video']);
|
||||
message.mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
statusJidList: jids,
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
protected prepareMessageIdForStatus(status: StatusRequest) {
|
||||
if (status.id) {
|
||||
this.saveSentMessageId(status.id);
|
||||
@@ -1948,23 +2268,74 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const response = await this.client.searchChannelsByView(query);
|
||||
const channels: Channel[] = response.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
return {
|
||||
page: response.page,
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const response = await this.client.searchChannelsByText(query);
|
||||
const channels: Channel[] = response.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
return {
|
||||
page: response.page,
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const downloadMedia = query.downloadMedia;
|
||||
const updates = await this.sock.newsletterFetchPreviewMessages(
|
||||
'invite',
|
||||
inviteCode,
|
||||
query.limit,
|
||||
null,
|
||||
);
|
||||
const promises = [];
|
||||
for (const update of updates) {
|
||||
promises.push(
|
||||
this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia),
|
||||
);
|
||||
}
|
||||
let result = await Promise.all(promises);
|
||||
result = result.filter(Boolean);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async NewsletterFetchedUpdateToChannelMessage(
|
||||
update: NewsletterFetchedUpdate,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
let reactions: any = Object.fromEntries(
|
||||
update.reactions.map(({ code, count }) => [code, count]),
|
||||
);
|
||||
reactions = sortObjectByValues(reactions) || {};
|
||||
const message = await this.processIncomingMessage(
|
||||
update.message,
|
||||
downloadMedia,
|
||||
);
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: update.views,
|
||||
};
|
||||
}
|
||||
|
||||
protected toChannel(newsletter: NOWEBNewsletterMetadata): Channel {
|
||||
@@ -2010,7 +2381,23 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
request.name,
|
||||
request.description,
|
||||
);
|
||||
return this.toChannel(toNewsletterMetadata(newsletter));
|
||||
const channel = this.toChannel(toNewsletterMetadata(newsletter));
|
||||
|
||||
if (request.picture) {
|
||||
let file = request.picture;
|
||||
let picture: any;
|
||||
// @ts-ignore
|
||||
if (file.url) {
|
||||
file = file as RemoteFile;
|
||||
picture = await esm.b.getStream({ url: file.url });
|
||||
// @ts-ignore
|
||||
} else if (file.data) {
|
||||
file = file as BinaryFile;
|
||||
picture = Buffer.from(file.data, 'base64');
|
||||
}
|
||||
await this.sock.newsletterUpdatePicture(channel.id, picture);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async channelsGetChannel(id: string) {
|
||||
@@ -2079,10 +2466,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
messagesFromMe$ = messagesFromMe$.pipe(
|
||||
mergeMap((msg) => this.processIncomingMessage(msg, true)),
|
||||
filter(Boolean),
|
||||
DistinctMessages(),
|
||||
share(), // share it so we don't process twice in message.any
|
||||
);
|
||||
messagesFromOthers$ = messagesFromOthers$.pipe(
|
||||
mergeMap((msg) => this.processIncomingMessage(msg, true)),
|
||||
filter(Boolean),
|
||||
DistinctMessages(),
|
||||
share(), // share it so we don't process twice in message.any
|
||||
);
|
||||
const messagesFromAll$ = merge(messagesFromMe$, messagesFromOthers$);
|
||||
@@ -2112,14 +2503,25 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
|
||||
// Handle edited messages
|
||||
const messagesEdited$ = messagesUpsert$.pipe(
|
||||
filter((message) => IsEditedMessage(message.message)),
|
||||
filter(
|
||||
(message) =>
|
||||
IsEditedMessage(message.message) ||
|
||||
IsSecretEncryptedMessageEdit(message.message),
|
||||
),
|
||||
mergeMap(async (message): Promise<WAMessageEditedBody> => {
|
||||
const waMessage = this.toWAMessage(message);
|
||||
// Extract the body from editedMessage using extractBody function
|
||||
const content = normalizeMessageContent(message.message);
|
||||
const body = extractBody(content.protocolMessage.editedMessage) || '';
|
||||
// Extract the original message ID from protocolMessage.key
|
||||
const editedMessageId = content.protocolMessage.key?.id;
|
||||
let body = '';
|
||||
let editedMessageId: string | undefined;
|
||||
if (IsEditedMessage(message.message)) {
|
||||
const content = normalizeMessageContent(message.message);
|
||||
body = extractBody(content.protocolMessage.editedMessage) || '';
|
||||
editedMessageId = content.protocolMessage.key?.id;
|
||||
} else if (IsSecretEncryptedMessageEdit(message.message)) {
|
||||
const sem = message.message.secretEncryptedMessage;
|
||||
editedMessageId = sem.targetMessageKey?.id;
|
||||
body =
|
||||
(await this.tryDecryptNOWEBSecretMessageEdit(message, sem)) || '';
|
||||
}
|
||||
return {
|
||||
...waMessage,
|
||||
body: body,
|
||||
@@ -2439,6 +2841,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return;
|
||||
// Ignore edit, we have a dedicated event for that
|
||||
if (IsEditedMessage(message.message)) return;
|
||||
// Ignore secret-encrypted message edits (mobile app format), dedicated handler routes them
|
||||
if (IsSecretEncryptedMessageEdit(message.message)) return;
|
||||
|
||||
// Ignore history sync notifications
|
||||
if (IsHistorySyncNotification(message.message)) return;
|
||||
@@ -2469,6 +2873,99 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async tryDecryptNOWEBSecretMessageEdit(
|
||||
editMessage: proto.IWebMessageInfo,
|
||||
sem: proto.Message.ISecretEncryptedMessage,
|
||||
): Promise<string> {
|
||||
const targetKey = sem.targetMessageKey;
|
||||
const origMsgId = targetKey?.id;
|
||||
if (!origMsgId) {
|
||||
return '';
|
||||
}
|
||||
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
|
||||
Boolean,
|
||||
);
|
||||
let stored: proto.IWebMessageInfo | undefined;
|
||||
for (const jid of jidsToTry) {
|
||||
stored = await this.store?.loadMessage(jid, origMsgId);
|
||||
if (stored) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stored) {
|
||||
this.logger.debug(
|
||||
{ origMsgId: origMsgId },
|
||||
'NOWEB message edit decrypt: original message not found in store',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
const secretBytes =
|
||||
normalizeMessageContent(stored.message)?.messageContextInfo
|
||||
?.messageSecret ?? stored.message?.messageContextInfo?.messageSecret;
|
||||
if (!secretBytes || secretBytes.length !== 32) {
|
||||
this.logger.debug(
|
||||
{ origMsgId: origMsgId },
|
||||
'NOWEB message edit decrypt: missing messageSecret on original',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
const origSecret = Buffer.from(secretBytes);
|
||||
const encPayload = sem.encPayload ? Buffer.from(sem.encPayload) : null;
|
||||
const encIv = sem.encIv ? Buffer.from(sem.encIv) : null;
|
||||
if (!encPayload || !encIv) {
|
||||
return '';
|
||||
}
|
||||
const editInfo = {
|
||||
Chat: editMessage.key?.remoteJid,
|
||||
Sender:
|
||||
editMessage.key?.participant ||
|
||||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
|
||||
};
|
||||
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
|
||||
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
|
||||
fromMe: targetKey.fromMe,
|
||||
remoteJID: targetKey.remoteJid,
|
||||
participant: targetKey.participant,
|
||||
});
|
||||
const candidates: string[] = [primaryOrig];
|
||||
const remoteNonAD = targetKey.remoteJid
|
||||
? jidToNonAD(targetKey.remoteJid)
|
||||
: '';
|
||||
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
|
||||
candidates.push(remoteNonAD);
|
||||
}
|
||||
const participantNonAD = targetKey.participant
|
||||
? jidToNonAD(targetKey.participant)
|
||||
: '';
|
||||
if (participantNonAD && !candidates.includes(participantNonAD)) {
|
||||
candidates.push(participantNonAD);
|
||||
}
|
||||
let lastErr: unknown;
|
||||
for (const origSenderJid of candidates) {
|
||||
try {
|
||||
const decoded = decryptSecretEncryptedMessageEditProto({
|
||||
encPayload: encPayload,
|
||||
encIv: encIv,
|
||||
origMsgId: origMsgId,
|
||||
origSenderJid: origSenderJid,
|
||||
modificationSenderJid: modificationSenderJid,
|
||||
origMsgSecret: origSecret,
|
||||
});
|
||||
const text = extractBody(decoded) || '';
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
this.logger.debug(
|
||||
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
|
||||
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
|
||||
protected async processIncomingMessage(
|
||||
message,
|
||||
downloadMedia: boolean,
|
||||
@@ -2766,11 +3263,16 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message): Promise<WAMedia | null> {
|
||||
const processor = new NOWEBEngineMediaProcessor(this, this.loggerBuilder);
|
||||
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
|
||||
this,
|
||||
this.loggerBuilder,
|
||||
);
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
return this.mediaManager.processMedia(processor, message, this.name);
|
||||
}
|
||||
|
||||
protected async getMessageOptions(request: {
|
||||
id?: string;
|
||||
chatId: string;
|
||||
reply_to?: string;
|
||||
}) {
|
||||
@@ -2782,7 +3284,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
quoted = await this.store.loadMessage(jid, key.id);
|
||||
}
|
||||
const chat = await this.store.getChat(jid);
|
||||
const messageId = this.generateMessageID();
|
||||
const messageId = request.id ? request.id : this.generateMessageID();
|
||||
this.saveSentMessageId(messageId);
|
||||
return {
|
||||
quoted: quoted,
|
||||
ephemeralExpiration: chat?.ephemeralExpiration,
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { DataStore } from '../../../abc/DataStore';
|
||||
import { MongoStorage } from './mongodb/MongoStorage';
|
||||
import { INowebStorage } from './INowebStorage';
|
||||
import { PsqlStorage } from './psql/PsqlStorage';
|
||||
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
|
||||
|
||||
export class NowebStorageFactoryCore {
|
||||
createStorage(store: DataStore, name: string): INowebStorage {
|
||||
if (store instanceof MongoStore) {
|
||||
return this.buildStorageMongo(store, name);
|
||||
}
|
||||
if (store instanceof PsqlStore) {
|
||||
return this.buildPsql(store, name);
|
||||
}
|
||||
if (store instanceof LocalStore) {
|
||||
return this.buildStorageSqlite3(store, name);
|
||||
}
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private buildStorageMongo(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
return new MongoStorage(db);
|
||||
}
|
||||
|
||||
private buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Storage');
|
||||
return new PsqlStorage(knex);
|
||||
}
|
||||
|
||||
private buildStorageSqlite3(store: LocalStore, name: string) {
|
||||
const filePath = store.getFilePath(name, 'store.sqlite3');
|
||||
return new Sqlite3Storage(filePath);
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user