Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91d541387b | ||
|
|
9dbd45da6e | ||
|
|
adc1c3960c | ||
|
|
ff742d2e84 | ||
|
|
10bd4d6271 | ||
|
|
874cafe114 | ||
|
|
0ce2c4b9fa | ||
|
|
a8a7988bfa | ||
|
|
2940d93b81 | ||
|
|
88707cf72b | ||
|
|
b0f883c6a8 | ||
|
|
4cddb766c2 | ||
|
|
6675492901 | ||
|
|
a9d030af2a | ||
|
|
2782a188a9 | ||
|
|
256688497e | ||
|
|
4def193c57 | ||
|
|
7f3a2e6aa5 | ||
|
|
60e4f13b67 | ||
|
|
2d6cfeb0fa | ||
|
|
03e85434df | ||
|
|
0ce4c01c48 | ||
|
|
0e95890ca4 | ||
|
|
072f4b2ab0 | ||
|
|
47a91ef669 | ||
|
|
5143bd1ef8 | ||
|
|
0efc0751c9 | ||
|
|
51dda739cb | ||
|
|
3b28826679 | ||
|
|
81646bbaa2 | ||
|
|
d8d84107fb | ||
|
|
58d917d374 | ||
|
|
f8f0063975 | ||
|
|
73faf330e3 | ||
|
|
5a1ebb186d | ||
|
|
54d6d715b0 | ||
|
|
5d9d6e29e2 | ||
|
|
cf3385f624 | ||
|
|
0346c5e7b9 | ||
|
|
443652b0a7 | ||
|
|
1e73c3dc6a | ||
|
|
afa9d4208f | ||
|
|
64eda25b5d | ||
|
|
cfc6c931a3 | ||
|
|
35bc56c948 | ||
|
|
371bde4c3e | ||
|
|
9497e2900e | ||
|
|
51bbc9998a | ||
|
|
057f7bce56 | ||
|
|
52412fb4cc | ||
|
|
a57f3443de | ||
|
|
9b2537e7c7 | ||
|
|
a378ad4c3d |
No files matched your search
@@ -22,6 +22,7 @@ WHATSAPP_SWAGGER_ENABLED=True
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
|
||||
# "Firefox (YourApp)" in Linked Devices
|
||||
# WAHA_CLIENT_DEVICE_NAME=YourApp
|
||||
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
|
||||
@@ -100,7 +100,51 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
- name: Build (attempt 1)
|
||||
id: build1
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if: steps.build1.outcome == 'failure'
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 2)
|
||||
id: build2
|
||||
if: steps.build1.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if: steps.build2.outcome == 'failure'
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 3)
|
||||
if: steps.build2.outcome == 'failure'
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -6,8 +6,37 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
if: github.repository == 'devlikeapro/waha-plus'
|
||||
runs-on: ubuntu-22.04
|
||||
name: Unit tests
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Enable Corepack
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare yarn@4.9.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
unset GIT_CONFIG_PARAMETERS
|
||||
yarn install --immutable
|
||||
|
||||
- name: Run unit tests
|
||||
run: yarn test:unit
|
||||
|
||||
docker-build:
|
||||
if: github.repository == 'devlikeapro/waha-plus'
|
||||
needs: unit-tests
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name:
|
||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||
@@ -15,12 +44,23 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Chromium - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
tag: 'dev'
|
||||
platform: 'amd64'
|
||||
browser: 'chromium'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# Chrome - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
tag: 'dev-chrome'
|
||||
platform: 'amd64'
|
||||
browser: 'chrome'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# Chromium - ARM
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
||||
tag: 'dev-arm'
|
||||
platform: 'linux/arm64'
|
||||
@@ -71,8 +111,60 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
- name: Build (attempt 1)
|
||||
id: build1
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
|
||||
== 'failure' }}
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 2)
|
||||
id: build2
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
|
||||
== 'failure' }}
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
|
||||
== 'failure' }}
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 3)
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
|
||||
== 'failure' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
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
|
||||
@@ -70,6 +75,7 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
|
||||
`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.
|
||||
- For configs, prefer runtime configurability over constants. Environment keys
|
||||
|
||||
@@ -32,10 +32,10 @@ push:
|
||||
docker push devlikeapro/waha
|
||||
|
||||
for-swagger:
|
||||
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
|
||||
export WHATSAPP_SWAGGER_CONFIG_ADVANCED=true && export WHATSAPP_SWAGGER_PASSWORD=666 && yarn start
|
||||
|
||||
up-noweb:
|
||||
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17
|
||||
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-01-25
|
||||
|
||||
up-noweb-libsignal:
|
||||
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||
|
||||
@@ -75,7 +75,6 @@ services:
|
||||
- POSTGRES_USER=postgres
|
||||
# Please provide your own password.
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
|
||||
+51
-21
@@ -16,23 +16,27 @@
|
||||
"start:prod-exit": "node dist/main",
|
||||
"lint": "oxlint --deny-warnings",
|
||||
"lint-fix": "oxlint --fix --deny-warnings",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test": "yarn test:unit",
|
||||
"test:watch": "jest --selectProjects unit --watch",
|
||||
"test:cov": "jest --selectProjects unit --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
|
||||
"test:unit": "jest --selectProjects unit",
|
||||
"test:e2e": "jest --selectProjects e2e",
|
||||
"test:e8e": "yarn test:e2e",
|
||||
"test:all": "yarn test:unit && yarn test:e8e",
|
||||
"gows:proto:fetch": "node scripts/gows-proto.js fetch",
|
||||
"gows:proto:build": "node scripts/gows-proto.js build",
|
||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17",
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-01-25",
|
||||
"@adiwajshing/keyed-db": "^0.2.4",
|
||||
"@aws-sdk/client-s3": "^3.633.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
||||
"@bull-board/api": "^6.9.1",
|
||||
"@bull-board/express": "^6.9.1",
|
||||
"@bull-board/nestjs": "^6.9.1",
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||
"@liaoliaots/nestjs-redis": "^9",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
@@ -148,21 +152,47 @@
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^@waha/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": ".test.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "unit",
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^@waha/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": ".test.ts$",
|
||||
"testPathIgnorePatterns": [
|
||||
"/__tests__/e2e/"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "e2e",
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^@waha/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": "__tests__/e2e/.*\\.test\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.2"
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import axios from 'axios';
|
||||
import WebSocket = require('ws');
|
||||
|
||||
const VALID_API_KEY = '666';
|
||||
const VALID_USERNAME = 'admin';
|
||||
const VALID_PASSWORD = '666';
|
||||
const VALID_SESSION_API_KEY = 'key_0fQfI3n3rFVsczBbBfknLXKUreY2my6J';
|
||||
|
||||
const BASE_URL = (process.env.WAHA_BASE_URL ?? 'http://localhost:3000').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const WS_BASE_URL = BASE_URL.replace(/^http/, 'ws');
|
||||
|
||||
const HTTP_OK = 200;
|
||||
const HTTP_CREATED = 201;
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
const HTTP_UNPROCESSABLE = 422;
|
||||
const WS_POLICY_VIOLATION = 1008;
|
||||
const WS_OK_CODES = [1000, 1005];
|
||||
|
||||
describe('admin - GET /api/sessions/{name}', () => {
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('valid api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('invalid api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
|
||||
headers: { 'X-Api-Key': '123' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin - POST /api/sessions', () => {
|
||||
test('admin key can create a session without name and remove it', async () => {
|
||||
const createResponse = await axios.post(
|
||||
`${BASE_URL}/api/sessions`,
|
||||
{},
|
||||
{
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
expect(createResponse.status).toBe(HTTP_CREATED);
|
||||
expect(createResponse.data?.name).toBeTruthy();
|
||||
|
||||
const deleteResponse = await axios.delete(
|
||||
`${BASE_URL}/api/sessions/${createResponse.data.name}`,
|
||||
{
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
expect(deleteResponse.status).toBe(HTTP_OK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions?all=true', () => {
|
||||
beforeAll(async () => {
|
||||
const createResponse = await axios.post(
|
||||
`${BASE_URL}/api/sessions`,
|
||||
{ name: 'another' },
|
||||
{
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
if (![HTTP_CREATED, HTTP_UNPROCESSABLE].includes(createResponse.status)) {
|
||||
throw new Error(
|
||||
`Unexpected status for creating "another" session: ${createResponse.status}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const deleteResponse = await axios.delete(
|
||||
`${BASE_URL}/api/sessions/another`,
|
||||
{
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
if (![HTTP_OK, HTTP_NOT_FOUND].includes(deleteResponse.status)) {
|
||||
throw new Error(
|
||||
`Unexpected status for deleting "another" session: ${deleteResponse.status}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin key returns two sessions', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
expect(response.data).toHaveLength(2);
|
||||
expect(response.data.map((session) => session.name).sort()).toEqual([
|
||||
'another',
|
||||
'default',
|
||||
]);
|
||||
});
|
||||
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('session key returns one session', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.data[0]?.name).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/server/version', () => {
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/version`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('admin api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/version`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('session api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/version`, {
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/server/environment', () => {
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('admin api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('session api key is forbidden', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin - GET /health', () => {
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/health`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('valid api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/health`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('invalid api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/health`, {
|
||||
headers: { 'X-Api-Key': '123' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/ws', () => {
|
||||
const buildWsUrl = (apiKey?: string) => {
|
||||
const wsUrl = new URL('/ws', WS_BASE_URL);
|
||||
wsUrl.searchParams.set('session', '*');
|
||||
wsUrl.searchParams.set('events', '*');
|
||||
if (apiKey) {
|
||||
wsUrl.searchParams.set('x-api-key', apiKey);
|
||||
}
|
||||
return wsUrl.toString();
|
||||
};
|
||||
|
||||
const waitForOpen = (socket: WebSocket, timeoutMs = 5_000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('WebSocket open timeout'));
|
||||
}, timeoutMs);
|
||||
socket.once('open', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const waitForClose = (socket: WebSocket, timeoutMs = 5_000) =>
|
||||
new Promise<{ code: number; reason: string }>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('WebSocket close timeout'));
|
||||
}, timeoutMs);
|
||||
socket.once('close', (code, reason) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
test('no api key is unauthorized', async () => {
|
||||
const socket = new WebSocket(buildWsUrl());
|
||||
const { code } = await waitForClose(socket);
|
||||
expect(code).toBe(WS_POLICY_VIOLATION);
|
||||
});
|
||||
|
||||
test('admin api key is ok', async () => {
|
||||
const socket = new WebSocket(buildWsUrl(VALID_API_KEY));
|
||||
await waitForOpen(socket);
|
||||
socket.close();
|
||||
const { code } = await waitForClose(socket);
|
||||
expect(WS_OK_CODES).toContain(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/files/test.txt', () => {
|
||||
// create "test.txt" in current dir/.media/test.txt
|
||||
let file = null;
|
||||
beforeAll(() => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const mediaDir = path.resolve('./.media');
|
||||
if (!fs.existsSync(mediaDir)) {
|
||||
fs.mkdirSync(mediaDir);
|
||||
}
|
||||
const filePath = path.join(mediaDir, 'test.txt');
|
||||
fs.writeFileSync(filePath, 'This is a test file.');
|
||||
file = filePath;
|
||||
});
|
||||
test('no api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('valid api key is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
|
||||
headers: { 'X-Api-Key': VALID_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('invalid api key is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
|
||||
headers: { 'X-Api-Key': '123' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
test('no auth is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('valid auth is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/`, {
|
||||
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('wrong auth is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/`, {
|
||||
auth: { username: VALID_USERNAME, password: 'password-another' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /dashboard', () => {
|
||||
test('no auth is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/dashboard/`, {
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('valid auth is ok', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/dashboard/`, {
|
||||
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('wrong auth is unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/dashboard/`, {
|
||||
auth: { username: VALID_USERNAME, password: 'password-another' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session key - GET /api/sessions/{name}', () => {
|
||||
test('default key - default session - is authorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_OK);
|
||||
});
|
||||
|
||||
test('default key - create a new sessions - forbidden', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/sessions`,
|
||||
{ name: 'newone' },
|
||||
{
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(HTTP_FORBIDDEN);
|
||||
});
|
||||
|
||||
test('another key - default session - unauthorized', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
|
||||
headers: { 'X-Api-Key': 'key_another' },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('default key - another session - forbidden', async () => {
|
||||
const response = await axios.get(`${BASE_URL}/api/sessions/another`, {
|
||||
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
expect(response.status).toBe(HTTP_FORBIDDEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
|
||||
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')
|
||||
@ApiTags('🔑 Api Keys')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
export class ApiKeysController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
@ApiOperation({ summary: 'Get all API keys' })
|
||||
async list(): Promise<ApiKeyDTO[]> {
|
||||
const keys = await this.manager.apiKeyRepository.list();
|
||||
return keys.map((key) => ApiKeyToDTO(key));
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an API key' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async update(
|
||||
@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);
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
}
|
||||
|
||||
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
|
||||
return {
|
||||
id: apikey.id,
|
||||
key: apikey.key,
|
||||
isActive: apikey.isActive,
|
||||
isAdmin: apikey.isAdmin,
|
||||
session: apikey.session,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
@@ -24,10 +25,17 @@ import {
|
||||
RequestCodeRequest,
|
||||
} from '../structures/auth.dto';
|
||||
import { Base64File } from '../structures/files.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/auth')
|
||||
@ApiTags('🔑 Auth')
|
||||
@ApiTags('📱 Pairing')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
class AuthController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -14,10 +15,17 @@ import {
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { RejectCallRequest } from '../structures/calls.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/calls')
|
||||
@ApiTags('📞 Calls')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class CallsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -39,10 +40,17 @@ import {
|
||||
parseChannelInviteLink,
|
||||
WhatsappSession,
|
||||
} from '../core/abc/session.abc';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/channels')
|
||||
@ApiTags('📢 Channels')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class ChannelsController {
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -40,11 +41,18 @@ import {
|
||||
} from '../structures/chats.dto';
|
||||
import { EditMessageRequest } from '../structures/chatting.dto';
|
||||
import { SortOrder } from '@waha/structures/pagination.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/chats')
|
||||
@ApiTags('💬 Chats')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
class ChatsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -48,15 +49,22 @@ import {
|
||||
mentionsAll,
|
||||
validateRequestMentions,
|
||||
} from '@waha/core/utils/mentions.all';
|
||||
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 { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api')
|
||||
@ApiTags('📤 Chatting')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class ChattingController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post('/sendText')
|
||||
@ApiOperation({ summary: 'Send a text message' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -72,6 +80,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')))
|
||||
async sendImage(@Body() request: MessageImageRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -87,6 +96,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')))
|
||||
async sendFile(@Body() request: MessageFileRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -102,6 +112,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')))
|
||||
async sendVoice(@Body() request: MessageVoiceRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendVoice(request);
|
||||
@@ -113,6 +124,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')))
|
||||
async sendVideo(@Body() request: MessageVideoRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -128,6 +140,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')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendLinkCustomPreview(
|
||||
@Body() request: MessageLinkCustomPreviewRequest,
|
||||
@@ -147,6 +160,7 @@ export class ChattingController {
|
||||
description: 'Send Buttons',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendButtons(@Body() request: SendButtonsRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -158,6 +172,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')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendList(@Body() request: SendListRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -165,6 +180,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/forwardMessage')
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async forwardMessage(
|
||||
@Body() request: MessageForwardRequest,
|
||||
): Promise<WAMessage> {
|
||||
@@ -173,6 +189,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/sendSeen')
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async sendSeen(@Body() chat: SendSeenRequest) {
|
||||
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
|
||||
if (!hasMessageId) {
|
||||
@@ -188,6 +205,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/startTyping')
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async startTyping(@Body() chat: ChatRequest) {
|
||||
// It's infinitive action
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
@@ -196,6 +214,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/stopTyping')
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async stopTyping(@Body() chat: ChatRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
await whatsapp.stopTyping(chat);
|
||||
@@ -204,6 +223,7 @@ export class ChattingController {
|
||||
|
||||
@Put('/reaction')
|
||||
@ApiOperation({ summary: 'React to a message with an emoji' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async setReaction(@Body() request: MessageReactionRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.setReaction(request);
|
||||
@@ -211,6 +231,7 @@ export class ChattingController {
|
||||
|
||||
@Put('/star')
|
||||
@ApiOperation({ summary: 'Star or unstar a message' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async setStar(@Body() request: MessageStarRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
await whatsapp.setStar(request);
|
||||
@@ -222,6 +243,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')))
|
||||
async sendPoll(@Body() request: MessagePollRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendPoll(request);
|
||||
@@ -232,6 +254,7 @@ export class ChattingController {
|
||||
summary: 'Vote on a poll',
|
||||
description: 'Cast vote(s) on an existing poll message',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendPollVote(@Body() request: MessagePollVoteRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -239,12 +262,14 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/sendLocation')
|
||||
@CheckPolicies(CanSession(Action.Use, 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')))
|
||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendContactVCard(request);
|
||||
@@ -254,6 +279,7 @@ export class ChattingController {
|
||||
@ApiOperation({
|
||||
summary: 'Reply on a button message',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async sendButtonsReply(@Body() request: MessageButtonReply) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -262,6 +288,7 @@ export class ChattingController {
|
||||
|
||||
@Get('/sendText')
|
||||
@ApiOperation({ summary: 'Send a text message', deprecated: true })
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
async sendTextGet(@Query() query: MessageTextQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
const msg = new MessageTextRequest();
|
||||
@@ -276,6 +303,7 @@ export class ChattingController {
|
||||
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getMessages(
|
||||
@Query() query: GetMessageQuery,
|
||||
@@ -292,6 +320,7 @@ export class ChattingController {
|
||||
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
async DEPRECATED_checkNumberStatus(
|
||||
@Query() request: CheckNumberStatusQuery,
|
||||
): Promise<WANumberExistResult> {
|
||||
@@ -305,6 +334,7 @@ export class ChattingController {
|
||||
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async reply(@Body() request: MessageReplyRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.reply(request);
|
||||
@@ -312,6 +342,7 @@ export class ChattingController {
|
||||
|
||||
@Post('/sendLinkPreview')
|
||||
@ApiOperation({ deprecated: true })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendLinkPreview(request);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -21,15 +22,22 @@ import {
|
||||
ContactRequest,
|
||||
ContactsPaginationParams,
|
||||
} from '../structures/contacts.dto';
|
||||
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 { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/contacts')
|
||||
@ApiTags('👤 Contacts')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class ContactsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/all')
|
||||
@ApiOperation({ summary: 'Get all contacts' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getAll(
|
||||
@Query() query: SessionQuery,
|
||||
@@ -45,6 +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')))
|
||||
async get(@Query() query: ContactQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getContact(query);
|
||||
@@ -52,6 +61,7 @@ export class ContactsController {
|
||||
|
||||
@Get('/check-exists')
|
||||
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
async checkExists(
|
||||
@Query() request: CheckNumberStatusQuery,
|
||||
): Promise<WANumberExistResult> {
|
||||
@@ -65,6 +75,7 @@ export class ContactsController {
|
||||
description:
|
||||
'Returns null if you do not have permission to read their status.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
async getAbout(@Query() query: ContactQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getContactAbout(query);
|
||||
@@ -76,6 +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')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
@@ -88,6 +100,7 @@ export class ContactsController {
|
||||
|
||||
@Post('/block')
|
||||
@ApiOperation({ summary: 'Block contact' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async block(@Body() request: ContactRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.blockContact(request);
|
||||
@@ -95,6 +108,7 @@ export class ContactsController {
|
||||
|
||||
@Post('/unblock')
|
||||
@ApiOperation({ summary: 'Unblock contact' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
async unblock(@Body() request: ContactRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.unblockContact(request);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Body, Controller, Delete, Param, Put, UsePipes } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Param,
|
||||
Put,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
|
||||
import {
|
||||
@@ -8,13 +18,16 @@ import {
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
|
||||
import { SessionManager } from './core/abc/manager.abc';
|
||||
import { Result } from './structures/base.dto';
|
||||
import { ContactUpdateBody } from './structures/contacts.dto';
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { Result } from '../structures/base.dto';
|
||||
import { ContactUpdateBody } from '../structures/contacts.dto';
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/contacts')
|
||||
@ApiTags('👤 Contacts')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class ContactsSessionController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -19,10 +20,17 @@ import {
|
||||
EventMessageRequest,
|
||||
} from '../structures/events.dto';
|
||||
import { WAMessage } from '../structures/responses.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/events')
|
||||
@ApiTags('📅 Events')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class EventsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -40,10 +41,17 @@ import {
|
||||
SettingsSecurityChangeInfo,
|
||||
SubjectRequest,
|
||||
} from '../structures/groups.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/groups')
|
||||
@ApiTags('👥 Groups')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class GroupsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { HealthCheck } from '@nestjs/terminus';
|
||||
|
||||
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('health')
|
||||
@ApiTags('🔍 Observability')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
export class HealthController {
|
||||
constructor(private wahaHealth: WAHAHealthCheckService) {}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -26,10 +27,17 @@ import {
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/labels')
|
||||
@ApiTags('🏷️ Labels')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class LabelsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { isLidUser } from '@waha/core/utils/jids';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/lids')
|
||||
@ApiTags('👤 Contacts')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class LidsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Post,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -21,10 +22,17 @@ import {
|
||||
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/media')
|
||||
@ApiTags('🖼️ Media')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
class MediaController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
|
||||
@@ -20,10 +21,17 @@ import {
|
||||
WAHAChatPresences,
|
||||
WAHASessionPresence,
|
||||
} from '../structures/presence.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/presence')
|
||||
@ApiTags('✅ Presence')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class PresenceController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Put,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -22,10 +23,17 @@ import {
|
||||
ProfilePictureRequest,
|
||||
ProfileStatusRequest,
|
||||
} from '@waha/structures/profile.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/profile')
|
||||
@ApiTags('🆔 Profile')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
export class ProfileController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -4,23 +4,35 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
import { SessionQuery } from '../structures/base.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromQuery } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api')
|
||||
@ApiTags('🖼️ Screenshot')
|
||||
@ApiTags('📱 Pairing')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
export class ScreenshotController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/screenshot')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get a screenshot of the current WhatsApp session (**WEBJS** only)',
|
||||
})
|
||||
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
|
||||
@ApiFileAcceptHeader('image/jpeg')
|
||||
async screenshot(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -22,10 +23,16 @@ import {
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { VERSION } from '@waha/version';
|
||||
import * as lodash from 'lodash';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/server')
|
||||
@ApiTags('🔍 Observability')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class ServerController {
|
||||
private logger: Logger;
|
||||
|
||||
@@ -35,12 +42,14 @@ export class ServerController {
|
||||
|
||||
@Get('version')
|
||||
@ApiOperation({ summary: 'Get the version of the server' })
|
||||
@CheckPolicies(CanServer(Action.Read))
|
||||
get(): WAHAEnvironment {
|
||||
return VERSION;
|
||||
}
|
||||
|
||||
@Get('environment')
|
||||
@ApiOperation({ summary: 'Get the server environment' })
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
environment(
|
||||
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
@@ -67,6 +76,7 @@ export class ServerController {
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get the server status' })
|
||||
@CheckPolicies(CanServer(Action.Read))
|
||||
async status(): Promise<ServerStatusResponse> {
|
||||
const now = Date.now();
|
||||
const uptime = Math.floor(process.uptime() * 1000);
|
||||
@@ -87,6 +97,7 @@ export class ServerController {
|
||||
"If you're using docker, after calling this endpoint Docker will start a new container, " +
|
||||
'so you can use this endpoint to restart the server',
|
||||
})
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async stop(@Body() request: StopRequest): Promise<StopResponse> {
|
||||
const timeout = 1_000;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
NotFoundException,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -25,10 +26,17 @@ import {
|
||||
CpuProfileQuery,
|
||||
} from '@waha/structures/server.debug.dto';
|
||||
import { createReadStream } from 'fs';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/server/debug')
|
||||
@ApiTags('🔍 Observability')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
export class ServerDebugController {
|
||||
private logger: Logger;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
|
||||
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiOAuth2,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
SessionApiParam,
|
||||
SessionParam,
|
||||
@@ -34,18 +42,24 @@ import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import {
|
||||
ListSessionsQuery,
|
||||
MeInfo,
|
||||
SessionExpand,
|
||||
SessionInfoQuery,
|
||||
SessionCreateRequest,
|
||||
SessionDTO,
|
||||
SessionExpand,
|
||||
SessionInfo,
|
||||
SessionInfoQuery,
|
||||
SessionUpdateRequest,
|
||||
} from '../structures/sessions.dto';
|
||||
import { SessionExamples } from './sessions.examples';
|
||||
import { FilterSessions } from '../core/auth/casl.ability';
|
||||
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';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/sessions')
|
||||
@ApiTags('🖥️ Sessions')
|
||||
@UseGuards(PoliciesGuard)
|
||||
class SessionsController {
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
@@ -57,11 +71,19 @@ class SessionsController {
|
||||
}
|
||||
|
||||
@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[]> {
|
||||
const sessions = await this.manager.getSessions(query.all);
|
||||
let sessions = await this.manager.getSessions(query.all);
|
||||
if (!req.user.isAdmin) {
|
||||
sessions = FilterSessions(req.ability, Action.Read, sessions);
|
||||
}
|
||||
if (query.expand?.includes(SessionExpand.apps)) {
|
||||
for (const session of sessions) {
|
||||
session.apps = await this.appsService.list(this.manager, session.name);
|
||||
@@ -73,6 +95,7 @@ class SessionsController {
|
||||
@Get('/:session')
|
||||
@ApiOperation({ summary: 'Get session information' })
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async get(
|
||||
@Param('session') name: string,
|
||||
@@ -91,6 +114,7 @@ class SessionsController {
|
||||
@Get(':session/me')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get information about the authenticated account' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
|
||||
return session.getSessionMeInfo();
|
||||
}
|
||||
@@ -102,6 +126,7 @@ class SessionsController {
|
||||
'Create session a new session (and start it at the same time if required).',
|
||||
})
|
||||
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
|
||||
@CheckPolicies(CanSession(Action.Create))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
|
||||
const name = request.name || generatePrefixedId('session');
|
||||
@@ -139,6 +164,7 @@ class SessionsController {
|
||||
})
|
||||
@SessionApiParam
|
||||
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
|
||||
async update(
|
||||
@Param('session') name: string,
|
||||
@@ -177,6 +203,7 @@ class SessionsController {
|
||||
description:
|
||||
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async delete(@Param('session') name: string): Promise<void> {
|
||||
await this.withLock(name, async () => {
|
||||
@@ -195,6 +222,7 @@ class SessionsController {
|
||||
description:
|
||||
'Start the session with the given name. The session must exist. Idempotent operation.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async start(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
@@ -214,6 +242,7 @@ class SessionsController {
|
||||
summary: 'Stop the session',
|
||||
description: 'Stop the session with the given name. Idempotent operation.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async stop(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
@@ -229,6 +258,7 @@ 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')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async logout(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
@@ -253,6 +283,7 @@ class SessionsController {
|
||||
summary: 'Restart the session',
|
||||
description: 'Restart the session with the given name.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async restart(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.manager.restart(name);
|
||||
@@ -266,6 +297,7 @@ class SessionsController {
|
||||
'Create session (if not exists) or update a config (if exists) and start it.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
|
||||
async DEPRACATED_start(
|
||||
@Body() request: SessionStartDeprecatedRequest,
|
||||
): Promise<SessionDTO> {
|
||||
@@ -292,6 +324,7 @@ class SessionsController {
|
||||
description: 'Stop session and Logout by default.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
|
||||
async DEPRECATED_stop(
|
||||
@Body() request: SessionStopDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
@@ -323,6 +356,7 @@ class SessionsController {
|
||||
description: 'Stop, Logout and Delete session.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
|
||||
async DEPRECATED_logout(
|
||||
@Body() request: SessionLogoutDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
SessionApiParam,
|
||||
@@ -15,10 +15,17 @@ import {
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '../structures/status.dto';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanSession, FromParam } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/status')
|
||||
@ApiTags('🟢 Status')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
|
||||
class StatusController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { WAHAEnvironment } from '../structures/environment.dto';
|
||||
import { VERSION } from '../version';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/version')
|
||||
@ApiTags('🔍 Observability')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanServer(Action.Read))
|
||||
export class VersionController {
|
||||
@Get('')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
|
||||
import { EventWildUnmask } from '@waha/utils/events';
|
||||
import { generatePrefixedId } from '@waha/utils/ids';
|
||||
import { IncomingMessage } from 'http';
|
||||
import * as url from 'url';
|
||||
import { URL } from 'url';
|
||||
import { Server } from 'ws';
|
||||
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
|
||||
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
|
||||
|
||||
export enum WebSocketCloseCode {
|
||||
NORMAL = 1000,
|
||||
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
private auth: WebSocketAuth,
|
||||
private readonly casl: CaslAbilityFactory,
|
||||
) {
|
||||
this.logger = new Logger('WebsocketGateway');
|
||||
this.heartbeat = new WebsocketHeartbeatJob(
|
||||
@@ -62,11 +65,16 @@ export class WebsocketGatewayCore
|
||||
);
|
||||
}
|
||||
|
||||
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
|
||||
async handleConnection(
|
||||
socket: WebSocket,
|
||||
request: IncomingMessage,
|
||||
...args
|
||||
): Promise<any> {
|
||||
// wsc - websocket client
|
||||
socket.id = generatePrefixedId('wsc');
|
||||
|
||||
if (!this.auth.validateRequest(request)) {
|
||||
const user = await this.auth.validateRequest(request);
|
||||
if (!user) {
|
||||
// Not authorized - close connection
|
||||
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
|
||||
this.logger.debug(
|
||||
@@ -75,9 +83,19 @@ export class WebsocketGatewayCore
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
||||
const params = this.getParams(request);
|
||||
const session: string = params.session;
|
||||
let session: string = params.session;
|
||||
const ability = this.casl.createForUser(user);
|
||||
if (session == '*' && !ability.can(Action.Use, 'all')) {
|
||||
// Limit user to listen only the session events
|
||||
session = user.session;
|
||||
}
|
||||
|
||||
if (!ability.can(Action.Use, new SessionName(session))) {
|
||||
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
|
||||
}
|
||||
|
||||
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
||||
const events: WAHAEvents[] = params.events;
|
||||
this.logger.debug(
|
||||
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
||||
@@ -86,12 +104,17 @@ export class WebsocketGatewayCore
|
||||
const sub = this.manager
|
||||
.getSessionEvents(session, events)
|
||||
.subscribe((data) => {
|
||||
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
|
||||
socket.send(JSON.stringify(data), (err) => {
|
||||
if (!err) {
|
||||
return;
|
||||
}
|
||||
this.logger.error(`Error sending data to client: ${err}`);
|
||||
setImmediate(() => {
|
||||
this.logger.debug(
|
||||
`Sending data to client, event.id: ${data.id}`,
|
||||
data,
|
||||
);
|
||||
socket.send(JSON.stringify(data), (err) => {
|
||||
if (!err) {
|
||||
return;
|
||||
}
|
||||
this.logger.error(`Error sending data to client: ${err}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
socket.on('close', () => {
|
||||
@@ -101,14 +124,13 @@ export class WebsocketGatewayCore
|
||||
}
|
||||
|
||||
private getParams(request: IncomingMessage) {
|
||||
const query = url.parse(request.url, true).query;
|
||||
const session = (query.session as string) || '*';
|
||||
let paramsEvents = (query.events as string[]) || '*';
|
||||
// if params events string - split by ","
|
||||
if (typeof paramsEvents === 'string') {
|
||||
paramsEvents = paramsEvents.split(',');
|
||||
}
|
||||
const events = this.eventUnmask.unmask(paramsEvents);
|
||||
// We need only search params, so localhost is fine here
|
||||
const query = new URL(request.url, 'http://localhost').searchParams;
|
||||
const session = query.get('session') || '*';
|
||||
const paramsEvents = query.getAll('events');
|
||||
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
|
||||
const eventsList = eventsRaw.flatMap((value) => value.split(','));
|
||||
const events = this.eventUnmask.unmask(eventsList);
|
||||
return { session, events };
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export class AxiosLogging {
|
||||
|
||||
// Log debug for 2xx/3xx responses
|
||||
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
|
||||
if (type.startsWith('application/json')) {
|
||||
if (type?.startsWith('application/json')) {
|
||||
const data = JSON.stringify(response.data);
|
||||
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -17,6 +20,10 @@ import {
|
||||
IAppsService,
|
||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
|
||||
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
|
||||
import { App } from '../dto/app.dto';
|
||||
@@ -25,6 +32,7 @@ import { ListAppsQuery } from '../dto/query.dto';
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/apps')
|
||||
@ApiTags('🧩 Apps')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class AppsController {
|
||||
constructor(
|
||||
@Inject(AppsService)
|
||||
@@ -34,6 +42,7 @@ export class AppsController {
|
||||
|
||||
@Get('/')
|
||||
@ApiOperation({ summary: 'List all apps for a session' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async list(
|
||||
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
|
||||
@@ -43,6 +52,7 @@ export class AppsController {
|
||||
|
||||
@Post('/')
|
||||
@ApiOperation({ summary: 'Create a new app' })
|
||||
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async create(@Body() app: App): Promise<App> {
|
||||
const result = await this.appsService.create(this.manager, app);
|
||||
@@ -56,14 +66,36 @@ export class AppsController {
|
||||
@Get('/:id')
|
||||
@ApiOperation({ summary: 'Get app by ID' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async get(@Param('id') id: string): Promise<App> {
|
||||
return await this.appsService.get(this.manager, id);
|
||||
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))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an existing app' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() app: App,
|
||||
@Req() req: any,
|
||||
): Promise<App> {
|
||||
const existing = await this.appsService.get(this.manager, id);
|
||||
if (existing) {
|
||||
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
} else {
|
||||
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!app.id) {
|
||||
app.id = id;
|
||||
} else if (app.id !== id) {
|
||||
@@ -83,7 +115,14 @@ export class AppsController {
|
||||
@Delete('/:id')
|
||||
@ApiOperation({ summary: 'Delete an app' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async delete(@Param('id') id: string): Promise<void> {
|
||||
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))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const app = await this.appsService.delete(this.manager, id);
|
||||
const isRunning = this.manager.isRunning(app.session);
|
||||
if (isRunning) {
|
||||
|
||||
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
|
||||
|
||||
export const JobRemoveOptions = jobRemoveOptions();
|
||||
|
||||
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
};
|
||||
function exponentialRetriesJobOptions(): DefaultJobOptions {
|
||||
return {
|
||||
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
|
||||
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
|
||||
backoff: {
|
||||
type: process.env.WAHA_APPS_JOBS_BACKOFF_TYPE || 'exponential',
|
||||
delay: parseInt(process.env.WAHA_APPS_JOBS_BACKOFF_DELAY) || 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ExponentialRetriesJobOptions = exponentialRetriesJobOptions();
|
||||
|
||||
export const NoRetriesJobOptions: DefaultJobOptions = {
|
||||
attempts: 1,
|
||||
|
||||
@@ -21,7 +21,7 @@ export class AppsDisabledService implements IAppsService {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
async get(manager: SessionManager, appId: string): Promise<App> {
|
||||
async get(manager: SessionManager, appId: string): Promise<App | null> {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,12 @@ export class AppsEnabledService implements IAppsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async get(manager: SessionManager, appId: string): Promise<App> {
|
||||
async get(manager: SessionManager, appId: string): Promise<App | null> {
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const app = await repo.getById(appId);
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App '${appId}' not found`);
|
||||
return null;
|
||||
}
|
||||
delete (app as any).pk;
|
||||
return app;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
|
||||
export interface IAppsService {
|
||||
list(manager: SessionManager, session: string): Promise<App[]>;
|
||||
|
||||
get(manager: SessionManager, appId: string): Promise<App>;
|
||||
get(manager: SessionManager, appId: string): Promise<App | null>;
|
||||
|
||||
create(manager: SessionManager, app: App): Promise<App>;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
import { CacheForConfig } from '../cache/ConversationCache';
|
||||
import { IConversationCache } from '../cache/IConversationCache';
|
||||
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||
|
||||
export interface ContactInfo {
|
||||
ChatId(): string;
|
||||
@@ -63,6 +64,8 @@ export class ContactConversationService {
|
||||
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
|
||||
);
|
||||
const attributes = await contactInfo.Attributes();
|
||||
// Keep the current chat id in sync to reply using the latest address.
|
||||
attributes[AttributeKey.WA_CHAT_ID] = chatId;
|
||||
await this.contactService.upsertCustomAttributes(
|
||||
cwContact.data,
|
||||
attributes,
|
||||
|
||||
@@ -27,15 +27,15 @@ export function GetAllChatIDs(contact: any): Array<string> {
|
||||
}
|
||||
|
||||
export function FindChatID(contact: any): string | null {
|
||||
if (GetChatID(contact)) {
|
||||
return GetChatID(contact);
|
||||
}
|
||||
if (GetJID(contact)) {
|
||||
return GetJID(contact);
|
||||
}
|
||||
if (GetLID(contact)) {
|
||||
return GetLID(contact);
|
||||
}
|
||||
if (GetChatID(contact)) {
|
||||
return GetChatID(contact);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ describe('parseMentionsFromText', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty content safely', () => {
|
||||
expect(parseMentionsFromText(null)).toEqual({
|
||||
text: null,
|
||||
mentions: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses @all and removes it from the text', () => {
|
||||
const input = 'Hello @all';
|
||||
expect(parseMentionsFromText(input)).toEqual({
|
||||
@@ -108,8 +115,16 @@ describe('parseMentionsFromText', () => {
|
||||
it('parses @lid mentions without c.us suffix', () => {
|
||||
const input = 'Notify @123123@lid now';
|
||||
expect(parseMentionsFromText(input)).toEqual({
|
||||
text: 'Notify @123123@lid now',
|
||||
text: 'Notify @123123 now',
|
||||
mentions: ['123123@lid'],
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces @lid suffix with plain ids', () => {
|
||||
const input = '@123456@lid please ping @654321@lid.';
|
||||
expect(parseMentionsFromText(input)).toEqual({
|
||||
text: '@123456 please ping @654321.',
|
||||
mentions: ['123456@lid', '654321@lid'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,15 @@ export interface ParsedMentions {
|
||||
* @param text - The message text
|
||||
* @returns Parsed mentions with cleaned text
|
||||
*/
|
||||
export function parseMentionsFromText(text: string): ParsedMentions {
|
||||
export function parseMentionsFromText(
|
||||
text: string | null | undefined,
|
||||
): ParsedMentions {
|
||||
if (!text) {
|
||||
return {
|
||||
text: text,
|
||||
mentions: null,
|
||||
};
|
||||
}
|
||||
const mentions: string[] = [];
|
||||
let content = text;
|
||||
|
||||
@@ -40,6 +48,10 @@ export function parseMentionsFromText(text: string): ParsedMentions {
|
||||
mentions.push(formattedLid);
|
||||
}
|
||||
}
|
||||
if (mentions.length > 0) {
|
||||
const lidRegexReplace = /(^|\s)@(\d{6,15})@lid\b/g;
|
||||
content = content.replace(lidRegexReplace, '$1@$2').trim();
|
||||
}
|
||||
|
||||
// Regex to match @ followed by phone number
|
||||
// Matches: @558591203123, @5585991203123, etc.
|
||||
|
||||
@@ -124,7 +124,7 @@ class WEBJSHelper implements IEngineHelper {
|
||||
return {
|
||||
timestamp: new Date(message.timestamp * 1000),
|
||||
from_me: message.fromMe,
|
||||
chat_id: message.from,
|
||||
chat_id: message.id.remote,
|
||||
message_id: message.id.id,
|
||||
participant: message.author || null,
|
||||
};
|
||||
|
||||
@@ -74,15 +74,14 @@ export class SwaggerConfiguratorCore {
|
||||
.setExternalDoc(this.title, this.externalDocUrl)
|
||||
.setVersion(VERSION.version)
|
||||
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
|
||||
.addTag('🧩 Apps', 'Applications (built-in integrations)')
|
||||
.addTag('🔑 Auth', 'Authentication')
|
||||
.addTag('📱 Pairing', 'Pair a session with WhatsApp on your phone.')
|
||||
.addTag('🆔 Profile', 'Your profile information')
|
||||
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
|
||||
.addTag('📤 Chatting', 'Chatting methods')
|
||||
.addTag('📞 Calls', 'Call handling methods')
|
||||
.addTag('✅ Presence', `Presence information`)
|
||||
.addTag('📢 Channels', 'Channels (newsletters) methods')
|
||||
.addTag('🟢 Status', 'Status (aka stories) methods')
|
||||
.addTag('💬 Chats', `Chats methods`)
|
||||
.addTag('🔑 Api Keys', 'API Keys management')
|
||||
.addTag(
|
||||
'👤 Contacts',
|
||||
`Contacts methods.<br>
|
||||
@@ -90,13 +89,14 @@ export class SwaggerConfiguratorCore {
|
||||
'E.g: \`12312312310\` OR \`12312312310@c.us\`<br>`,
|
||||
)
|
||||
.addTag('👥 Groups', `Groups methods.<br>`)
|
||||
.addTag('✅ Presence', `Presence information`)
|
||||
.addTag('📞 Calls', 'Call handling methods')
|
||||
.addTag('📅 Events', `Event Message`)
|
||||
.addTag(
|
||||
'🏷️ Labels',
|
||||
'Labels - available only for WhatsApp Business accounts',
|
||||
)
|
||||
.addTag('🖼️ Media', 'Media methods')
|
||||
.addTag('🧩 Apps', 'Applications (built-in integrations)')
|
||||
.addTag('🔍 Observability', 'Other methods')
|
||||
.addTag('🗄️ Storage', 'Storage methods')
|
||||
.addApiKey({
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { ISessionAuthRepository } from '../storage/ISessionAuthRepository';
|
||||
import { ISessionConfigRepository } from '../storage/ISessionConfigRepository';
|
||||
import { WhatsappSession } from './session.abc';
|
||||
import { IApiKeyRepository } from '@waha/core/storage/IApiKeyRepository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
@@ -48,6 +49,7 @@ export abstract class SessionManager
|
||||
public sessionConfigRepository: ISessionConfigRepository;
|
||||
protected sessionMeRepository: ISessionMeRepository;
|
||||
protected sessionWorkerRepository: ISessionWorkerRepository;
|
||||
public apiKeyRepository: IApiKeyRepository;
|
||||
private lock: any;
|
||||
|
||||
WAIT_SESSION_RUNNING_INTERVAL = 500;
|
||||
|
||||
@@ -381,6 +381,7 @@ export abstract class WhatsappSession {
|
||||
'--disable-default-apps',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-extensions',
|
||||
'--disable-metrics',
|
||||
// '--disable-features=site-per-process', // COMMENTED to test WEBJS stability
|
||||
'--disable-gpu', // COMMENTED to test WEBJS stability
|
||||
'--disable-offer-store-unmasked-wallet-cards',
|
||||
|
||||
@@ -9,15 +9,16 @@ import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { ChannelsController } from '@waha/api/channels.controller';
|
||||
import { LidsController } from '@waha/api/lids.controller';
|
||||
import { ApiKeysController } from '@waha/api/apikeys.controller';
|
||||
import { ProfileController } from '@waha/api/profile.controller';
|
||||
import { ServerController } from '@waha/api/server.controller';
|
||||
import { ServerDebugController } from '@waha/api/server.debug.controller';
|
||||
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
|
||||
import { AppsModuleExports } from '@waha/apps/apps.module';
|
||||
import { ContactsSessionController } from '@waha/contacts.session.controller';
|
||||
import { ContactsSessionController } from '@waha/api/contacts.session.controller';
|
||||
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
|
||||
import { IApiKeyAuth } from '@waha/core/auth/auth';
|
||||
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
|
||||
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
|
||||
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
|
||||
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
@@ -64,6 +65,9 @@ import { EngineConfigService } from './config/EngineConfigService';
|
||||
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
|
||||
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';
|
||||
|
||||
export const IMPORTS_CORE = [
|
||||
...AppsModuleExports.imports,
|
||||
@@ -140,6 +144,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
|
||||
|
||||
export const CONTROLLERS = [
|
||||
AuthController,
|
||||
ApiKeysController,
|
||||
SessionsController,
|
||||
ProfileController,
|
||||
ChattingController,
|
||||
@@ -178,6 +183,9 @@ export const PROVIDERS_BASE: Provider[] = [
|
||||
MediaLocalStorageConfig,
|
||||
WebSocketAuth,
|
||||
ApiKeyStrategy,
|
||||
ApiKeyService,
|
||||
CaslAbilityFactory,
|
||||
PoliciesGuard,
|
||||
{
|
||||
provide: IApiKeyAuth,
|
||||
useFactory: ApiKeyAuthFactory,
|
||||
@@ -234,11 +242,15 @@ export class AppModuleCore {
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Because we use ServeStaticModule, we need to inject a middleware
|
||||
// ServeStaticModule does not support @UseGuards
|
||||
const exclude = this.config.getExcludedPaths();
|
||||
consumer
|
||||
.apply(AuthMiddleware)
|
||||
.apply(ApiKeyAuthMiddleware)
|
||||
.exclude(...exclude)
|
||||
.forRoutes('api', 'health', 'ws');
|
||||
.forRoutes('api', 'health');
|
||||
|
||||
// Dashboard
|
||||
const dashboardCredentials = this.dashboardConfig.credentials;
|
||||
if (dashboardCredentials) {
|
||||
const username = dashboardCredentials[0];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User } from '@waha/core/auth/apiKey.strategy';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
async get(apikey: string): Promise<User | null> {
|
||||
if (!apikey) {
|
||||
return null;
|
||||
}
|
||||
const key = await this.manager.apiKeyRepository.getActiveByKey(apikey);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
isAdmin: key.isAdmin,
|
||||
session: key.session,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IncomingMessage } from 'http';
|
||||
import * as url from 'url';
|
||||
|
||||
import { IApiKeyAuth } from './auth';
|
||||
import { URL } from 'url';
|
||||
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class WebSocketAuth {
|
||||
constructor(private auth: IApiKeyAuth) {}
|
||||
constructor(private strategy: ApiKeyStrategy) {}
|
||||
|
||||
validateRequest(request: IncomingMessage) {
|
||||
if (this.auth.skipAuth()) {
|
||||
return true;
|
||||
}
|
||||
const provided = this.getKeyFromQueryParams(request);
|
||||
return this.auth.isValid(provided);
|
||||
async validateRequest(request: IncomingMessage) {
|
||||
const apikey = this.getKeyFromQueryParams('x-api-key', request);
|
||||
return await this.strategy.user(apikey);
|
||||
}
|
||||
|
||||
private getKeyFromQueryParams(request: IncomingMessage) {
|
||||
let query = url.parse(request.url, true).query;
|
||||
// case-insensitive query params
|
||||
query = Object.keys(query).reduce((acc, key) => {
|
||||
acc[key.toLowerCase()] = query[key];
|
||||
return acc;
|
||||
}, {});
|
||||
private getKeyFromQueryParams(name: string, request: IncomingMessage) {
|
||||
// Case-insensitive
|
||||
name = name.toLowerCase();
|
||||
const query = new URL(request.url || '', 'http://localhost').searchParams;
|
||||
const matches: string[] = [];
|
||||
|
||||
const provided = query['x-api-key'];
|
||||
// Check if it's array - return first
|
||||
if (Array.isArray(provided)) {
|
||||
return provided[0];
|
||||
for (const [key, value] of query.entries()) {
|
||||
if (key.toLowerCase() === name) {
|
||||
matches.push(value);
|
||||
}
|
||||
}
|
||||
return provided;
|
||||
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyAuthGuard extends AuthGuard('headerapikey') {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as passport from 'passport';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyAuthMiddleware implements NestMiddleware {
|
||||
constructor() {}
|
||||
|
||||
use(req: any, res: any, next: () => void) {
|
||||
passport.authenticate('headerapikey', { session: false }, (err, user?) => {
|
||||
if (err) {
|
||||
const exception =
|
||||
err instanceof UnauthorizedException
|
||||
? err
|
||||
: new InternalServerErrorException();
|
||||
res.status(exception.getStatus()).json(exception.getResponse());
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
const exception = new UnauthorizedException();
|
||||
res.status(exception.getStatus()).json(exception.getResponse());
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
})(req, res, next);
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,42 @@ 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';
|
||||
|
||||
export interface User {
|
||||
isAdmin: boolean;
|
||||
session?: string;
|
||||
}
|
||||
|
||||
function AdminUser(): User {
|
||||
return {
|
||||
isAdmin: true,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
|
||||
constructor(private auth: IApiKeyAuth) {
|
||||
constructor(
|
||||
private auth: IApiKeyAuth,
|
||||
private apiKeyService: ApiKeyService,
|
||||
) {
|
||||
// @ts-ignore
|
||||
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
|
||||
const isValid = this.auth.isValid(apikey);
|
||||
return done(isValid);
|
||||
return this.validate(apikey, done);
|
||||
});
|
||||
}
|
||||
|
||||
validate(apikey: string, done: (result: boolean) => void): void {
|
||||
const isValid = this.auth.isValid(apikey);
|
||||
return done(isValid);
|
||||
validate(apikey: string, done: (err?, user?: User) => void): void {
|
||||
this.user(apikey)
|
||||
.then((user) => done(null, user))
|
||||
.catch((err) => done(err, null));
|
||||
}
|
||||
|
||||
async user(apikey: string): Promise<User> {
|
||||
if (this.auth.isValid(apikey)) {
|
||||
return AdminUser();
|
||||
}
|
||||
return this.apiKeyService.get(apikey);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as passport from 'passport';
|
||||
|
||||
import { IApiKeyAuth } from './auth';
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
constructor(private auth: IApiKeyAuth) {}
|
||||
|
||||
use(req: any, res: any, next: () => void) {
|
||||
// Skip authentication if auth says so
|
||||
if (this.auth.skipAuth()) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
passport.authenticate('headerapikey', { session: false }, (value) => {
|
||||
if (!value) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
next();
|
||||
})(req, res, next);
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,12 @@ import * as crypto from 'crypto';
|
||||
|
||||
export abstract class IApiKeyAuth {
|
||||
abstract isValid(plain: string): boolean;
|
||||
|
||||
abstract skipAuth(): boolean;
|
||||
}
|
||||
|
||||
export class NoAuth implements IApiKeyAuth {
|
||||
isValid(plain: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
skipAuth(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlainApiKeyAuth implements IApiKeyAuth {
|
||||
@@ -22,10 +16,6 @@ export class PlainApiKeyAuth implements IApiKeyAuth {
|
||||
isValid(plain: string): boolean {
|
||||
return compare(plain, this.key);
|
||||
}
|
||||
|
||||
skipAuth(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class HashAuth implements IApiKeyAuth {
|
||||
@@ -41,10 +31,6 @@ export class HashAuth implements IApiKeyAuth {
|
||||
const hash = crypto.createHash(this.algorithm).update(plain).digest('hex');
|
||||
return compare(hash, this.hash);
|
||||
}
|
||||
|
||||
skipAuth(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User } from './apiKey.strategy';
|
||||
import { createMongoAbility } from '@casl/ability';
|
||||
import { AdminRules, SessionRules } from './casl.rules';
|
||||
import { Action, AppAbility, session } from './casl.types';
|
||||
|
||||
@Injectable()
|
||||
export class CaslAbilityFactory {
|
||||
createForUser(user: User): AppAbility {
|
||||
// when we disable auth using WHATSAPP_API_KEY_EXCLUDE_PATH
|
||||
// req.use will be null, so we allow any request
|
||||
if (!user) {
|
||||
return createMongoAbility(AdminRules());
|
||||
}
|
||||
// Admin user
|
||||
if (user.isAdmin) {
|
||||
return createMongoAbility(AdminRules());
|
||||
}
|
||||
if (user.session) {
|
||||
return createMongoAbility(SessionRules(user.session));
|
||||
}
|
||||
return createMongoAbility([]);
|
||||
}
|
||||
}
|
||||
|
||||
export function FilterSessions<T extends { name: string }>(
|
||||
ability: AppAbility,
|
||||
action: Action,
|
||||
sessions: T[],
|
||||
) {
|
||||
return sessions.filter((s) => ability.can(action, new session(s.name)));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { RawRuleOf } from '@casl/ability';
|
||||
import { Action, AppAbility } from './casl.types';
|
||||
|
||||
export function AdminRules(): RawRuleOf<AppAbility>[] {
|
||||
return [
|
||||
{
|
||||
action: Action.Manage,
|
||||
subject: 'all',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
|
||||
return [
|
||||
//
|
||||
// Server
|
||||
//
|
||||
{
|
||||
action: 'read',
|
||||
subject: 'server',
|
||||
},
|
||||
//
|
||||
// Session
|
||||
//
|
||||
{
|
||||
action: Action.List,
|
||||
subject: 'session',
|
||||
},
|
||||
{
|
||||
action: Action.Read,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
},
|
||||
// {
|
||||
// action: Action.Delete,
|
||||
// subject: 'session',
|
||||
// conditions: { name: name },
|
||||
// },
|
||||
{
|
||||
action: Action.Use,
|
||||
subject: 'session',
|
||||
conditions: { name: name },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { InferSubjects, MongoAbility } from '@casl/ability';
|
||||
|
||||
export class session {
|
||||
constructor(public name: string) {}
|
||||
}
|
||||
|
||||
export class server {}
|
||||
|
||||
export enum Action {
|
||||
//
|
||||
// CASL
|
||||
//
|
||||
Manage = 'manage', // it's a special action in casl, meaning "any actions"
|
||||
//
|
||||
// Session
|
||||
//
|
||||
List = 'list', // list sessions
|
||||
Read = 'read', // read 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
|
||||
}
|
||||
|
||||
type Subjects =
|
||||
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
|
||||
| 'all';
|
||||
type Actions = keyof typeof Action | Action | `${Action}`;
|
||||
export type AppAbility = MongoAbility<[Actions, Subjects]>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface SValue {
|
||||
param: string;
|
||||
@@ -142,7 +143,7 @@ export function ReportGeneratedValue() {
|
||||
'Save these values to your environment (.env or WAHA_*) to reuse them; new keys are generated on every start otherwise.',
|
||||
);
|
||||
console.warn('');
|
||||
console.warn("cat <<'EOF' > .env");
|
||||
console.warn("cat <<'EOF' >> .env");
|
||||
console.warn('');
|
||||
for (const key of values) {
|
||||
console.warn(`${key.param}=${key.value}`);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import { AppAbility } from './casl.types';
|
||||
|
||||
export const CHECK_POLICIES_KEY = 'check_policies';
|
||||
|
||||
export type PolicyHandlerCallback = (
|
||||
ability: AppAbility,
|
||||
context: ExecutionContext,
|
||||
) => boolean;
|
||||
export type PolicyHandler = PolicyHandlerCallback;
|
||||
|
||||
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
|
||||
SetMetadata(CHECK_POLICIES_KEY, handlers);
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
|
||||
import {
|
||||
CHECK_POLICIES_KEY,
|
||||
PolicyHandler,
|
||||
} from '@waha/core/auth/policies.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PoliciesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly caslAbilityFactory: CaslAbilityFactory,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const handlers =
|
||||
this.reflector.getAllAndOverride<PolicyHandler[]>(CHECK_POLICIES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) || [];
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user;
|
||||
const ability = this.caslAbilityFactory.createForUser(user);
|
||||
req.ability = ability;
|
||||
const ok = handlers.every((handler) => handler(ability, context));
|
||||
if (!ok) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BadRequestException, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
Action,
|
||||
AppAbility,
|
||||
server,
|
||||
session,
|
||||
} from '@waha/core/auth/casl.types';
|
||||
|
||||
type GetSession = (req: any) => unknown;
|
||||
|
||||
function requireSessionName(value: unknown) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new BadRequestException('Session name is required');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CanSession(action: Action, name?: GetSession) {
|
||||
return (ability: AppAbility, context: ExecutionContext) => {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
if (name) {
|
||||
const sessionName = requireSessionName(name(req));
|
||||
return ability.can(action, new session(sessionName));
|
||||
}
|
||||
return ability.can(action, 'session');
|
||||
};
|
||||
}
|
||||
|
||||
export function FromParam(key = 'session'): GetSession {
|
||||
return (req) => req.params?.[key];
|
||||
}
|
||||
|
||||
export function FromBody(key = 'session'): GetSession {
|
||||
return (req) => req.body?.[key];
|
||||
}
|
||||
|
||||
export function FromQuery(key = 'session'): GetSession {
|
||||
return (req) => req.query?.[key];
|
||||
}
|
||||
|
||||
export function CanServer(action: Action) {
|
||||
return (ability: AppAbility, context: ExecutionContext) => {
|
||||
return ability.can(action, 'server');
|
||||
};
|
||||
}
|
||||
@@ -52,9 +52,11 @@ export class GowsEventStreamObservable extends Observable<EnginePayload> {
|
||||
};
|
||||
|
||||
stream.on('data', (raw) => {
|
||||
const obj = raw.toObject();
|
||||
obj.data = JSON.parse(obj.data);
|
||||
subscriber?.next(obj);
|
||||
setImmediate(() => {
|
||||
const obj = raw.toObject();
|
||||
obj.data = JSON.parse(obj.data);
|
||||
subscriber?.next(obj);
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('end', (...args) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export class GowsSubprocess {
|
||||
|
||||
private child: any;
|
||||
private ready: boolean = false;
|
||||
private stdoutBuffer: string = '';
|
||||
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
@@ -17,7 +18,7 @@ export class GowsSubprocess {
|
||||
readonly pprof: boolean = false,
|
||||
) {}
|
||||
|
||||
start(onExit: (code: number) => void) {
|
||||
start(onExit: (code: number | null, signal: NodeJS.Signals | null) => void) {
|
||||
this.logger.info('Starting GOWS subprocess...');
|
||||
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
|
||||
|
||||
@@ -33,43 +34,51 @@ export class GowsSubprocess {
|
||||
detached: true,
|
||||
});
|
||||
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
|
||||
this.child.on('close', async (code, singal) => {
|
||||
const msg = code
|
||||
? `GOWS subprocess closed with code ${code}`
|
||||
: `GOWS subprocess closed by signal ${singal}`;
|
||||
this.child.on('close', (code, signal) => {
|
||||
const msg =
|
||||
code !== null
|
||||
? `GOWS subprocess closed with code ${code}`
|
||||
: `GOWS subprocess closed by signal ${signal}`;
|
||||
this.logger.debug(msg);
|
||||
onExit(code);
|
||||
onExit(code, signal);
|
||||
});
|
||||
this.child.on('error', (err) => {
|
||||
this.logger.error(`GOWS subprocess error: ${err}`);
|
||||
});
|
||||
|
||||
this.child.stderr.setEncoding('utf8');
|
||||
this.child.stderr.on('data', (data) => {
|
||||
this.logger.error(data);
|
||||
this.child.stderr?.setEncoding('utf8');
|
||||
this.child.stderr?.on('data', (data) => {
|
||||
this.logger.error(data.toString().trim());
|
||||
});
|
||||
|
||||
this.child.stdout.setEncoding('utf8');
|
||||
this.child.stdout.on('data', async (data) => {
|
||||
// remove empty line at the end, split by \n
|
||||
const lines = data.trim().split('\n');
|
||||
lines.forEach((line) => this.log(line));
|
||||
this.child.stdout?.setEncoding('utf8');
|
||||
this.child.stdout?.on('data', (data) => {
|
||||
this.handleStdout(data.toString());
|
||||
});
|
||||
this.listenReady();
|
||||
}
|
||||
|
||||
listenReady() {
|
||||
this.child.stdout.on('data', async (data) => {
|
||||
if (this.ready) {
|
||||
private handleStdout(chunk: string) {
|
||||
this.stdoutBuffer += chunk;
|
||||
const parts = this.stdoutBuffer.split('\n');
|
||||
this.stdoutBuffer = parts.pop() ?? '';
|
||||
parts.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (!data.includes(this.readyText)) {
|
||||
return;
|
||||
}
|
||||
await sleep(this.readyDelayMs);
|
||||
this.ready = true;
|
||||
this.logger.info('GOWS is ready');
|
||||
this.log(trimmed);
|
||||
void this.checkReady(trimmed);
|
||||
});
|
||||
void this.checkReady(this.stdoutBuffer);
|
||||
}
|
||||
|
||||
private async checkReady(text: string) {
|
||||
if (this.ready || !text.includes(this.readyText)) {
|
||||
return;
|
||||
}
|
||||
await sleep(this.readyDelayMs);
|
||||
this.ready = true;
|
||||
this.logger.info('GOWS is ready');
|
||||
}
|
||||
|
||||
async waitWhenReady(timeout: number) {
|
||||
@@ -79,7 +88,7 @@ export class GowsSubprocess {
|
||||
timeout,
|
||||
);
|
||||
if (!started) {
|
||||
const msg = 'GOWS did not start after 10 seconds';
|
||||
const msg = `GOWS did not start after ${timeout} ms`;
|
||||
this.logger.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { messages } from '@waha/core/engines/gows/grpc/gows';
|
||||
|
||||
export const GetMessageServiceClient = (
|
||||
session: string,
|
||||
export function BuildMessageServiceClient(
|
||||
address: string,
|
||||
credentials: grpc.ChannelCredentials,
|
||||
): messages.MessageServiceClient => {
|
||||
): messages.MessageServiceClient {
|
||||
return new messages.MessageServiceClient(address, credentials, {
|
||||
'grpc.max_send_message_length': 128 * 1024 * 1024,
|
||||
'grpc.max_receive_message_length': 128 * 1024 * 1024,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const GetEventStreamClient = (
|
||||
session: string,
|
||||
export function BuildEventStreamClient(
|
||||
address: string,
|
||||
credentials: grpc.ChannelCredentials,
|
||||
): messages.EventStreamClient => {
|
||||
): messages.EventStreamClient {
|
||||
return new messages.EventStreamClient(address, credentials, {
|
||||
'grpc.max_send_message_length': 128 * 1024 * 1024,
|
||||
'grpc.max_receive_message_length': 128 * 1024 * 1024,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -879,6 +879,99 @@ export namespace messages {
|
||||
return EventJson.deserialize(bytes);
|
||||
}
|
||||
}
|
||||
export class StreamEventsRequest extends pb_1.Message {
|
||||
#one_of_decls: number[][] = [];
|
||||
constructor(data?: any[] | {
|
||||
session?: Session;
|
||||
exclude?: string[];
|
||||
}) {
|
||||
super();
|
||||
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [2], this.#one_of_decls);
|
||||
if (!Array.isArray(data) && typeof data == "object") {
|
||||
if ("session" in data && data.session != undefined) {
|
||||
this.session = data.session;
|
||||
}
|
||||
if ("exclude" in data && data.exclude != undefined) {
|
||||
this.exclude = data.exclude;
|
||||
}
|
||||
}
|
||||
}
|
||||
get session() {
|
||||
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
|
||||
}
|
||||
set session(value: Session) {
|
||||
pb_1.Message.setWrapperField(this, 1, value);
|
||||
}
|
||||
get has_session() {
|
||||
return pb_1.Message.getField(this, 1) != null;
|
||||
}
|
||||
get exclude() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 2, []) as string[];
|
||||
}
|
||||
set exclude(value: string[]) {
|
||||
pb_1.Message.setField(this, 2, value);
|
||||
}
|
||||
static fromObject(data: {
|
||||
session?: ReturnType<typeof Session.prototype.toObject>;
|
||||
exclude?: string[];
|
||||
}): StreamEventsRequest {
|
||||
const message = new StreamEventsRequest({});
|
||||
if (data.session != null) {
|
||||
message.session = Session.fromObject(data.session);
|
||||
}
|
||||
if (data.exclude != null) {
|
||||
message.exclude = data.exclude;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
toObject() {
|
||||
const data: {
|
||||
session?: ReturnType<typeof Session.prototype.toObject>;
|
||||
exclude?: string[];
|
||||
} = {};
|
||||
if (this.session != null) {
|
||||
data.session = this.session.toObject();
|
||||
}
|
||||
if (this.exclude != null) {
|
||||
data.exclude = this.exclude;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
serialize(): Uint8Array;
|
||||
serialize(w: pb_1.BinaryWriter): void;
|
||||
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
|
||||
const writer = w || new pb_1.BinaryWriter();
|
||||
if (this.has_session)
|
||||
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
|
||||
if (this.exclude.length)
|
||||
writer.writeRepeatedString(2, this.exclude);
|
||||
if (!w)
|
||||
return writer.getResultBuffer();
|
||||
}
|
||||
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): StreamEventsRequest {
|
||||
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new StreamEventsRequest();
|
||||
while (reader.nextField()) {
|
||||
if (reader.isEndGroup())
|
||||
break;
|
||||
switch (reader.getFieldNumber()) {
|
||||
case 1:
|
||||
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
|
||||
break;
|
||||
case 2:
|
||||
pb_1.Message.addToRepeatedField(message, 2, reader.readString());
|
||||
break;
|
||||
default: reader.skipField();
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
serializeBinary(): Uint8Array {
|
||||
return this.serialize();
|
||||
}
|
||||
static deserializeBinary(bytes: Uint8Array): StreamEventsRequest {
|
||||
return StreamEventsRequest.deserialize(bytes);
|
||||
}
|
||||
}
|
||||
export class PairCodeRequest extends pb_1.Message {
|
||||
#one_of_decls: number[][] = [];
|
||||
constructor(data?: any[] | {
|
||||
@@ -1236,6 +1329,193 @@ export namespace messages {
|
||||
return SessionStoreConfig.deserialize(bytes);
|
||||
}
|
||||
}
|
||||
export class SessionStorageConfig extends pb_1.Message {
|
||||
#one_of_decls: number[][] = [[1], [2], [3], [4]];
|
||||
constructor(data?: any[] | ({} & (({
|
||||
messages?: boolean;
|
||||
}) | ({
|
||||
groups?: boolean;
|
||||
}) | ({
|
||||
chats?: boolean;
|
||||
}) | ({
|
||||
labels?: boolean;
|
||||
})))) {
|
||||
super();
|
||||
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
|
||||
if (!Array.isArray(data) && typeof data == "object") {
|
||||
if ("messages" in data && data.messages != undefined) {
|
||||
this.messages = data.messages;
|
||||
}
|
||||
if ("groups" in data && data.groups != undefined) {
|
||||
this.groups = data.groups;
|
||||
}
|
||||
if ("chats" in data && data.chats != undefined) {
|
||||
this.chats = data.chats;
|
||||
}
|
||||
if ("labels" in data && data.labels != undefined) {
|
||||
this.labels = data.labels;
|
||||
}
|
||||
}
|
||||
}
|
||||
get messages() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 1, false) as boolean;
|
||||
}
|
||||
set messages(value: boolean) {
|
||||
pb_1.Message.setOneofField(this, 1, this.#one_of_decls[0], value);
|
||||
}
|
||||
get has_messages() {
|
||||
return pb_1.Message.getField(this, 1) != null;
|
||||
}
|
||||
get groups() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
|
||||
}
|
||||
set groups(value: boolean) {
|
||||
pb_1.Message.setOneofField(this, 2, this.#one_of_decls[1], value);
|
||||
}
|
||||
get has_groups() {
|
||||
return pb_1.Message.getField(this, 2) != null;
|
||||
}
|
||||
get chats() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 3, false) as boolean;
|
||||
}
|
||||
set chats(value: boolean) {
|
||||
pb_1.Message.setOneofField(this, 3, this.#one_of_decls[2], value);
|
||||
}
|
||||
get has_chats() {
|
||||
return pb_1.Message.getField(this, 3) != null;
|
||||
}
|
||||
get labels() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 4, false) as boolean;
|
||||
}
|
||||
set labels(value: boolean) {
|
||||
pb_1.Message.setOneofField(this, 4, this.#one_of_decls[3], value);
|
||||
}
|
||||
get has_labels() {
|
||||
return pb_1.Message.getField(this, 4) != null;
|
||||
}
|
||||
get _messages() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "messages";
|
||||
} = {
|
||||
0: "none",
|
||||
1: "messages"
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [1])];
|
||||
}
|
||||
get _groups() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "groups";
|
||||
} = {
|
||||
0: "none",
|
||||
2: "groups"
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [2])];
|
||||
}
|
||||
get _chats() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "chats";
|
||||
} = {
|
||||
0: "none",
|
||||
3: "chats"
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [3])];
|
||||
}
|
||||
get _labels() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "labels";
|
||||
} = {
|
||||
0: "none",
|
||||
4: "labels"
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [4])];
|
||||
}
|
||||
static fromObject(data: {
|
||||
messages?: boolean;
|
||||
groups?: boolean;
|
||||
chats?: boolean;
|
||||
labels?: boolean;
|
||||
}): SessionStorageConfig {
|
||||
const message = new SessionStorageConfig({});
|
||||
if (data.messages != null) {
|
||||
message.messages = data.messages;
|
||||
}
|
||||
if (data.groups != null) {
|
||||
message.groups = data.groups;
|
||||
}
|
||||
if (data.chats != null) {
|
||||
message.chats = data.chats;
|
||||
}
|
||||
if (data.labels != null) {
|
||||
message.labels = data.labels;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
toObject() {
|
||||
const data: {
|
||||
messages?: boolean;
|
||||
groups?: boolean;
|
||||
chats?: boolean;
|
||||
labels?: boolean;
|
||||
} = {};
|
||||
if (this.messages != null) {
|
||||
data.messages = this.messages;
|
||||
}
|
||||
if (this.groups != null) {
|
||||
data.groups = this.groups;
|
||||
}
|
||||
if (this.chats != null) {
|
||||
data.chats = this.chats;
|
||||
}
|
||||
if (this.labels != null) {
|
||||
data.labels = this.labels;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
serialize(): Uint8Array;
|
||||
serialize(w: pb_1.BinaryWriter): void;
|
||||
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
|
||||
const writer = w || new pb_1.BinaryWriter();
|
||||
if (this.has_messages)
|
||||
writer.writeBool(1, this.messages);
|
||||
if (this.has_groups)
|
||||
writer.writeBool(2, this.groups);
|
||||
if (this.has_chats)
|
||||
writer.writeBool(3, this.chats);
|
||||
if (this.has_labels)
|
||||
writer.writeBool(4, this.labels);
|
||||
if (!w)
|
||||
return writer.getResultBuffer();
|
||||
}
|
||||
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): SessionStorageConfig {
|
||||
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new SessionStorageConfig();
|
||||
while (reader.nextField()) {
|
||||
if (reader.isEndGroup())
|
||||
break;
|
||||
switch (reader.getFieldNumber()) {
|
||||
case 1:
|
||||
message.messages = reader.readBool();
|
||||
break;
|
||||
case 2:
|
||||
message.groups = reader.readBool();
|
||||
break;
|
||||
case 3:
|
||||
message.chats = reader.readBool();
|
||||
break;
|
||||
case 4:
|
||||
message.labels = reader.readBool();
|
||||
break;
|
||||
default: reader.skipField();
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
serializeBinary(): Uint8Array {
|
||||
return this.serialize();
|
||||
}
|
||||
static deserializeBinary(bytes: Uint8Array): SessionStorageConfig {
|
||||
return SessionStorageConfig.deserialize(bytes);
|
||||
}
|
||||
}
|
||||
export class SessionProxyConfig extends pb_1.Message {
|
||||
#one_of_decls: number[][] = [];
|
||||
constructor(data?: any[] | {
|
||||
@@ -1440,13 +1720,15 @@ export namespace messages {
|
||||
}
|
||||
}
|
||||
export class SessionConfig extends pb_1.Message {
|
||||
#one_of_decls: number[][] = [[4]];
|
||||
#one_of_decls: number[][] = [[4], [5]];
|
||||
constructor(data?: any[] | ({
|
||||
store?: SessionStoreConfig;
|
||||
log?: SessionLogConfig;
|
||||
proxy?: SessionProxyConfig;
|
||||
} & (({
|
||||
ignore?: SessionIgnoreJidsConfig;
|
||||
}) | ({
|
||||
storage?: SessionStorageConfig;
|
||||
})))) {
|
||||
super();
|
||||
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
|
||||
@@ -1463,6 +1745,9 @@ export namespace messages {
|
||||
if ("ignore" in data && data.ignore != undefined) {
|
||||
this.ignore = data.ignore;
|
||||
}
|
||||
if ("storage" in data && data.storage != undefined) {
|
||||
this.storage = data.storage;
|
||||
}
|
||||
}
|
||||
}
|
||||
get store() {
|
||||
@@ -1501,6 +1786,15 @@ export namespace messages {
|
||||
get has_ignore() {
|
||||
return pb_1.Message.getField(this, 4) != null;
|
||||
}
|
||||
get storage() {
|
||||
return pb_1.Message.getWrapperField(this, SessionStorageConfig, 5) as SessionStorageConfig;
|
||||
}
|
||||
set storage(value: SessionStorageConfig) {
|
||||
pb_1.Message.setOneofWrapperField(this, 5, this.#one_of_decls[1], value);
|
||||
}
|
||||
get has_storage() {
|
||||
return pb_1.Message.getField(this, 5) != null;
|
||||
}
|
||||
get _ignore() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "ignore";
|
||||
@@ -1510,11 +1804,21 @@ export namespace messages {
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [4])];
|
||||
}
|
||||
get _storage() {
|
||||
const cases: {
|
||||
[index: number]: "none" | "storage";
|
||||
} = {
|
||||
0: "none",
|
||||
5: "storage"
|
||||
};
|
||||
return cases[pb_1.Message.computeOneofCase(this, [5])];
|
||||
}
|
||||
static fromObject(data: {
|
||||
store?: ReturnType<typeof SessionStoreConfig.prototype.toObject>;
|
||||
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
|
||||
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
|
||||
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
|
||||
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
|
||||
}): SessionConfig {
|
||||
const message = new SessionConfig({});
|
||||
if (data.store != null) {
|
||||
@@ -1529,6 +1833,9 @@ export namespace messages {
|
||||
if (data.ignore != null) {
|
||||
message.ignore = SessionIgnoreJidsConfig.fromObject(data.ignore);
|
||||
}
|
||||
if (data.storage != null) {
|
||||
message.storage = SessionStorageConfig.fromObject(data.storage);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
toObject() {
|
||||
@@ -1537,6 +1844,7 @@ export namespace messages {
|
||||
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
|
||||
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
|
||||
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
|
||||
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
|
||||
} = {};
|
||||
if (this.store != null) {
|
||||
data.store = this.store.toObject();
|
||||
@@ -1550,6 +1858,9 @@ export namespace messages {
|
||||
if (this.ignore != null) {
|
||||
data.ignore = this.ignore.toObject();
|
||||
}
|
||||
if (this.storage != null) {
|
||||
data.storage = this.storage.toObject();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
serialize(): Uint8Array;
|
||||
@@ -1564,6 +1875,8 @@ export namespace messages {
|
||||
writer.writeMessage(3, this.proxy, () => this.proxy.serialize(writer));
|
||||
if (this.has_ignore)
|
||||
writer.writeMessage(4, this.ignore, () => this.ignore.serialize(writer));
|
||||
if (this.has_storage)
|
||||
writer.writeMessage(5, this.storage, () => this.storage.serialize(writer));
|
||||
if (!w)
|
||||
return writer.getResultBuffer();
|
||||
}
|
||||
@@ -1585,6 +1898,9 @@ export namespace messages {
|
||||
case 4:
|
||||
reader.readMessage(message.ignore, () => message.ignore = SessionIgnoreJidsConfig.deserialize(reader));
|
||||
break;
|
||||
case 5:
|
||||
reader.readMessage(message.storage, () => message.storage = SessionStorageConfig.deserialize(reader));
|
||||
break;
|
||||
default: reader.skipField();
|
||||
}
|
||||
}
|
||||
@@ -10491,20 +10807,20 @@ export namespace messages {
|
||||
path: "/messages.EventStream/StreamEvents",
|
||||
requestStream: false,
|
||||
responseStream: true,
|
||||
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
|
||||
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
|
||||
requestSerialize: (message: StreamEventsRequest) => Buffer.from(message.serialize()),
|
||||
requestDeserialize: (bytes: Buffer) => StreamEventsRequest.deserialize(new Uint8Array(bytes)),
|
||||
responseSerialize: (message: EventJson) => Buffer.from(message.serialize()),
|
||||
responseDeserialize: (bytes: Buffer) => EventJson.deserialize(new Uint8Array(bytes))
|
||||
}
|
||||
};
|
||||
[method: string]: grpc_1.UntypedHandleCall;
|
||||
abstract StreamEvents(call: grpc_1.ServerWritableStream<Session, EventJson>): void;
|
||||
abstract StreamEvents(call: grpc_1.ServerWritableStream<StreamEventsRequest, EventJson>): void;
|
||||
}
|
||||
export class EventStreamClient extends grpc_1.makeGenericClientConstructor(UnimplementedEventStreamService.definition, "EventStream", {}) {
|
||||
constructor(address: string, credentials: grpc_1.ChannelCredentials, options?: Partial<grpc_1.ChannelOptions>) {
|
||||
super(address, credentials, options);
|
||||
}
|
||||
StreamEvents: GrpcStreamServiceInterface<Session, EventJson> = (message: Session, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
|
||||
StreamEvents: GrpcStreamServiceInterface<StreamEventsRequest, EventJson> = (message: StreamEventsRequest, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
|
||||
return super.StreamEvents(message, metadata, options);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -642,6 +642,17 @@ function deserialize_messages_StartSessionRequest(buffer_arg) {
|
||||
return gows_pb.StartSessionRequest.deserializeBinary(new Uint8Array(buffer_arg));
|
||||
}
|
||||
|
||||
function serialize_messages_StreamEventsRequest(arg) {
|
||||
if (!(arg instanceof gows_pb.StreamEventsRequest)) {
|
||||
throw new Error('Expected argument of type messages.StreamEventsRequest');
|
||||
}
|
||||
return Buffer.from(arg.serializeBinary());
|
||||
}
|
||||
|
||||
function deserialize_messages_StreamEventsRequest(buffer_arg) {
|
||||
return gows_pb.StreamEventsRequest.deserializeBinary(new Uint8Array(buffer_arg));
|
||||
}
|
||||
|
||||
function serialize_messages_SubscribePresenceRequest(arg) {
|
||||
if (!(arg instanceof gows_pb.SubscribePresenceRequest)) {
|
||||
throw new Error('Expected argument of type messages.SubscribePresenceRequest');
|
||||
@@ -695,10 +706,10 @@ var EventStreamService = exports.EventStreamService = {
|
||||
path: '/messages.EventStream/StreamEvents',
|
||||
requestStream: false,
|
||||
responseStream: true,
|
||||
requestType: gows_pb.Session,
|
||||
requestType: gows_pb.StreamEventsRequest,
|
||||
responseType: gows_pb.EventJson,
|
||||
requestSerialize: serialize_messages_Session,
|
||||
requestDeserialize: deserialize_messages_Session,
|
||||
requestSerialize: serialize_messages_StreamEventsRequest,
|
||||
requestDeserialize: deserialize_messages_StreamEventsRequest,
|
||||
responseSerialize: serialize_messages_EventJson,
|
||||
responseDeserialize: deserialize_messages_EventJson,
|
||||
},
|
||||
|
||||
@@ -108,12 +108,14 @@ goog.exportSymbol('proto.messages.SessionIgnoreJidsConfig', null, global);
|
||||
goog.exportSymbol('proto.messages.SessionLogConfig', null, global);
|
||||
goog.exportSymbol('proto.messages.SessionProxyConfig', null, global);
|
||||
goog.exportSymbol('proto.messages.SessionStateResponse', null, global);
|
||||
goog.exportSymbol('proto.messages.SessionStorageConfig', null, global);
|
||||
goog.exportSymbol('proto.messages.SessionStoreConfig', null, global);
|
||||
goog.exportSymbol('proto.messages.SetPictureRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.SetProfilePictureRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.SortBy', null, global);
|
||||
goog.exportSymbol('proto.messages.SortBy.Order', null, global);
|
||||
goog.exportSymbol('proto.messages.StartSessionRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.StreamEventsRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.SubscribePresenceRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.UpdateContactRequest', null, global);
|
||||
goog.exportSymbol('proto.messages.UpdateParticipantsRequest', null, global);
|
||||
@@ -329,6 +331,27 @@ if (goog.DEBUG && !COMPILED) {
|
||||
*/
|
||||
proto.messages.EventJson.displayName = 'proto.messages.EventJson';
|
||||
}
|
||||
/**
|
||||
* Generated by JsPbCodeGenerator.
|
||||
* @param {Array=} opt_data Optional initial data array, typically from a
|
||||
* server response, or constructed directly in Javascript. The array is used
|
||||
* in place and becomes part of the constructed object. It is not cloned.
|
||||
* If no data is provided, the constructed object will be empty, but still
|
||||
* valid.
|
||||
* @extends {jspb.Message}
|
||||
* @constructor
|
||||
*/
|
||||
proto.messages.StreamEventsRequest = function(opt_data) {
|
||||
jspb.Message.initialize(this, opt_data, 0, -1, proto.messages.StreamEventsRequest.repeatedFields_, null);
|
||||
};
|
||||
goog.inherits(proto.messages.StreamEventsRequest, jspb.Message);
|
||||
if (goog.DEBUG && !COMPILED) {
|
||||
/**
|
||||
* @public
|
||||
* @override
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.displayName = 'proto.messages.StreamEventsRequest';
|
||||
}
|
||||
/**
|
||||
* Generated by JsPbCodeGenerator.
|
||||
* @param {Array=} opt_data Optional initial data array, typically from a
|
||||
@@ -434,6 +457,27 @@ if (goog.DEBUG && !COMPILED) {
|
||||
*/
|
||||
proto.messages.SessionStoreConfig.displayName = 'proto.messages.SessionStoreConfig';
|
||||
}
|
||||
/**
|
||||
* Generated by JsPbCodeGenerator.
|
||||
* @param {Array=} opt_data Optional initial data array, typically from a
|
||||
* server response, or constructed directly in Javascript. The array is used
|
||||
* in place and becomes part of the constructed object. It is not cloned.
|
||||
* If no data is provided, the constructed object will be empty, but still
|
||||
* valid.
|
||||
* @extends {jspb.Message}
|
||||
* @constructor
|
||||
*/
|
||||
proto.messages.SessionStorageConfig = function(opt_data) {
|
||||
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
|
||||
};
|
||||
goog.inherits(proto.messages.SessionStorageConfig, jspb.Message);
|
||||
if (goog.DEBUG && !COMPILED) {
|
||||
/**
|
||||
* @public
|
||||
* @override
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.displayName = 'proto.messages.SessionStorageConfig';
|
||||
}
|
||||
/**
|
||||
* Generated by JsPbCodeGenerator.
|
||||
* @param {Array=} opt_data Optional initial data array, typically from a
|
||||
@@ -3604,6 +3648,213 @@ proto.messages.EventJson.prototype.setData = function(value) {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* List of repeated fields within this message type.
|
||||
* @private {!Array<number>}
|
||||
* @const
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.repeatedFields_ = [2];
|
||||
|
||||
|
||||
|
||||
if (jspb.Message.GENERATE_TO_OBJECT) {
|
||||
/**
|
||||
* Creates an object representation of this proto.
|
||||
* Field names that are reserved in JavaScript and will be renamed to pb_name.
|
||||
* Optional fields that are not set will be set to undefined.
|
||||
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
|
||||
* For the list of reserved names please see:
|
||||
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
|
||||
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
|
||||
* JSPB instance for transitional soy proto support:
|
||||
* http://goto/soy-param-migration
|
||||
* @return {!Object}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.toObject = function(opt_includeInstance) {
|
||||
return proto.messages.StreamEventsRequest.toObject(opt_includeInstance, this);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Static version of the {@see toObject} method.
|
||||
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
|
||||
* the JSPB instance for transitional soy proto support:
|
||||
* http://goto/soy-param-migration
|
||||
* @param {!proto.messages.StreamEventsRequest} msg The msg instance to transform.
|
||||
* @return {!Object}
|
||||
* @suppress {unusedLocalVariables} f is only used for nested messages
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
|
||||
excludeList: (f = jspb.Message.getRepeatedField(msg, 2)) == null ? undefined : f
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
obj.$jspbMessageInstance = msg;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deserializes binary data (in protobuf wire format).
|
||||
* @param {jspb.ByteSource} bytes The bytes to deserialize.
|
||||
* @return {!proto.messages.StreamEventsRequest}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.deserializeBinary = function(bytes) {
|
||||
var reader = new jspb.BinaryReader(bytes);
|
||||
var msg = new proto.messages.StreamEventsRequest;
|
||||
return proto.messages.StreamEventsRequest.deserializeBinaryFromReader(msg, reader);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Deserializes binary data (in protobuf wire format) from the
|
||||
* given reader into the given message object.
|
||||
* @param {!proto.messages.StreamEventsRequest} msg The message object to deserialize into.
|
||||
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
|
||||
* @return {!proto.messages.StreamEventsRequest}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.deserializeBinaryFromReader = function(msg, reader) {
|
||||
while (reader.nextField()) {
|
||||
if (reader.isEndGroup()) {
|
||||
break;
|
||||
}
|
||||
var field = reader.getFieldNumber();
|
||||
switch (field) {
|
||||
case 1:
|
||||
var value = new proto.messages.Session;
|
||||
reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader);
|
||||
msg.setSession(value);
|
||||
break;
|
||||
case 2:
|
||||
var value = /** @type {string} */ (reader.readString());
|
||||
msg.addExclude(value);
|
||||
break;
|
||||
default:
|
||||
reader.skipField();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Serializes the message to binary data (in protobuf wire format).
|
||||
* @return {!Uint8Array}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.serializeBinary = function() {
|
||||
var writer = new jspb.BinaryWriter();
|
||||
proto.messages.StreamEventsRequest.serializeBinaryToWriter(this, writer);
|
||||
return writer.getResultBuffer();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Serializes the given message to binary data (in protobuf wire
|
||||
* format), writing to the given BinaryWriter.
|
||||
* @param {!proto.messages.StreamEventsRequest} message
|
||||
* @param {!jspb.BinaryWriter} writer
|
||||
* @suppress {unusedLocalVariables} f is only used for nested messages
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.serializeBinaryToWriter = function(message, writer) {
|
||||
var f = undefined;
|
||||
f = message.getSession();
|
||||
if (f != null) {
|
||||
writer.writeMessage(
|
||||
1,
|
||||
f,
|
||||
proto.messages.Session.serializeBinaryToWriter
|
||||
);
|
||||
}
|
||||
f = message.getExcludeList();
|
||||
if (f.length > 0) {
|
||||
writer.writeRepeatedString(
|
||||
2,
|
||||
f
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional Session session = 1;
|
||||
* @return {?proto.messages.Session}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.getSession = function() {
|
||||
return /** @type{?proto.messages.Session} */ (
|
||||
jspb.Message.getWrapperField(this, proto.messages.Session, 1));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {?proto.messages.Session|undefined} value
|
||||
* @return {!proto.messages.StreamEventsRequest} returns this
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.setSession = function(value) {
|
||||
return jspb.Message.setWrapperField(this, 1, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the message field making it undefined.
|
||||
* @return {!proto.messages.StreamEventsRequest} returns this
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.clearSession = function() {
|
||||
return this.setSession(undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.hasSession = function() {
|
||||
return jspb.Message.getField(this, 1) != null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* repeated string exclude = 2;
|
||||
* @return {!Array<string>}
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.getExcludeList = function() {
|
||||
return /** @type {!Array<string>} */ (jspb.Message.getRepeatedField(this, 2));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {!Array<string>} value
|
||||
* @return {!proto.messages.StreamEventsRequest} returns this
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.setExcludeList = function(value) {
|
||||
return jspb.Message.setField(this, 2, value || []);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {number=} opt_index
|
||||
* @return {!proto.messages.StreamEventsRequest} returns this
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.addExclude = function(value, opt_index) {
|
||||
return jspb.Message.addToRepeatedField(this, 2, value, opt_index);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the list making it empty but non-null.
|
||||
* @return {!proto.messages.StreamEventsRequest} returns this
|
||||
*/
|
||||
proto.messages.StreamEventsRequest.prototype.clearExcludeList = function() {
|
||||
return this.setExcludeList([]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (jspb.Message.GENERATE_TO_OBJECT) {
|
||||
@@ -4308,6 +4559,298 @@ proto.messages.SessionStoreConfig.prototype.setAddress = function(value) {
|
||||
|
||||
|
||||
|
||||
if (jspb.Message.GENERATE_TO_OBJECT) {
|
||||
/**
|
||||
* Creates an object representation of this proto.
|
||||
* Field names that are reserved in JavaScript and will be renamed to pb_name.
|
||||
* Optional fields that are not set will be set to undefined.
|
||||
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
|
||||
* For the list of reserved names please see:
|
||||
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
|
||||
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
|
||||
* JSPB instance for transitional soy proto support:
|
||||
* http://goto/soy-param-migration
|
||||
* @return {!Object}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.toObject = function(opt_includeInstance) {
|
||||
return proto.messages.SessionStorageConfig.toObject(opt_includeInstance, this);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Static version of the {@see toObject} method.
|
||||
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
|
||||
* the JSPB instance for transitional soy proto support:
|
||||
* http://goto/soy-param-migration
|
||||
* @param {!proto.messages.SessionStorageConfig} msg The msg instance to transform.
|
||||
* @return {!Object}
|
||||
* @suppress {unusedLocalVariables} f is only used for nested messages
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
messages: jspb.Message.getBooleanFieldWithDefault(msg, 1, false),
|
||||
groups: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
chats: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
|
||||
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false)
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
obj.$jspbMessageInstance = msg;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deserializes binary data (in protobuf wire format).
|
||||
* @param {jspb.ByteSource} bytes The bytes to deserialize.
|
||||
* @return {!proto.messages.SessionStorageConfig}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.deserializeBinary = function(bytes) {
|
||||
var reader = new jspb.BinaryReader(bytes);
|
||||
var msg = new proto.messages.SessionStorageConfig;
|
||||
return proto.messages.SessionStorageConfig.deserializeBinaryFromReader(msg, reader);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Deserializes binary data (in protobuf wire format) from the
|
||||
* given reader into the given message object.
|
||||
* @param {!proto.messages.SessionStorageConfig} msg The message object to deserialize into.
|
||||
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
|
||||
* @return {!proto.messages.SessionStorageConfig}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.deserializeBinaryFromReader = function(msg, reader) {
|
||||
while (reader.nextField()) {
|
||||
if (reader.isEndGroup()) {
|
||||
break;
|
||||
}
|
||||
var field = reader.getFieldNumber();
|
||||
switch (field) {
|
||||
case 1:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setMessages(value);
|
||||
break;
|
||||
case 2:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setGroups(value);
|
||||
break;
|
||||
case 3:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setChats(value);
|
||||
break;
|
||||
case 4:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setLabels(value);
|
||||
break;
|
||||
default:
|
||||
reader.skipField();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Serializes the message to binary data (in protobuf wire format).
|
||||
* @return {!Uint8Array}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.serializeBinary = function() {
|
||||
var writer = new jspb.BinaryWriter();
|
||||
proto.messages.SessionStorageConfig.serializeBinaryToWriter(this, writer);
|
||||
return writer.getResultBuffer();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Serializes the given message to binary data (in protobuf wire
|
||||
* format), writing to the given BinaryWriter.
|
||||
* @param {!proto.messages.SessionStorageConfig} message
|
||||
* @param {!jspb.BinaryWriter} writer
|
||||
* @suppress {unusedLocalVariables} f is only used for nested messages
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.serializeBinaryToWriter = function(message, writer) {
|
||||
var f = undefined;
|
||||
f = /** @type {boolean} */ (jspb.Message.getField(message, 1));
|
||||
if (f != null) {
|
||||
writer.writeBool(
|
||||
1,
|
||||
f
|
||||
);
|
||||
}
|
||||
f = /** @type {boolean} */ (jspb.Message.getField(message, 2));
|
||||
if (f != null) {
|
||||
writer.writeBool(
|
||||
2,
|
||||
f
|
||||
);
|
||||
}
|
||||
f = /** @type {boolean} */ (jspb.Message.getField(message, 3));
|
||||
if (f != null) {
|
||||
writer.writeBool(
|
||||
3,
|
||||
f
|
||||
);
|
||||
}
|
||||
f = /** @type {boolean} */ (jspb.Message.getField(message, 4));
|
||||
if (f != null) {
|
||||
writer.writeBool(
|
||||
4,
|
||||
f
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool messages = 1;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.getMessages = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 1, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.setMessages = function(value) {
|
||||
return jspb.Message.setField(this, 1, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the field making it undefined.
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.clearMessages = function() {
|
||||
return jspb.Message.setField(this, 1, undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.hasMessages = function() {
|
||||
return jspb.Message.getField(this, 1) != null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool groups = 2;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.getGroups = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.setGroups = function(value) {
|
||||
return jspb.Message.setField(this, 2, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the field making it undefined.
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.clearGroups = function() {
|
||||
return jspb.Message.setField(this, 2, undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.hasGroups = function() {
|
||||
return jspb.Message.getField(this, 2) != null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool chats = 3;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.getChats = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.setChats = function(value) {
|
||||
return jspb.Message.setField(this, 3, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the field making it undefined.
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.clearChats = function() {
|
||||
return jspb.Message.setField(this, 3, undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.hasChats = function() {
|
||||
return jspb.Message.getField(this, 3) != null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool labels = 4;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.getLabels = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 4, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.setLabels = function(value) {
|
||||
return jspb.Message.setField(this, 4, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the field making it undefined.
|
||||
* @return {!proto.messages.SessionStorageConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.clearLabels = function() {
|
||||
return jspb.Message.setField(this, 4, undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionStorageConfig.prototype.hasLabels = function() {
|
||||
return jspb.Message.getField(this, 4) != null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (jspb.Message.GENERATE_TO_OBJECT) {
|
||||
/**
|
||||
* Creates an object representation of this proto.
|
||||
@@ -4690,7 +5233,8 @@ proto.messages.SessionConfig.toObject = function(includeInstance, msg) {
|
||||
store: (f = msg.getStore()) && proto.messages.SessionStoreConfig.toObject(includeInstance, f),
|
||||
log: (f = msg.getLog()) && proto.messages.SessionLogConfig.toObject(includeInstance, f),
|
||||
proxy: (f = msg.getProxy()) && proto.messages.SessionProxyConfig.toObject(includeInstance, f),
|
||||
ignore: (f = msg.getIgnore()) && proto.messages.SessionIgnoreJidsConfig.toObject(includeInstance, f)
|
||||
ignore: (f = msg.getIgnore()) && proto.messages.SessionIgnoreJidsConfig.toObject(includeInstance, f),
|
||||
storage: (f = msg.getStorage()) && proto.messages.SessionStorageConfig.toObject(includeInstance, f)
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -4747,6 +5291,11 @@ proto.messages.SessionConfig.deserializeBinaryFromReader = function(msg, reader)
|
||||
reader.readMessage(value,proto.messages.SessionIgnoreJidsConfig.deserializeBinaryFromReader);
|
||||
msg.setIgnore(value);
|
||||
break;
|
||||
case 5:
|
||||
var value = new proto.messages.SessionStorageConfig;
|
||||
reader.readMessage(value,proto.messages.SessionStorageConfig.deserializeBinaryFromReader);
|
||||
msg.setStorage(value);
|
||||
break;
|
||||
default:
|
||||
reader.skipField();
|
||||
break;
|
||||
@@ -4808,6 +5357,14 @@ proto.messages.SessionConfig.serializeBinaryToWriter = function(message, writer)
|
||||
proto.messages.SessionIgnoreJidsConfig.serializeBinaryToWriter
|
||||
);
|
||||
}
|
||||
f = message.getStorage();
|
||||
if (f != null) {
|
||||
writer.writeMessage(
|
||||
5,
|
||||
f,
|
||||
proto.messages.SessionStorageConfig.serializeBinaryToWriter
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4959,6 +5516,43 @@ proto.messages.SessionConfig.prototype.hasIgnore = function() {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional SessionStorageConfig storage = 5;
|
||||
* @return {?proto.messages.SessionStorageConfig}
|
||||
*/
|
||||
proto.messages.SessionConfig.prototype.getStorage = function() {
|
||||
return /** @type{?proto.messages.SessionStorageConfig} */ (
|
||||
jspb.Message.getWrapperField(this, proto.messages.SessionStorageConfig, 5));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {?proto.messages.SessionStorageConfig|undefined} value
|
||||
* @return {!proto.messages.SessionConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionConfig.prototype.setStorage = function(value) {
|
||||
return jspb.Message.setWrapperField(this, 5, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clears the message field making it undefined.
|
||||
* @return {!proto.messages.SessionConfig} returns this
|
||||
*/
|
||||
proto.messages.SessionConfig.prototype.clearStorage = function() {
|
||||
return this.setStorage(undefined);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether this field is set.
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.SessionConfig.prototype.hasStorage = function() {
|
||||
return jspb.Message.getField(this, 5) != null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -124,7 +124,11 @@ import {
|
||||
WAMessageReaction,
|
||||
} from '@waha/structures/responses.dto';
|
||||
import { CallData } from '@waha/structures/calls.dto';
|
||||
import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
ProxyConfig,
|
||||
SessionConfig,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
@@ -174,8 +178,8 @@ import {
|
||||
isLabelUpsertEvent,
|
||||
} from './labels.gows';
|
||||
import {
|
||||
GetEventStreamClient,
|
||||
GetMessageServiceClient,
|
||||
BuildEventStreamClient,
|
||||
BuildMessageServiceClient,
|
||||
} from '@waha/core/engines/gows/clients';
|
||||
import esm from '@waha/vendor/esm';
|
||||
import { IsEditedMessage } from '@waha/core/utils/pwa';
|
||||
@@ -188,6 +192,19 @@ import * as path from 'path';
|
||||
import MessageServiceClient = messages.MessageServiceClient;
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
function getGowsStorageConfig(
|
||||
sessionConfig?: SessionConfig,
|
||||
): messages.SessionStorageConfig {
|
||||
const storeConfig = sessionConfig?.gows?.storage;
|
||||
return new messages.SessionStorageConfig({
|
||||
// Only explicit false disables; undefined/null defaults to enabled.
|
||||
messages: storeConfig?.messages !== false,
|
||||
groups: storeConfig?.groups !== false,
|
||||
chats: storeConfig?.chats !== false,
|
||||
labels: storeConfig?.labels !== false,
|
||||
});
|
||||
}
|
||||
|
||||
enum WhatsMeowEvent {
|
||||
CONNECTED = 'gows.ConnectedEventData',
|
||||
DISCONNECTED = 'events.Disconnected',
|
||||
@@ -216,6 +233,10 @@ enum WhatsMeowEvent {
|
||||
CALL_REJECT = 'events.CallReject',
|
||||
CALL_TERMINATE = 'events.CallTerminate',
|
||||
CALL_OFFER_NOTICE = 'events.CallOfferNotice',
|
||||
// Other
|
||||
APP_STATE = 'events.AppState',
|
||||
HISTORY_SYNC = 'events.HistorySync',
|
||||
CONTACT = 'events.Contact',
|
||||
}
|
||||
|
||||
export interface GowsConfig {
|
||||
@@ -267,6 +288,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
address: auth.address(),
|
||||
dialect: auth.dialect(),
|
||||
}),
|
||||
storage: getGowsStorageConfig(this.sessionConfig),
|
||||
log: new messages.SessionLogConfig({
|
||||
level: level ?? messages.LogLevel.INFO,
|
||||
}),
|
||||
@@ -282,8 +304,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}),
|
||||
});
|
||||
|
||||
this.client = GetMessageServiceClient(
|
||||
this.name,
|
||||
this.client = BuildMessageServiceClient(
|
||||
this.engineConfig.connection,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
@@ -310,12 +331,23 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
this.stream$ = new GowsEventStreamObservable(
|
||||
this.loggerBuilder.child({ grpc: 'stream' }),
|
||||
() => {
|
||||
const client = GetEventStreamClient(
|
||||
this.name,
|
||||
const client = BuildEventStreamClient(
|
||||
this.engineConfig.connection,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
const stream = client.StreamEvents(this.session);
|
||||
// Avoid having a lot of events after pairing the device
|
||||
// https://github.com/devlikeapro/waha/issues/1826
|
||||
// TODO: we need to make it more dynamic
|
||||
const exclude = [
|
||||
WhatsMeowEvent.APP_STATE,
|
||||
WhatsMeowEvent.HISTORY_SYNC,
|
||||
WhatsMeowEvent.CONTACT,
|
||||
];
|
||||
const request = new messages.StreamEventsRequest({
|
||||
session: this.session,
|
||||
exclude: exclude,
|
||||
});
|
||||
const stream = client.StreamEvents(request);
|
||||
return { client, stream };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -99,6 +99,19 @@ export class WPage extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// removeExposedFunction
|
||||
async removeExposedFunction(name: string) {
|
||||
try {
|
||||
return await this.page.removeExposedFunction(name);
|
||||
} catch (err) {
|
||||
this.emit(PAGE_CALL_ERROR_EVENT, {
|
||||
method: 'removeExposedFunction',
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async reload(options?: any): Promise<any> {
|
||||
try {
|
||||
return await this.page.reload(options);
|
||||
|
||||
@@ -36,6 +36,10 @@ export class WebjsClientCore extends Client {
|
||||
) {
|
||||
super(options);
|
||||
// Wait until it's READY and inject more utils
|
||||
this.on(Events.AUTHENTICATED, async () => {
|
||||
await this.attachCustomEventListeners();
|
||||
await this.injectWaha();
|
||||
});
|
||||
this.on(Events.READY, async () => {
|
||||
await this.attachCustomEventListeners();
|
||||
await this.injectWaha();
|
||||
@@ -124,6 +128,7 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
|
||||
async setPushName(name: string) {
|
||||
await this.ensureWahaInjected();
|
||||
await this.pupPage.evaluate(async (pushName) => {
|
||||
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
|
||||
pushName,
|
||||
@@ -151,6 +156,7 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
|
||||
async createLabel(name: string, color: number): Promise<number> {
|
||||
await this.ensureWahaInjected();
|
||||
const labelId: number = (await this.pupPage.evaluate(
|
||||
async (name, color) => {
|
||||
// @ts-ignore
|
||||
@@ -166,6 +172,7 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
|
||||
async deleteLabel(label: Label) {
|
||||
await this.ensureWahaInjected();
|
||||
return await this.pupPage.evaluate(async (label) => {
|
||||
// @ts-ignore
|
||||
return await window.WAHA.WAWebBizLabelEditingAction.labelDeleteAction(
|
||||
@@ -177,6 +184,7 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
|
||||
async updateLabel(label: Label) {
|
||||
await this.ensureWahaInjected();
|
||||
return await this.pupPage.evaluate(async (label) => {
|
||||
// @ts-ignore
|
||||
return await window.WAHA.WAWebBizLabelEditingAction.labelEditAction(
|
||||
@@ -193,6 +201,8 @@ export class WebjsClientCore extends Client {
|
||||
return await super.getChats();
|
||||
}
|
||||
|
||||
await this.ensureWahaInjected();
|
||||
|
||||
// Get paginated chats
|
||||
pagination.limit ||= Infinity;
|
||||
pagination.offset ||= 0;
|
||||
@@ -209,6 +219,16 @@ export class WebjsClientCore extends Client {
|
||||
return chats.map((chat) => ChatFactory.create(this, chat));
|
||||
}
|
||||
|
||||
protected async ensureWahaInjected() {
|
||||
const hasWaha = await this.pupPage.evaluate(() => {
|
||||
// @ts-ignore
|
||||
return Boolean(window.WAHA && window.WAHA.getChats);
|
||||
});
|
||||
if (!hasWaha) {
|
||||
await this.injectWaha();
|
||||
}
|
||||
}
|
||||
|
||||
async sendTextStatus(status: TextStatus) {
|
||||
// Convert from hex to number
|
||||
const waColor = 'FF' + status.backgroundColor.replace('#', '');
|
||||
|
||||
@@ -44,8 +44,10 @@ export class WebhookConductor {
|
||||
for (const event of events) {
|
||||
const obs$ = session.getEventObservable(event);
|
||||
obs$.subscribe((payload) => {
|
||||
const data = populateSessionInfo(event, session)(payload);
|
||||
sender.send(data);
|
||||
setImmediate(() => {
|
||||
const data = populateSessionInfo(event, session)(payload);
|
||||
sender.send(data);
|
||||
});
|
||||
});
|
||||
this.logger.debug(`Event '${event}' is enabled for url: ${url}`);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { getProxyConfig } from './helpers.proxy';
|
||||
import { MediaManager } from './media/MediaManager';
|
||||
import { LocalSessionAuthRepository } from './storage/LocalSessionAuthRepository';
|
||||
import { LocalStoreCore } from './storage/LocalStoreCore';
|
||||
import { CoreApiKeyRepository } from './storage/CoreApiKeyRepository';
|
||||
|
||||
export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException {
|
||||
constructor(name: string) {
|
||||
@@ -137,6 +138,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.apiKeyRepository = new CoreApiKeyRepository();
|
||||
await this.engineBootstrap.bootstrap();
|
||||
this.startPredefinedSessions();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WhatsappConfigService } from '@waha/config.service';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class MediaLocalStorageConfig {
|
||||
@@ -12,7 +13,14 @@ export class MediaLocalStorageConfig {
|
||||
}
|
||||
|
||||
get filesFolder(): string {
|
||||
return this.config.get('WHATSAPP_FILES_FOLDER', '/tmp/whatsapp-files');
|
||||
const configured = this.config.get('WHATSAPP_FILES_FOLDER', '');
|
||||
if (!configured) {
|
||||
return '/tmp/whatsapp-files';
|
||||
}
|
||||
if (path.isAbsolute(configured)) {
|
||||
return configured;
|
||||
}
|
||||
return path.resolve(process.cwd(), configured);
|
||||
}
|
||||
|
||||
get filesLifetime(): number {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import { DOCS_URL } from '@waha/core/exceptions';
|
||||
import {
|
||||
ApiKey,
|
||||
IApiKeyRepository,
|
||||
} from '@waha/core/storage/IApiKeyRepository';
|
||||
|
||||
export class CoreApiKeyRepository implements IApiKeyRepository {
|
||||
async init() {
|
||||
return;
|
||||
}
|
||||
|
||||
list(): Promise<ApiKey[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getActiveByKey(key: string): Promise<ApiKey | null> {
|
||||
void key;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
getById(id: string): Promise<ApiKey | null> {
|
||||
void id;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
getByKey(key: string): Promise<ApiKey | null> {
|
||||
void key;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
async upsert(key: ApiKey): Promise<ApiKey> {
|
||||
void key;
|
||||
throw new UnprocessableEntityException(
|
||||
`API key management is not available in this edition. See ${DOCS_URL}`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
void id;
|
||||
throw new UnprocessableEntityException(
|
||||
`API key management is not available in this edition. See ${DOCS_URL}`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBySession(session: string): Promise<void> {
|
||||
void session;
|
||||
throw new UnprocessableEntityException(
|
||||
`API key management is not available in this edition. See ${DOCS_URL}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { RawRuleOf } from '@casl/ability';
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import { AppAbility } from '../auth/casl.types';
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
key: string;
|
||||
isActive: boolean;
|
||||
isAdmin: boolean;
|
||||
session: string | null;
|
||||
rules: RawRuleOf<AppAbility>[] | null;
|
||||
}
|
||||
|
||||
export interface IApiKeyRepository {
|
||||
init(): Promise<void>;
|
||||
|
||||
list(): Promise<ApiKey[]>;
|
||||
|
||||
upsert(key: ApiKey): Promise<ApiKey>;
|
||||
|
||||
getActiveByKey(key: string): Promise<ApiKey | null>;
|
||||
|
||||
getById(id: string): Promise<ApiKey | null>;
|
||||
|
||||
getByKey(key: string): Promise<ApiKey | null>;
|
||||
|
||||
deleteById(id: string): Promise<void>;
|
||||
|
||||
deleteBySession(session: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function CheckInvariant(
|
||||
apiKey: Pick<ApiKey, 'isAdmin' | 'session'>,
|
||||
): void {
|
||||
if (apiKey.isAdmin && apiKey.session) {
|
||||
throw new UnprocessableEntityException(
|
||||
'Session is not allowed for admin keys',
|
||||
);
|
||||
}
|
||||
if (!apiKey.isAdmin && !apiKey.session) {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either isAdmin must be true or session must be provided',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export abstract class ISessionConfigRepository {
|
||||
|
||||
abstract getConfig(sessionName: string): Promise<SessionConfig | null>;
|
||||
|
||||
abstract getConfigBySessions(
|
||||
sessionNames: string[],
|
||||
): Promise<Map<string, SessionConfig | null>>;
|
||||
|
||||
abstract exists(sessionName: string): Promise<boolean>;
|
||||
|
||||
abstract deleteConfig(sessionName: string): Promise<void>;
|
||||
|
||||
@@ -5,6 +5,10 @@ export abstract class ISessionMeRepository {
|
||||
|
||||
abstract getMe(sessionName: string): Promise<MeInfo | null>;
|
||||
|
||||
abstract getMeBySessions(
|
||||
sessionNames: string[],
|
||||
): Promise<Map<string, MeInfo | null>>;
|
||||
|
||||
abstract removeMe(sessionName: string): Promise<void>;
|
||||
|
||||
abstract init(): Promise<void>;
|
||||
|
||||
@@ -48,6 +48,26 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
async getConfigBySessions(
|
||||
sessionNames: string[],
|
||||
): Promise<Map<string, SessionConfig | null>> {
|
||||
const result = new Map<string, SessionConfig | null>();
|
||||
const uniqueNames = Array.from(new Set(sessionNames));
|
||||
if (uniqueNames.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const items = await Promise.all(
|
||||
uniqueNames.map(async (sessionName) => ({
|
||||
sessionName,
|
||||
config: await this.getConfig(sessionName),
|
||||
})),
|
||||
);
|
||||
for (const item of items) {
|
||||
result.set(item.sessionName, item.config ?? null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async saveConfig(sessionName: string, config: SessionConfig) {
|
||||
// Create a folder if not exist
|
||||
const folder = this.store.getSessionDirectory(sessionName);
|
||||
|
||||
@@ -52,3 +52,29 @@ export const SQLSessionWorkerMigrations: Migration[] = [
|
||||
// Worker can have multiple records
|
||||
'CREATE INDEX IF NOT EXISTS session_worker_worker_idx ON session_worker (worker)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Api Keys
|
||||
*/
|
||||
export const SQLApiKeySchema = new Schema(
|
||||
'api_key',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('key', 'TEXT'),
|
||||
new Field('isActive', 'INTEGER'),
|
||||
new Field('session', 'TEXT'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('api_key_id_index', ['id']),
|
||||
new Index('api_key_key_idx', ['key']),
|
||||
new Index('api_key_session_idx', ['session']),
|
||||
],
|
||||
);
|
||||
|
||||
export const SQLApiKeyMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS api_key (id TEXT PRIMARY KEY, "key" TEXT, "isActive" INTEGER, session TEXT, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_id_index ON api_key (id)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_key_idx ON api_key ("key")',
|
||||
'CREATE INDEX IF NOT EXISTS api_key_session_idx ON api_key (session)',
|
||||
];
|
||||
@@ -36,6 +36,22 @@ export class Sqlite3SessionMeRepository
|
||||
return data?.me;
|
||||
}
|
||||
|
||||
async getMeBySessions(
|
||||
sessionNames: string[],
|
||||
): Promise<Map<string, MeInfo | null>> {
|
||||
const result = new Map<string, MeInfo | null>();
|
||||
const uniqueNames = Array.from(new Set(sessionNames));
|
||||
if (uniqueNames.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const entities = await this.getEntitiesByIds(uniqueNames);
|
||||
for (const sessionName of uniqueNames) {
|
||||
const entity = entities.get(sessionName);
|
||||
result.set(sessionName, entity?.me ?? null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeMe(sessionName: string): Promise<void> {
|
||||
return this.deleteById(sessionName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, ValidateIf } from 'class-validator';
|
||||
import { SessionName } from '@waha/structures/sessions.dto';
|
||||
|
||||
export class ApiKeyDTO {
|
||||
@ApiProperty({ example: 'key_id_00000000000000000000000000' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA' })
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@ApiProperty({ example: 'default', required: false, nullable: true })
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export class ApiKeyRequest {
|
||||
@ApiProperty({ example: false })
|
||||
@IsBoolean()
|
||||
isAdmin: boolean = false;
|
||||
|
||||
@ApiProperty({ example: 'default', nullable: true })
|
||||
@SessionName()
|
||||
@IsOptional()
|
||||
session: string | null = null;
|
||||
|
||||
@ApiProperty({ required: true, example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive: boolean = true;
|
||||
}
|
||||
@@ -25,4 +25,15 @@ export class WAHAEnvironment {
|
||||
example: 'linux/x86',
|
||||
})
|
||||
platform: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
id: 'worker-1',
|
||||
},
|
||||
nullable: true,
|
||||
description: 'Worker metadata for the running instance.',
|
||||
})
|
||||
worker: {
|
||||
id: string | null;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
|
||||
@@ -112,6 +113,55 @@ export class NowebConfig {
|
||||
markOnline: boolean = true;
|
||||
}
|
||||
|
||||
export class GowsStorageConfig {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Store messages locally. Set to false to disable; omit or null to keep enabled.',
|
||||
required: false,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
messages?: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Store groups locally. Set to false to disable; omit or null to keep enabled.',
|
||||
required: false,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
groups?: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Store chats locally. Set to false to disable; omit or null to keep enabled.',
|
||||
required: false,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
chats?: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Store labels locally. Set to false to disable; omit or null to keep enabled.',
|
||||
required: false,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
labels?: boolean | null;
|
||||
}
|
||||
|
||||
export class GowsConfig {
|
||||
@ValidateNested()
|
||||
@Type(() => GowsStorageConfig)
|
||||
@IsOptional()
|
||||
storage?: GowsStorageConfig;
|
||||
}
|
||||
|
||||
export class WebjsConfig {
|
||||
@ApiProperty({
|
||||
description:
|
||||
@@ -240,6 +290,21 @@ export class SessionConfig {
|
||||
@IsOptional()
|
||||
noweb?: NowebConfig;
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
storage: {
|
||||
messages: true,
|
||||
groups: true,
|
||||
chats: true,
|
||||
labels: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => GowsConfig)
|
||||
@IsOptional()
|
||||
gows?: GowsConfig;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'WebJS-specific settings.',
|
||||
required: false,
|
||||
@@ -307,19 +372,25 @@ export class SessionDetailedInfo extends SessionInfo {
|
||||
const DB_NAME_LIMIT = 64;
|
||||
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
|
||||
|
||||
export function SessionName() {
|
||||
return applyDecorators(
|
||||
IsString(),
|
||||
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
|
||||
Matches(/^[a-zA-Z0-9_-]*$/, {
|
||||
message:
|
||||
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionCreateRequest {
|
||||
@ApiProperty({
|
||||
example: 'default',
|
||||
description: 'Session name (id)',
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN)
|
||||
@Matches(/^[a-zA-Z0-9_-]*$/, {
|
||||
message:
|
||||
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
|
||||
})
|
||||
@SessionName()
|
||||
name: string | undefined;
|
||||
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
/**
|
||||
@@ -7,3 +8,15 @@ import { ulid } from 'ulid';
|
||||
export function generatePrefixedId(prefix: string) {
|
||||
return `${prefix}_${ulid().toLowerCase()}`;
|
||||
}
|
||||
|
||||
const SECRET_CHARS =
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
export function generateSecret(length: number = 32) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let secret = '';
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
secret += SECRET_CHARS[bytes[i % bytes.length] % SECRET_CHARS.length];
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
+6
-1
@@ -29,6 +29,10 @@ export function getWAHAVersion(): WAHAVersion {
|
||||
return WAHAVersion.CORE;
|
||||
}
|
||||
|
||||
export function getWorker() {
|
||||
return { id: process.env.WAHA_WORKER_ID || null };
|
||||
}
|
||||
|
||||
function getBrowser() {
|
||||
return getEngineName() === WAHAEngine.WEBJS
|
||||
? getBrowserExecutablePath()
|
||||
@@ -40,11 +44,12 @@ function getPlatform() {
|
||||
}
|
||||
|
||||
export const VERSION: WAHAEnvironment = {
|
||||
version: '2026.1.2',
|
||||
version: '2026.1.5',
|
||||
engine: getEngineName(),
|
||||
tier: getWAHAVersion(),
|
||||
browser: getBrowser(),
|
||||
platform: getPlatform(),
|
||||
worker: getWorker(),
|
||||
};
|
||||
|
||||
export const IsChrome = VERSION.browser?.includes('chrome');
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
"waha": {
|
||||
"gows": {
|
||||
"repo": "devlikeapro/gows",
|
||||
"ref": "v1.0.29"
|
||||
"ref": "v1.0.32"
|
||||
},
|
||||
"dashboard": {
|
||||
"repo": "devlikeapro/dashboard",
|
||||
"ref": "6030cf5e08f2a6e6d09a7a70a1c4194baf0beb55"
|
||||
"ref": "0cf1cb04fcfc878aade2b3c4f85f25f10ed0384f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ __metadata:
|
||||
version: 8
|
||||
cacheKey: 10
|
||||
|
||||
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17":
|
||||
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-01-25":
|
||||
version: 7.0.0-rc.9
|
||||
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=3d54943ebd83c56f1b5739c4d28da3f9af01020a"
|
||||
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=7a9463729776e6fb19ccfb9ce8312515db92b94a"
|
||||
dependencies:
|
||||
"@cacheable/node-cache": "npm:^1.4.0"
|
||||
"@hapi/boom": "npm:^9.1.3"
|
||||
@@ -31,7 +31,7 @@ __metadata:
|
||||
optional: true
|
||||
link-preview-js:
|
||||
optional: true
|
||||
checksum: 10/6ec9803fea06e359f1e883c1e050b511350ec272cfb9e3fc3a14c1346ccc8bc18c4bfb3d22f90877d5aaf4f973b02cb8fda9f5a7ab801138d17e766c36b05dfc
|
||||
checksum: 10/69fce0f910203bc1bf4c1c677110e2d887fb5d00e5ea4759da8506afa36606baf70dc19e5396b24997cb204c86b48cda5d88a1e7d333282f7499b52b356c3b00
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1242,6 +1242,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@casl/ability@npm:^6.8.0":
|
||||
version: 6.8.0
|
||||
resolution: "@casl/ability@npm:6.8.0"
|
||||
dependencies:
|
||||
"@ucast/mongo2js": "npm:^1.3.0"
|
||||
checksum: 10/e5a6b9c4ea480f734cfa1c88bbd18d65eb74ab2d66ef082e50b40064583ff70fa101e70e06faf0403a824b9b96879bcf57cf7abfecf7a5a26d6d99e6f16bd056
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@colors/colors@npm:1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "@colors/colors@npm:1.5.0"
|
||||
@@ -4018,6 +4027,42 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ucast/core@npm:^1.0.0, @ucast/core@npm:^1.4.1, @ucast/core@npm:^1.6.1":
|
||||
version: 1.10.2
|
||||
resolution: "@ucast/core@npm:1.10.2"
|
||||
checksum: 10/35c91961b534df03518ade09bf920856355bfadf0a59a8606d91ac4e22a24c2bd470964b2c54764a31e8eacecd4a2768ba8426b6a96c2474c6df305ee9b3f9a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ucast/js@npm:^3.0.0":
|
||||
version: 3.0.4
|
||||
resolution: "@ucast/js@npm:3.0.4"
|
||||
dependencies:
|
||||
"@ucast/core": "npm:^1.0.0"
|
||||
checksum: 10/8a8a5ebe45b29e1885f00a05b293b47c54cbbe506080720c505425cd52bc3e311d16a49069ae8d0f797faa34d439ad066591a7abfebf56e89702c9ad7e57a96a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ucast/mongo2js@npm:^1.3.0":
|
||||
version: 1.4.0
|
||||
resolution: "@ucast/mongo2js@npm:1.4.0"
|
||||
dependencies:
|
||||
"@ucast/core": "npm:^1.6.1"
|
||||
"@ucast/js": "npm:^3.0.0"
|
||||
"@ucast/mongo": "npm:^2.4.0"
|
||||
checksum: 10/85def36f3da27469dea621dff616ce19bd15011fad56dba04ba2ece0f08b1943592e68f7e43e4a63612297cac8d4dd980937f0f16cbaffe39be7c5670f9a8943
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ucast/mongo@npm:^2.4.0":
|
||||
version: 2.4.3
|
||||
resolution: "@ucast/mongo@npm:2.4.3"
|
||||
dependencies:
|
||||
"@ucast/core": "npm:^1.4.1"
|
||||
checksum: 10/5bcac2a06df97dda33deab3cfe1d57effd4ac49f27a846de29d6e583ca04f379f63d0def4737ec2c023dd9bbfded8ef2dfa285ed1575b8a320b1c2807c2d6200
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@wasm-audio-decoders/common@npm:9.0.7":
|
||||
version: 9.0.7
|
||||
resolution: "@wasm-audio-decoders/common@npm:9.0.7"
|
||||
@@ -12548,13 +12593,14 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "waha@workspace:."
|
||||
dependencies:
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17"
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-01-25"
|
||||
"@adiwajshing/keyed-db": "npm:^0.2.4"
|
||||
"@aws-sdk/client-s3": "npm:^3.633.0"
|
||||
"@aws-sdk/s3-request-presigner": "npm:^3.633.0"
|
||||
"@bull-board/api": "npm:^6.9.1"
|
||||
"@bull-board/express": "npm:^6.9.1"
|
||||
"@bull-board/nestjs": "npm:^6.9.1"
|
||||
"@casl/ability": "npm:^6.8.0"
|
||||
"@figuro/chatwoot-sdk": "npm:^1.1.17"
|
||||
"@grpc/grpc-js": "npm:^1.14.1"
|
||||
"@grpc/proto-loader": "npm:^0.8.0"
|
||||
@@ -12756,7 +12802,7 @@ __metadata:
|
||||
|
||||
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17":
|
||||
version: 1.34.3
|
||||
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=8823818e1db005aeca3cb75cf48622493c5e4cd2"
|
||||
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=72ae41eda26b73f50232ef73f8daec5a17bf0265"
|
||||
dependencies:
|
||||
"@pedroslopez/moduleraid": "npm:^5.0.2"
|
||||
archiver: "npm:^5.3.1"
|
||||
@@ -12774,7 +12820,7 @@ __metadata:
|
||||
optional: true
|
||||
unzipper:
|
||||
optional: true
|
||||
checksum: 10/3bcbb476ccf1fc330beccb67b26720b212a37d74d166291c346430feef955a85ffb5948f3802a7f14f9f620fbd7edcd292b049b54e77c80ce9c5f7f2c2474878
|
||||
checksum: 10/87fa2abf0ac92390ed523d3e750c7cf9974b334e25302b35ed9de32d031dc23b229102db8202d0d2ab7c5aeb9a27e73d9fe2321f4947706af16283338487fec6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in new issue
Block a user