Compare commits

...
99 Commits
Author SHA1 Message Date
devlikepro 8ab642b20d fix(ChatWoot): duplicate contact created for same LID without phone number - fix #2246
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-09-01 15:36:04 +07:00
devlikeproandJulián Valdés 93fa55b7b7 fix(ChatWoot): name LID contacts by push name instead of raw @lid id - closes #1493
Co-authored-by: Julián Valdés <julianvaldes24@gmail.com>
2026-09-01 14:56:33 +07:00
devlikepro 2881458baf chore: waha-dashboard and waha-swagger modules 2026-08-31 18:12:17 +07:00
devlikepro 2595ccfe7b chore: http.paths - access log, auth, metrics 2026-08-31 17:40:01 +07:00
devlikepro b7c4eac93b feat: Prometheus support - GET /metrics - closes #2245 fix #2245 2026-08-31 17:23:56 +07:00
devlikepro 89543ab371 chore: EventWildUnmask support wildcard - group.* 2026-08-31 17:05:52 +07:00
devlikepro 86aba9ea94 chore: EventWildUnmask return unknown 2026-08-31 17:05:52 +07:00
devlikepro 6d25680d43 fix(GOWS): Messages carrying senderKeyDistributionMessage never decrypt — silent group message loss since @lid migration - fix #2242 2026-08-31 12:38:19 +07:00
devlikepro 068658df23 feat: modules docs 2026-08-31 12:36:22 +07:00
devlikepro 69306942ee feat: waha-webhook 2026-08-31 12:32:48 +07:00
devlikepro e968b49349 feat: waha-session-runtime-info 2026-08-31 12:21:17 +07:00
devlikepro 6dc19229ae feat: waha-message-source 2026-08-31 12:17:16 +07:00
devlikepro 63d56b8467 feat: waha-wid-jid + waha-wid-suffix plugins 2026-08-31 12:14:58 +07:00
devlikepro 53687647e9 feat: plugins + modules 2026-08-31 12:07:46 +07:00
devlikepro fdf0589b68 up(WEBJS): handle reply_to in channels - fix #2233 2026-08-31 11:38:22 +07:00
devlikepro f32dace3f8 up(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:22:58 +07:00
devlikepro ed056cb1f6 fix(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:20:05 +07:00
devlikepro 0e9b02d92c up(WEBJS)
- Handle reply_to in channels - fix #2233
2026-08-31 10:50:08 +07:00
devlikepro fc556dad77 up(NOWEB)
- Handle reply_to in channels - fix #2233
2026-08-31 10:49:51 +07:00
devlikepro 5780bc09f9 up(WEBJS)
- fix delete message in channels - fix #2236
2026-08-31 10:44:22 +07:00
devlikepro d79940e494 up(dashboard) 2026-08-31 10:44:22 +07:00
devlikepro 1df6ec68db feat: /api/sendSticker
Co-authored-by: nickxs1 <lucasendlichgomes@hotmail.com>

fix #1287
closes #2240
2026-08-31 10:44:22 +07:00
devlikepro a06a72901a fix: Save zip if lottie processing failed
mention #2239
2026-08-31 10:44:22 +07:00
devlikepro 7e859b4ca9 up(WPP) 2026-08-31 10:44:22 +07:00
devlikepro 9490a05586 feat: app purge API 2026-08-31 10:44:22 +07:00
devlikepro b244115600 chore: remove CoreMediaConverter 2026-08-31 10:44:22 +07:00
devlikepro 287d52fe1a feat: Plugin.attach 2026-08-31 10:44:22 +07:00
devlikepro 21efcf07b1 fix: apps openapi tags, brazilian phone numbers app cache 2026-08-31 10:44:22 +07:00
devlikepro aaa0eb12d5 fix: gows .engine 2026-08-31 10:44:22 +07:00
devlikepro 1c5ece5a86 fix: app config import 2026-08-31 10:44:22 +07:00
devlikepro 061601826e fix: circular dependencies - noweb - chatwoot 2026-08-31 10:44:22 +07:00
devlikepro af51c0ae65 feat: app.modules.ts 2026-08-31 10:44:22 +07:00
devlikepro 16b50c672b feat: unique flag for apps - one instance per session 2026-08-31 10:44:22 +07:00
devlikepro dbda9c0457 feat: session plugins registry 2026-08-31 10:44:22 +07:00
devlikeproandbergpinheiro ff30e5ca90 feat: BrazilianPhoneNumberApp + Plugin
Co-authored-by: bergpinheiro
  <24882737+bergpinheiro@users.noreply.github.com>

closes #2180
2026-08-31 10:44:22 +07:00
devlikepro 2770c649c6 feat: IsDuration nestjs field validation 2026-08-31 10:44:22 +07:00
devlikepro 61f1f71343 feat: hooks, plugins, apps
- MaintainOnlineStatusPlugin (Activity)
- MessageSourceCachePlugin
- WebhookPlugin
- Wid*Plugin
2026-08-31 10:44:22 +07:00
devlikeproandElimeshi1 99e4dfcfb5 feat: granular control for media - api, events, mimetypes
closes #2211

Co-authored-by: Elimeshi1 <eliyaume@edu.hac.ac.il>
2026-08-31 10:44:22 +07:00
devlikeproandicecubex300 cc12380e48 feat(groups): membership approval - settings, pending requests API and group.v2.participants.join-request event
Closes #2200

Co-authored-by: icecubex300 <83597812+icecubex300@users.noreply.github.com>
2026-08-31 10:44:22 +07:00
devlikepro b5e223a333 up: axios 1.19.0 2026-08-31 10:44:22 +07:00
devlikeproandAnderson Lemes f6db89c141 fix(proxy): honor HTTP(S)_PROXY when downloading media
Closes #2224

Co-authored-by: Anderson Lemes <andersonlemes@outlook.pt>
2026-08-31 10:44:22 +07:00
devlikepro d2545c2de6 feat: use traceparent from opentelemetry
fix #2176 closes #2179
2026-08-31 10:44:22 +07:00
devlikepro 12a774c864 up(GOWS)
- Fix maps mirrored fromMe messages to the wrong contact/conversation - fix #2241
- Unable to retrieve WhatsApp groups - fix #2234
2026-08-31 10:44:22 +07:00
devlikepro 186847a455 up(WPP) 2026-08-19 15:51:46 +07:00
devlikepro 8c44b70109 version: 2026.8.2 2026-08-14 19:15:57 +07:00
devlikepro 382c1a7e94 fix(WEBJS): phantom AUTHENTICATED/READY on logout and onNewMessageId binding race - fix #2222
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-08-14 18:48:18 +07:00
devlikepro d7fb3091a6 build: ignore on watch 2026-08-14 18:48:18 +07:00
devlikepro 1fbe60e646 fix(WEBJS): session stuck in STARTING 2026-08-14 18:48:18 +07:00
devlikepro 841063739e feat: GET /api/sessions/{name}/timelock - fix #2219 2026-08-14 18:48:18 +07:00
devlikepro 8d4eb02202 up(WEBJS): hide "What's new on WhatsApp Web" - fix #2217 2026-08-14 18:48:18 +07:00
Moshe Grunwald f6ed147acc fix(s3): set ContentType on uploaded media objects 2026-08-14 17:45:14 +07:00
devlikepro 96a106cff7 up(WPP): fix promote to admin types 2026-08-14 17:39:23 +07:00
devlikepro 98854e2da3 up(WPP) 2026-08-14 17:25:11 +07:00
devlikepro 48c34cbc17 up(dashboard) 2026-08-05 16:00:16 +07:00
devlikepro 926b5c1b66 fea(GOWS): disable contacts, messageSecrets - mention #2207 2026-08-05 15:31:40 +07:00
devlikepro 608bad07a9 fix(ChatWoot): populate phone number if available fix #2208 2026-08-05 15:00:43 +07:00
devlikepro 64c6a32786 fix(GOWS): populate jid if available fix #2208 2026-08-05 14:34:59 +07:00
devlikepro f33ca787a5 up(NOWEB): fix presense chat issue 2026-08-05 14:34:59 +07:00
devlikepro 78aea9aa51 fix: set participant.pn if available 2026-08-05 14:34:59 +07:00
devlikepro 5e75d47149 up(WPP) 2026-08-05 14:34:59 +07:00
devlikepro c5a53d36bb fix(NOWEB): Fix group.v2.leave - fix #2206 closes #2209 2026-08-05 14:34:59 +07:00
devlikepro 0213692705 fix(ChatWot): do not save full attachments content in a task result - fix #2201 closes #2202 2026-08-05 14:34:59 +07:00
devlikepro a112fde248 fix(WEBJS): set group description #2199 2026-08-05 14:34:59 +07:00
devlikepro c207f3c08c fix(WEBJS): kill chrome at the end 2026-08-05 14:34:59 +07:00
devlikepro 1b8027473e fix(NOWEB): stop session properly 2026-08-05 14:34:59 +07:00
devlikepro 976066b216 chore(WEBJS): Up chrome to 142.0.7444.134-1 2026-08-05 14:34:59 +07:00
devlikepro af229a1d40 fix(GOWS): SIGKILL if hanged 2026-08-05 14:34:59 +07:00
devlikepro d14126d8a6 fix: process catch on promise timeout 2026-08-05 14:34:59 +07:00
devlikepro d4ce0e6541 fix: process.once on signals 2026-08-05 14:34:59 +07:00
devlikepro 5ddbeb995e feat(WEBJS, NOWEB): message capping API and events mention #2186 2026-08-05 14:34:59 +07:00
devlikepro f26d43b299 fix(WEBJS) - send button reply fix #2183 2026-08-05 14:34:59 +07:00
Berg Pinheiro ff998579f0 feat(GOWS): expose new-chat message capping (per-cycle quota) (#2186) 2026-08-05 14:34:59 +07:00
devlikepro 3cae921fba up(WPP): up engine 2026-08-05 14:34:59 +07:00
devlikepro 4682dddb4e chore(WPP): make with ignore sharp 2026-08-05 14:34:59 +07:00
devlikepro 2e6683d26f up(WEBJS): fix add group participants 2026-08-05 14:34:59 +07:00
devlikepro e97107ba1a up(GOWS)
- Update proto
- Fix disable whole store - fix #2207
2026-08-05 14:34:59 +07:00
devlikepro 2d96a57f72 feat(NOWEB): WAHA_NOWEB_WA_VERSION=auto-web|auto-baileys - fetch the latest WhatsApp Web version on start 2026-07-29 16:36:19 +07:00
devlikepro 9adcd3b133 version: 2026.8.1 2026-07-29 15:53:48 +07:00
devlikepro 79233e09e3 fix(NOWEB): Add WAHA_NOWEB_WA_VERSION / WAHA_NOWEB_WA_VERSION_FORCE - closes #2193
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-29 15:18:03 +07:00
devlikepro a96a4b2161 up(NOWEB): fix version - fix #2191 2026-07-29 14:31:41 +07:00
devlikepro f736105873 up(dashboard): session info, copy button in Event Monitor 2026-07-22 17:42:41 +07:00
devlikepro 2c1ea2c7c1 feat: /settings/security/member-add-mode - fix #2165 2026-07-22 17:35:49 +07:00
Ali White 216e00cc25 [core] feat(NOWEB): expose group member-add-mode as a settable endpoint close #2172 2026-07-22 17:19:34 +07:00
devlikeproandBerg Pinheiro ac1a015046 [core] Fix restartStoppedSessions aborting on one stuck session, closes #2169
Co-Authored-By: Berg Pinheiro <berg.pinheiro2009@gmail.com>
2026-07-22 17:17:35 +07:00
devlikepro 8f4af6bb08 feat: expose Reachout Timelock data - WORKING status and /me info fix #2166 2026-07-22 16:56:44 +07:00
devlikepro acee1b7d79 chore: timehelper docs 2026-07-22 16:12:21 +07:00
devlikepro 99241c3089 chore: SessionName to validation 2026-07-22 16:00:28 +07:00
devlikepro 8e764372d2 fix: LongTimeout and unref() for not keeping the process alive 2026-07-22 15:53:05 +07:00
devlikepro fa21a60cf3 fix(ChatWoot): safe read, typing when sending messages - fix #2173 2026-07-22 13:20:18 +07:00
devlikepro 02fa0ea06b ci: build dev nightly 2026-07-22 13:20:18 +07:00
devlikepro 8d1ad04625 fix(NOWEB): fix empty message.edited body - fix #2168 2026-07-22 13:20:18 +07:00
devlikepro a639baad8d fix(NOWEB): up engine 2026-07-22 13:20:18 +07:00
devlikepro 2e24107018 chore: up yarn@4.17.1 2026-07-22 13:20:18 +07:00
devlikepro 750ce208b3 chore(WEBJS): Up chrome 140.0.7339.207-1
The previous version is not found
2026-07-22 13:20:18 +07:00
devlikepro 057e0a0e70 up(GOWS): fix channels link preview - mention #2163, fix unknown field "faviconMMSMetadata" - fix #2172 2026-07-22 13:20:18 +07:00
devlikepro 255f84a12d fix(NOWEB): sending preview in channels mention #2163 2026-07-17 19:00:38 +07:00
devlikepro ac6d14394a chore: publish dev on core 2026-07-17 19:00:38 +07:00
devlikepro cb77c2fc49 fix(WEBJS): up engine. Fix sending link preview to channels fix #2163 2026-07-17 19:00:38 +07:00
devlikepro 5c279c5240 [core] 2026.7.2 2026-07-17 14:55:42 +07:00
205 changed files with 11526 additions and 1807 deletions

No files matched your search

+24
View File
@@ -56,6 +56,20 @@ WAHA_LOG_LEVEL=info
# Don't print QR codes in logs
WAHA_PRINT_QR=False
# ======================
# ===== PROMETHEUS =====
# ======================
# Prometheus metrics endpoint - GET /metrics
# WAHA_PROMETHEUS_ENABLED=True
# WAHA_PROMETHEUS_PATH=/metrics
# WAHA_PROMETHEUS_METRIC_PREFIX=waha_
# WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS=0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30
# Events to count in waha_events_total ('*' for all) - the server actively processes listed events even with no webhooks
# WAHA_PROMETHEUS_TRACK_EVENTS=message.any
# Optional basic auth for the metrics endpoint (both must be set)
# WAHA_PROMETHEUS_USERNAME=admin
# WAHA_PROMETHEUS_PASSWORD=secret
# =========================
# ===== MEDIA STORAGE =====
# =========================
@@ -64,7 +78,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=0
WHATSAPP_FILES_FOLDER=/app/.media
#
# Media download settings
#
# Events (webhooks, websockets)
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
# WAHA_API_DOWNLOAD_MEDIA=true
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
# WHATSAPP_DOWNLOAD_MEDIA=true
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
+1
View File
@@ -0,0 +1 @@
SHARP_IGNORE_GLOBAL_LIBVIPS=1
+2 -1
View File
@@ -35,7 +35,7 @@ jobs:
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
if: github.repository == 'devlikeapro/waha'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
@@ -73,6 +73,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: dev
fetch-depth: 0
- name: Check recent changes
+7
View File
@@ -1,9 +1,16 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
supportedArchitectures:
cpu:
- arm
+2 -2
View File
@@ -16,10 +16,10 @@ RUN apt-get update && \
WORKDIR /git
COPY package.json .
COPY yarn.lock .
COPY .yarnrc.yml .
ENV YARN_CHECKSUM_BEHAVIOR=update
RUN npm install -g corepack && corepack enable
RUN yarn set version 4.9.2
RUN yarn install
# App
@@ -179,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Install Chrome
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="140.0.7339.80-1"
ARG CHROME_VERSION="142.0.7444.134-1"
ARG OPUSTAGS_VERSION="1.10.1"
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
+2 -2
View File
@@ -44,8 +44,8 @@ up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
up-wpp:
yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
+10
View File
@@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=1800
WHATSAPP_FILES_FOLDER=/app/.media
#
# Media download settings
#
# Events (webhooks, websockets)
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
# WAHA_API_DOWNLOAD_MEDIA=true
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
# WHATSAPP_DOWNLOAD_MEDIA=true
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
-1
View File
@@ -3,7 +3,6 @@
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
"assets": [
{ "include": "dashboard/**", "watchAssets": false },
{ "include": "core/engines/webjs/*", "watchAssets": true },
+10 -3
View File
@@ -54,6 +54,12 @@
"@nestjs/swagger": "^7.1.11",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation-http": "^0.221.0",
"@opentelemetry/instrumentation-pino": "^0.67.0",
"@opentelemetry/sdk-node": "^0.221.0",
"@opentelemetry/sdk-trace": "^2.10.0",
"@prometheus-io/client": "^0.16.1",
"@types/better-sqlite3": "^7.6.10",
"@types/lodash": "^4.14.194",
"@types/mustache": "^4.2.5",
@@ -66,7 +72,7 @@
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
"audio-decode": "^2.2.2",
"axios": "^1.9.0",
"axios": "^1.19.0",
"axios-retry": "^4.5.0",
"better-sqlite3": "^12.4.1",
"bullmq": "^5.48.1",
@@ -115,6 +121,7 @@
"shell-quote": "^1.8.3",
"sqlite3": "^5.1.7",
"swagger-ui-express": "^4.1.4",
"tapable": "^2.2.2",
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
@@ -133,7 +140,7 @@
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"whatwg-url": "13.0.0",
"axios": "^1.9.0",
"axios": "^1.19.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
},
"devDependencies": {
@@ -205,5 +212,5 @@
}
]
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.17.1"
}
+42 -1
View File
@@ -9,7 +9,7 @@ import {
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
GetChatMessagesFilter,
@@ -37,6 +37,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextQuery,
MessageTextRequest,
MessageVideoRequest,
@@ -145,6 +146,46 @@ export class ChattingController {
return whatsapp.sendVideo(request);
}
@Post('/sendSticker')
@ApiOperation({
summary: 'Send a sticker',
description:
'The image will not be converted. It must already be in WebP format. PNG and JPEG are not supported.',
})
@ApiBody({
type: MessageStickerRequest,
examples: {
url: {
summary: 'From URL',
value: {
session: 'default',
chatId: '11111111111@c.us',
reply_to: null,
file: {
url: 'https://www.gstatic.com/webp/gallery/1.webp',
},
},
},
base64webp: {
summary: 'From base64 WebP',
value: {
session: 'default',
chatId: '11111111111@c.us',
reply_to: null,
file: {
mimetype: 'image/webp',
data: 'your-base64-data-of-webp',
},
},
},
},
})
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendSticker(@Body() request: MessageStickerRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendSticker(request);
}
@Post('/send/link-custom-preview')
@ApiOperation({
summary: 'Send a text message with a CUSTOM link preview.',
+119
View File
@@ -32,12 +32,16 @@ import {
CreateGroupRequest,
DescriptionRequest,
GroupField,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -309,6 +313,121 @@ export class GroupsController {
return session.getMessagesAdminsOnly(id);
}
@Put(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - who can add new members',
description:
'Updates the group settings for who can add new members to the group - all members or admins only.',
})
setMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberAddMode,
) {
return session.setMemberAddMode(id, request.membersCanAddNewMember);
}
@Get(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - who can add new members',
description:
'The group settings for who can add new members to the group - all members or admins only.',
})
getMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberAddMode> {
return session.getMemberAddMode(id);
}
@Put(':id/settings/security/membership-approval')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - approve new members',
description:
'Enables or disables admin approval for users requesting to join the group.',
})
setMembershipApprovalMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMembershipApproval,
): Promise<boolean> {
return session.setMembershipApprovalMode(
id,
request.newMembersApprovalRequired,
);
}
@Get(':id/settings/security/membership-approval')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - approve new members',
description:
'Returns whether admin approval is required for users requesting to join the group.',
})
getMembershipApprovalMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMembershipApproval> {
return session.getMembershipApprovalMode(id);
}
@Get(':id/participants/join-requests')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get pending requests to join the group',
})
getGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<GroupJoinRequest[]> {
return session.getGroupJoinRequests(id);
}
@Post(':id/participants/join-requests/approve')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Approve pending requests to join the group',
})
approveGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return session.approveGroupJoinRequests(id, request);
}
@Post(':id/participants/join-requests/reject')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Reject pending requests to join the group',
})
rejectGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return session.rejectGroupJoinRequests(id, request);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
+42 -2
View File
@@ -34,11 +34,14 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
MessageCappingData,
ReachoutTimelockData,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionLogoutRequest,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
@@ -98,6 +101,39 @@ class SessionsController {
return this.sessionService.getSessionMe(session);
}
@Get(':session/capping')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account new-chat message capping (per-cycle quota)',
description:
'Fetch a fresh new-chat message capping (quota) state from WhatsApp. ' +
'The same value is also available under me.messageCapping in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchMessageCapping(
@SessionParam session: WhatsappSession,
): Promise<MessageCappingData> {
return session.fetchMessageCapping();
}
@Get(':session/timelock')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account reachout timelock state',
description:
'Fetch a fresh reachout timelock state from WhatsApp - the restriction ' +
'behind "server returned error 463" when messaging new contacts. ' +
'The same value is also available under me.reachoutTimelock in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchReachoutTimelock(
@SessionParam session: WhatsappSession,
): Promise<ReachoutTimelockData> {
return session.fetchReachoutTimelock();
}
@Post('')
@ApiOperation({
summary: 'Create a session',
@@ -168,10 +204,14 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@ApiBody({ type: SessionLogoutRequest, required: false })
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
return this.sessionService.logoutSession(name);
async logout(
@Param('session') name: string,
@Body() request?: SessionLogoutRequest,
): Promise<SessionDTO> {
return this.sessionService.logoutSession(name, request);
}
@Post(':session/restart')
+1 -1
View File
@@ -1,4 +1,4 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
export const SessionExamples = {
basic: {
+8 -3
View File
@@ -97,7 +97,7 @@ export class WebsocketGatewayCore
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
const events = params.events as WAHAEvents[];
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
);
@@ -131,8 +131,13 @@ export class WebsocketGatewayCore
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 };
const result = this.eventUnmask.unmask(eventsList);
if (result.unknown.length > 0) {
this.logger.warn(
`Ignoring unknown websocket events: ${result.unknown.join(', ')}`,
);
}
return { session, events: result.events };
}
handleDisconnect(socket: WebSocket): any {
+49 -5
View File
@@ -14,7 +14,9 @@ import {
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import {
AppsService,
IAppsService,
@@ -26,12 +28,13 @@ import {
CanServer,
CanSession,
FromBody,
FromParam,
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 { APPS } from '../apps/definition';
import { GetApp } from '../apps/registry';
import { App } from '../dto/app.dto';
import { ListAppsQuery } from '../dto/query.dto';
@@ -44,6 +47,7 @@ export class AppsController {
@Inject(AppsService)
private appsService: IAppsService,
private manager: SessionManager,
private resolver: UniqueAppResolver,
) {}
@Get('/')
@@ -63,7 +67,11 @@ export class AppsController {
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
if (
isRunning &&
app.enabled &&
GetApp(app.app)?.definition.restartOnChange
) {
await this.manager.restart(app.session);
}
return result;
@@ -114,7 +122,7 @@ export class AppsController {
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(result.session);
if (isRunning && APPS[result.app].restartOnChange) {
if (isRunning && GetApp(result.app)?.definition.restartOnChange) {
await this.manager.restart(result.session);
}
return result;
@@ -134,8 +142,44 @@ export class AppsController {
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && APPS[app.app].restartOnChange) {
if (isRunning && GetApp(app.app)?.definition.restartOnChange) {
await this.manager.restart(app.session);
}
}
@Post('/:id/purge')
@ApiOperation({
summary: 'Purge app storage by app ID',
description:
"Delete the app's stored data (database rows, caches) while keeping the app configured.",
})
@CheckPolicies(CanServer(Action.Retrieve))
@UsePipes(new WAHAValidationPipe())
async purge(@Param('id') id: string, @Req() req: any): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.App, new SessionName(existing.session))) {
throw new ForbiddenException();
}
return await this.appsService.purge(this.manager, id);
}
@Post('/:app/:session/purge')
@ApiOperation({
summary: 'Purge app storage by app name and session',
description:
"Delete the unique app's stored data for the session. The app must be enabled.",
})
@ApiParam({ name: 'app', enum: AppName })
@CheckPolicies(CanSession(Action.App, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async purgeUniqueApp(
@Param('app') appName: string,
@Param('session') session: string,
): Promise<App> {
const app = await this.resolver.getEnabledApp(session, appName);
return await this.appsService.purge(this.manager, app.id);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
export interface ApiTag {
name: string;
description: string;
}
/**
* OpenAPI tags for all registered apps - one "🧩 Apps: {Title}" tag per app,
* so app-specific endpoints get their own section in Swagger.
* Includes disabled apps too (only their openapi metadata is used) - some app
* controllers are served even when apps are disabled ('disabledControllers');
* their description is prefixed with "DISABLED" to make the state visible.
*/
export function GetAppsApiTags(): ApiTag[] {
return GetApps().map((app) => {
const enabled = AppRuntimeConfig.HasApp(app.name);
const description = enabled
? app.openapi.description
: `DISABLED - ${app.openapi.description}`;
return {
name: AppApiTag(app.openapi),
description: description,
};
});
}
+8 -8
View File
@@ -1,15 +1,15 @@
import { AppEnv } from '@waha/apps/app_sdk/env';
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
class AppRuntimeConfigC {
private constructor(private apps: AppDefinition[] | null) {}
private constructor(private apps: AppModule[] | null) {}
static FromEnv(env: typeof AppEnv) {
if (!env.enabled) {
return new AppRuntimeConfigC(null);
}
let apps = Object.values(APPS);
let apps: AppModule[] = GetApps();
// Include
if (env.on && env.on.length > 0) {
apps = apps.filter((app) => env.on!.includes(app.name));
@@ -30,18 +30,18 @@ class AppRuntimeConfigC {
}
GetAppsWithMigration() {
return this.GetApps().filter((app) => app.migrations);
return this.GetApps().filter((app) => app.definition.migrations);
}
GetAppsRequiringPlainKey() {
return this.GetApps().filter((app) => app.plainkey);
return this.GetApps().filter((app) => app.definition.plainkey);
}
GetAppsRequiringQueue() {
return this.GetApps().filter((app) => app.queue);
return this.GetApps().filter((app) => app.definition.queue);
}
HasApp(name: AppName) {
HasApp(name: string) {
return this.GetApps().some((app) => app.name === name);
}
+28
View File
@@ -0,0 +1,28 @@
import { Type } from '@nestjs/common';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
brazilianPhoneNumbers = 'brazilian-phone-numbers',
}
/**
* DTO classes used to transform and validate App.config, by app name.
* Kept separate from the registry so DTOs (imported by core structures) can resolve config classes
* without pulling in every app module (controllers, services, queues) - that creates require cycles.
*/
export const AppConfigClasses: Record<AppName, Type<any>> = {
[AppName.brazilianPhoneNumbers]: BrazilianPhoneNumbersAppConfig,
[AppName.calls]: CallsAppConfig,
[AppName.chatwoot]: ChatWootAppConfig,
[AppName.mcp]: McpAppConfig,
};
export function GetAppConfigClass(name: AppName): Type<any> {
return AppConfigClasses[name] ?? Object;
}
+51 -27
View File
@@ -1,8 +1,10 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { Type } from '@nestjs/common';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { AppOpenAPI } from '@waha/apps/app_sdk/apps/openapi';
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
export interface AppDefinition {
// App name
name: AppName;
// If app requires WAHA_API_KEY_PLAIN to work
plainkey: boolean;
// If app requires queue to work
@@ -11,29 +13,51 @@ export interface AppDefinition {
migrations: boolean;
// If adding, updating, or removing this app requires a session restart
restartOnChange: boolean;
// If only one instance of this app is allowed per session
unique: boolean;
}
// All Apps
export const APPS: Record<AppName, AppDefinition> = {
[AppName.calls]: {
name: AppName.calls,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
},
[AppName.mcp]: {
name: AppName.mcp,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
},
};
// NestJS module parts, included when the app is enabled in runtime configuration
export interface AppNestJS {
imports: any[];
controllers: any[];
providers: any[];
}
/**
* Contract for the default export of 'src/apps/<name>/app.module.ts'.
* Each app self-describes with this and gets listed in the registry ('apps/registry.ts').
*/
export interface AppModule {
// App name
name: AppName;
// OpenAPI metadata (tag title, description) from 'src/apps/<name>/openapi.ts'
openapi: AppOpenAPI;
// Static app metadata (requirements, behavior flags)
definition: AppDefinition;
// NestJS module parts
nestjs: AppNestJS;
// Service implementing app lifecycle hooks; must also be listed in 'nestjs.providers'
Service: Type<IAppService>;
}
export function isUniqueApp(name: AppName): boolean {
return GetApp(name)?.definition.unique === true;
}
// Returns the first unique AppName that appears more than once in the list, or null
export function findDuplicateUniqueApp(
apps: Array<{ app: AppName }>,
): AppName | null {
const seen = new Set<AppName>();
for (const app of apps) {
if (!isUniqueApp(app.app)) {
continue;
}
if (seen.has(app.app)) {
return app.app;
}
seen.add(app.app);
}
return null;
}
-5
View File
@@ -1,5 +0,0 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
}
+14
View File
@@ -0,0 +1,14 @@
/**
* OpenAPI metadata for an app, defined inline in the app's AppModule ('openapi' field).
* The registry applies the "🧩 Apps: {Title}" tag to every controller in 'nestjs.controllers',
* so app controllers do not declare @ApiTags themselves - a new app only touches its own folder.
*/
export interface AppOpenAPI {
title: string;
description: string;
}
// OpenAPI tag for app-specific endpoints - "🧩 Apps: {Title}"
export function AppApiTag(openapi: AppOpenAPI): string {
return `🧩 Apps: ${openapi.title}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { ApiTags } from '@nestjs/swagger';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
import CallsAppModule from '@waha/apps/calls/app.module';
import ChatWootAppModule from '@waha/apps/chatwoot/app.module';
import McpAppModule from '@waha/apps/mcp/app.module';
/**
* Registry of available apps.
* Add new apps here - the rest of app_sdk works off this list.
*/
const APPS: AppModule[] = [
BrazilianPhoneNumbersAppModule,
CallsAppModule,
ChatWootAppModule,
McpAppModule,
];
// Tag every app controller with the app's OpenAPI tag ("🧩 Apps: {Title}") from AppModule.openapi,
// so controllers do not declare @ApiTags themselves (they cannot import their own app.module - require cycle).
for (const app of APPS) {
for (const controller of app.nestjs.controllers) {
ApiTags(AppApiTag(app.openapi))(controller);
}
}
export function GetApps(): AppModule[] {
return APPS;
}
export function GetApp(name: string): AppModule | undefined {
return APPS.find((app) => app.name === name);
}
+12 -39
View File
@@ -1,6 +1,3 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -10,15 +7,13 @@ import {
ValidateNested,
} from 'class-validator';
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import {
AppConfigClasses,
AppName,
GetAppConfigClass,
} from '@waha/apps/app_sdk/apps/apps';
export type AllowedAppConfig =
| ChatWootAppConfig
| CallsAppConfig
| McpAppConfig;
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
export class App<T extends AllowedAppConfig = any> {
export class App<T = any> {
@IsString()
id: string;
@@ -41,36 +36,14 @@ export class App<T extends AllowedAppConfig = any> {
@ValidateNested()
@Type((options) => {
if (options && options.object && options.object.app) {
switch (options.object.app) {
case AppName.chatwoot:
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
case AppName.mcp:
return McpAppConfig;
default:
return Object;
}
const name = options?.object?.app;
if (!name) {
return Object;
}
return Object;
return GetAppConfigClass(name);
})
config: T;
}
export class ChatWootAppDto extends App<ChatWootAppConfig> {
@Type(() => ChatWootAppConfig)
config: ChatWootAppConfig;
}
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
// Swagger models for app configs
ApiExtraModels(...Object.values(AppConfigClasses))(App);
+3 -3
View File
@@ -1,9 +1,9 @@
import { parseBool } from '@waha/helpers';
import { APPS } from '@waha/apps/app_sdk/apps/definition';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
// Apps that don't require queue (Redis) - derived from AppDefinition
const IN_MEMORY_APPS = Object.values(APPS)
.filter((app) => !app.queue)
const IN_MEMORY_APPS = GetApps()
.filter((app) => !app.definition.queue)
.map((app) => app.name);
function parseCommaSeparatedList(value: string | undefined): string[] {
@@ -41,6 +41,14 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async purge(manager: SessionManager, appId: string): Promise<App> {
throw new AppsIsDisabledError();
}
async purgeBySession(manager: SessionManager, session: string) {
return;
}
async removeBySession(manager: SessionManager, session: string) {
return;
}
+77 -41
View File
@@ -1,15 +1,12 @@
import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { migrate } from '@waha/apps/app_sdk/migrations';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -19,8 +16,12 @@ import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { App } from '../dto/app.dto';
import { AppRepository } from '../storage/AppRepository';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import {
findDuplicateUniqueApp,
isUniqueApp,
} from '@waha/apps/app_sdk/apps/definition';
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
export class AppDisableError extends UnprocessableEntityException {
constructor(app: string) {
@@ -30,14 +31,21 @@ export class AppDisableError extends UnprocessableEntityException {
}
}
export class AppUniquePerSessionError extends UnprocessableEntityException {
constructor(app: string, session: string, existingAppId: string) {
super(
`Only one '${app}' app is allowed per session. ` +
`Session '${session}' already has a '${app}' app with ID '${existingAppId}'.`,
);
}
}
@Injectable()
export class AppsEnabledService implements IAppsService {
constructor(
@InjectPinoLogger('AppsService')
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
@Optional() protected readonly mcpAppService: McpAppService,
private readonly moduleRef: ModuleRef,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
@@ -64,31 +72,17 @@ export class AppsEnabledService implements IAppsService {
throw new Error(`App with ID '${app.id}' already exists.`);
}
let existingApps: App[] = [];
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
existingApps = await repo.getAllBySession(app.session);
}
// Validate only one Chatwoot app per session
if (app.app === AppName.chatwoot) {
const existingChatwootApp = existingApps.find(
(existingApp) => existingApp.app === AppName.chatwoot,
// Validate only one instance of a unique app per session
if (isUniqueApp(app.app)) {
const existingApps = await repo.getAllBySession(app.session);
const duplicateApp = existingApps.find(
(existingApp) => existingApp.app === app.app,
);
if (existingChatwootApp) {
throw new Error(
`Only one Chatwoot app is allowed per session. Session '${app.session}' already has a Chatwoot app with ID '${existingChatwootApp.id}'.`,
);
}
}
// Validate only one Calls app per session
if (app.app === AppName.calls) {
const existingCallsApp = existingApps.find(
(existingApp) => existingApp.app === AppName.calls,
);
if (existingCallsApp) {
throw new Error(
`Only one Calls app is allowed per session. Session '${app.session}' already has a Calls app with ID '${existingCallsApp.id}'.`,
if (duplicateApp) {
throw new AppUniquePerSessionError(
app.app,
app.session,
duplicateApp.id,
);
}
}
@@ -192,6 +186,35 @@ export class AppsEnabledService implements IAppsService {
return app;
}
async purge(manager: SessionManager, appId: string): Promise<App> {
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`);
}
const service = this.getAppService(app);
if (!service) {
throw new AppDisableError(app.app);
}
await service.purge(manager, app);
delete app.pk;
return app;
}
async purgeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
for (const app of apps) {
const service = this.getAppService(app);
if (!service) {
continue;
}
await service.purge(manager, app);
}
}
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
@@ -212,6 +235,10 @@ export class AppsEnabledService implements IAppsService {
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
const plugins = service.plugins(app, session, store);
for (const options of plugins) {
session.plugins.add(options, app.id);
}
service.beforeSessionStart(app, session);
}
}
@@ -234,6 +261,15 @@ export class AppsEnabledService implements IAppsService {
session: string,
apps: App[],
): Promise<void> {
// Reject duplicate unique apps in the payload before any writes,
// otherwise the by-type matching below binds them to the same app
const duplicateUniqueApp = findDuplicateUniqueApp(apps);
if (duplicateUniqueApp !== null) {
throw new UnprocessableEntityException(
`Only one '${duplicateUniqueApp}' app is allowed per session - remove duplicate entries from 'apps'.`,
);
}
const existing = await this.list(manager, session);
const ids = new Set<string>();
@@ -266,15 +302,15 @@ export class AppsEnabledService implements IAppsService {
}
private getAppService(app: App): IAppService | null {
switch (app.app) {
case AppName.chatwoot:
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
case AppName.mcp:
return this.mcpAppService;
default:
throw new Error(`App '${app.app}' not supported`);
const appModule = GetApp(app.app);
if (!appModule) {
throw new Error(`App '${app.app}' not supported`);
}
try {
return this.moduleRef.get(appModule.Service, { strict: false });
} catch {
// Provider is not registered - app is disabled via WAHA_APPS_ON / WAHA_APPS_OFF
return null;
}
}
+19
View File
@@ -1,6 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { PluginOptions } from '@waha/core/abc/session.plugin';
/**
* Exact App service
@@ -42,6 +44,12 @@ export interface IAppService {
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Deletes the app's stored data (database rows, caches) while keeping the app configured.
* Apps without storage implement it as a no-op.
*/
purge(manager: SessionManager, app: App): Promise<void>;
/**
* Called for each app before a bulk session deletion removes all app records.
* Use this to clean up external resources tied to the app (e.g. API keys).
@@ -54,6 +62,17 @@ export interface IAppService {
*/
enrich(manager: SessionManager, app: App): Promise<void>;
/**
* Session plugins contributed by this app. AppsEnabledService registers them with the app id,
* and the session manager attaches their hooks and events before session.start().
* store - the server data store, for apps whose plugins persist data.
*/
plugins(
app: App,
session: WhatsappSession,
store?: DataStore,
): PluginOptions[];
beforeSessionStart(app: App, session: WhatsappSession): void;
afterSessionStart(app: App, session: WhatsappSession): void;
@@ -17,6 +17,10 @@ export interface IAppsService {
delete(manager: SessionManager, appId: string): Promise<App>;
purge(manager: SessionManager, appId: string): Promise<App>;
purgeBySession(manager: SessionManager, session: string): Promise<void>;
removeBySession(manager: SessionManager, session: string): Promise<void>;
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
@@ -0,0 +1,55 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
/**
* Resolves the single instance of a unique-per-session app ('definition.unique') by session name,
* so app controllers can expose session-keyed routes ('api/apps/{app}/{session}/...') instead of app-id ones.
* Uniqueness is guaranteed at write time by AppsEnabledService, so the first enabled row is the instance.
* Must not import 'apps/registry.ts' or 'apps/definition.ts' - controllers import this resolver,
* and the registry imports app modules (with their controllers), which would create a require cycle.
*/
@Injectable()
export class UniqueAppResolver {
constructor(private readonly manager: SessionManager) {}
/**
* Enabled app row for the session; 404 if the app is missing or disabled.
* Does not require the session to be running - persistent app data stays accessible for stopped sessions.
*/
async getEnabledApp(sessionName: string, appName: string): Promise<AppDB> {
const knex = this.manager.store.getWAHADatabase();
const repository = new AppRepository(knex);
const app = await repository.getEnabledBySessionAndApp(
sessionName,
appName,
);
if (!app) {
throw new NotFoundException(
`App '${appName}' is not enabled for session '${sessionName}'`,
);
}
return app;
}
/**
* Live plugin instance for the app, or null when the session is not running (best-effort access).
*/
getPlugin<Plugin extends SessionPlugin<any, any>>(
app: AppDB,
PluginClass: abstract new (...args: any[]) => Plugin,
): Plugin | null {
if (!this.manager.isRunning(app.session)) {
return null;
}
let session;
try {
session = this.manager.getSession(app.session);
} catch {
return null;
}
return session.plugins.get(PluginClass, app.id);
}
}
+19
View File
@@ -83,6 +83,25 @@ export class AppRepository {
return this.deserialize(app);
}
/**
* Gets the enabled app of the given type for a session.
* Intended for unique-per-session apps - returns the first match.
*/
async getEnabledBySessionAndApp(
session: string,
appName: string,
): Promise<AppDB | null> {
const app = await this.knex(this.tableName)
.where('session', session)
.andWhere('app', appName)
.andWhere('enabled', true)
.first();
if (!app) {
return null;
}
return this.deserialize(app);
}
/**
* Gets all apps
*/
+2
View File
@@ -1,5 +1,6 @@
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
@@ -9,6 +10,7 @@ export const AppsDisabled = {
provide: AppsService,
useClass: AppsDisabledService,
},
UniqueAppResolver,
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
+15 -32
View File
@@ -4,17 +4,20 @@ import { RMutexModule } from '@waha/modules/rmutex';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { Auth } from '@waha/core/auth/config';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const QUEUES_IMPORTS_REQUIRED = [
HttpPathsRegistration(
{ prefix: '/jobs', include: { authBasic: false } },
{ prefix: '/jobs/', include: { authBasic: false, accessLog: false } },
),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
@@ -71,47 +74,27 @@ const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
? QUEUES_IMPORTS_REQUIRED
: [];
function getAppModule(name: AppName) {
if (!AppRuntimeConfig.HasApp(name)) {
return {
imports: [],
controllers: [],
providers: [],
};
}
switch (name) {
case AppName.calls:
return CallsAppExports;
case AppName.chatwoot:
return ChatWootExports;
case AppName.mcp:
return McpModuleExports;
default:
throw Error(`App module not found for ${name}`);
}
}
// Apps enabled in the runtime configuration (WAHA_APPS_ON / WAHA_APPS_OFF)
const ENABLED_APPS = GetApps().filter((app) =>
AppRuntimeConfig.HasApp(app.name),
);
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.mcp).imports,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
...ENABLED_APPS.flatMap((app) => app.nestjs.imports),
],
controllers: [
AppsController,
...getAppModule(AppName.mcp).controllers,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
...ENABLED_APPS.flatMap((app) => app.nestjs.controllers),
],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.mcp).providers,
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
UniqueAppResolver,
...ENABLED_APPS.flatMap((app) => app.nestjs.providers),
],
};
@@ -0,0 +1,163 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import {
BrazilianPhoneCachePurgeResponse,
BrazilianPhoneCacheStatsResponse,
BrazilianPhoneDbCacheEntry,
BrazilianPhoneMemoryCacheEntry,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { SessionApiParam } from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import * as ms from 'ms';
const DEFAULT_LIMIT = 100;
@ApiSecurity('api_key')
@Controller('api/apps/brazilian-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class BrazilianPhoneNumbersController {
constructor(
private manager: SessionManager,
private resolver: UniqueAppResolver,
private appService: BrazilianPhoneNumbersAppService,
) {}
@Get('cache/memory')
@SessionApiParam
@ApiOperation({
summary: 'List in-memory cache entries',
description:
'Entries from the in-memory cache tier of the running session, ' +
'sorted by key. The session must be running.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async memory(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneMemoryCacheEntry[]> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
if (!plugin) {
throw new UnprocessableEntityException(
`Session '${session}' is not running - the in-memory cache is not available.`,
);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? DEFAULT_LIMIT;
return plugin.getMemoryCacheEntries().slice(offset, offset + limit);
}
@Get('cache/db')
@SessionApiParam
@ApiOperation({
summary: 'List persistent cache entries',
description:
'Entries from the persistent (database) cache tier, sorted by id. ' +
'Works even when the session is stopped.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async db(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneDbCacheEntry[]> {
const app = await this.getApp(session);
if (!this.persistentEnabled(app)) {
throw new UnprocessableEntityException(
`Persistent cache is disabled for the app in session '${session}'.`,
);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? DEFAULT_LIMIT;
return await this.repository(app).list(limit, offset);
}
@Get('cache/stats')
@SessionApiParam
@ApiOperation({
summary: 'Get cache stats',
description:
'Stats for both cache tiers. "memory" is null when the session is not running, ' +
'"db" is null when the persistent cache is disabled.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stats(
@Param('session') session: string,
): Promise<BrazilianPhoneCacheStatsResponse> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
const memory = plugin ? plugin.getMemoryCacheStats() : null;
let db = null;
if (this.persistentEnabled(app)) {
db = await this.repository(app).stats();
}
return { memory: memory, db: db };
}
@Delete('cache/purge')
@SessionApiParam
@ApiOperation({
summary: 'Purge the resolved-numbers cache',
description:
'Removes ALL persistent cache entries and clears the in-memory tier ' +
'(the in-memory tier only when the session is running).',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async purge(
@Param('session') session: string,
): Promise<BrazilianPhoneCachePurgeResponse> {
const app = await this.getApp(session);
const deleted = await this.appService.purgeCache(this.manager, app);
return { deleted: deleted };
}
private async getApp(session: string): Promise<AppDB> {
return await this.resolver.getEnabledApp(
session,
AppName.brazilianPhoneNumbers,
);
}
private persistentEnabled(app: AppDB): boolean {
const config = app.config as BrazilianPhoneNumbersAppConfig;
return config?.cache?.persistent ?? true;
}
private repository(app: AppDB): BrazilianPhoneCacheRepository {
const knex = this.manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
return new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
}
}
@@ -0,0 +1,27 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/api/brazilian-phone-numbers.controller';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
const BrazilianPhoneNumbersAppModule: AppModule = {
name: AppName.brazilianPhoneNumbers,
openapi: {
title: 'Brazilian Phone Numbers',
description: 'Resolve Brazilian phone numbers (with and without 9 digit)',
},
definition: {
plainkey: false,
queue: false,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [BrazilianPhoneNumbersController],
providers: [BrazilianPhoneNumbersAppService],
},
Service: BrazilianPhoneNumbersAppService,
};
export default BrazilianPhoneNumbersAppModule;
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { Type } from 'class-transformer';
export class BrazilianPhoneNumbersAppDto extends App<BrazilianPhoneNumbersAppConfig> {
@Type(() => BrazilianPhoneNumbersAppConfig)
config: BrazilianPhoneNumbersAppConfig;
}
@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrazilianPhoneMemoryCacheEntry {
@ApiProperty({
description: 'Phone number digits the cache entry is keyed by',
})
key: string;
@ApiProperty({
description:
'Resolved chat id. An empty string is a confirmed-negative - ' +
'the number is verified NOT to exist on WhatsApp.',
})
chatId: string;
@ApiProperty({
description: 'When the entry expires; null when it has no TTL',
nullable: true,
type: Date,
})
expiresAt: Date | null;
}
export class BrazilianPhoneDbCacheEntry {
@ApiProperty({
description: 'Record id',
})
id: number;
@ApiProperty({
description: 'Phone number digits the cache entry is keyed by',
})
key: string;
@ApiProperty({
description: 'Resolved chat id',
})
chatId: string;
@ApiProperty({
description: 'Whether the resolution was verified against WhatsApp',
})
verified: boolean;
@ApiProperty({
description: 'When the number was resolved',
})
resolvedAt: Date;
}
export class BrazilianPhoneMemoryCacheStats {
@ApiProperty({
description: 'Number of entries in the in-memory cache',
})
total: number;
}
export class BrazilianPhoneDbCacheStats {
@ApiProperty({
description: 'Total number of entries in the persistent cache',
})
total: number;
@ApiProperty({
description: 'Number of entries verified against WhatsApp',
})
verified: number;
}
export class BrazilianPhoneCacheStatsResponse {
@ApiProperty({
description: 'In-memory cache stats; null when the session is not running',
nullable: true,
type: BrazilianPhoneMemoryCacheStats,
})
memory: BrazilianPhoneMemoryCacheStats | null;
@ApiProperty({
description:
'Persistent cache stats; null when the persistent cache is disabled',
nullable: true,
type: BrazilianPhoneDbCacheStats,
})
db: BrazilianPhoneDbCacheStats | null;
}
export class BrazilianPhoneCachePurgeResponse {
@ApiProperty({
description: 'Number of entries removed from the persistent cache',
})
deleted: number;
}
@@ -0,0 +1,88 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDuration } from '@waha/nestjs/validation/IsDuration';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import * as ms from 'ms';
export const DEFAULT_MEMORY_TTL: ms.StringValue = '24h';
export const DEFAULT_PERSISTENT_TTL: ms.StringValue = '31d';
export class BrazilianPhoneNumbersCacheConfig {
@ApiProperty({
description:
'TTL for resolved numbers in the in-memory cache tier, as a duration string.',
required: false,
default: DEFAULT_MEMORY_TTL,
example: '24h',
})
@IsOptional()
@IsString()
@IsDuration()
memoryTtl?: string = DEFAULT_MEMORY_TTL;
@ApiProperty({
description:
'Persist verified resolutions in the database so they survive session restarts. ' +
'Unverified best-guesses and negatives are never persisted.',
required: false,
default: true,
})
@IsOptional()
@IsBoolean()
persistent?: boolean = true;
@ApiProperty({
description:
'TTL for resolved numbers in the database cache tier, as a duration string.',
required: false,
default: DEFAULT_PERSISTENT_TTL,
example: '31d',
})
@IsOptional()
@IsString()
@IsDuration()
persistentTtl?: string = DEFAULT_PERSISTENT_TTL;
}
export class BrazilianPhoneNumbersAppConfig {
@ApiProperty({
description:
'When a Brazilian mobile number is confirmed NOT to exist on WhatsApp: ' +
'false (default) - warn and send the best-guess anyway; ' +
'true - reject the send with 422. Strict trades delivery for certainty ' +
'and can block valid sends on lookup false-negatives (throttling).',
required: false,
default: false,
})
@IsOptional()
@IsBoolean()
strict?: boolean = false;
@ApiProperty({
description:
'Allow the WhatsApp server lookup tier for numbers the cache and the ' +
'local contact store cannot resolve. When false, unresolved numbers are ' +
'sent as provided.',
required: false,
default: true,
})
@IsOptional()
@IsBoolean()
lookup?: boolean = true;
@ApiProperty({
description: 'Cache tuning for resolved numbers.',
required: false,
type: BrazilianPhoneNumbersCacheConfig,
})
@IsOptional()
@ValidateNested()
@Type(() => BrazilianPhoneNumbersCacheConfig)
cache?: BrazilianPhoneNumbersCacheConfig =
new BrazilianPhoneNumbersCacheConfig();
}
@@ -0,0 +1,29 @@
import { Knex } from 'knex';
exports.up = function (knex: Knex) {
return knex.schema
.createTable('app_brazilian_phone_numbers_cache', function (table) {
table.increments('id');
table.integer('app_pk');
table
.foreign('app_pk')
.references('pk')
.inTable('apps')
.onDelete('CASCADE');
// Phone number digits the caller may address the chat by
table.string('key', 32);
// The chat id the number resolved to ('5585...@c.us' or '...@lid')
table.string('chat_id', 64);
table.boolean('verified');
table.datetime('resolved_at');
})
.table('app_brazilian_phone_numbers_cache', function (table) {
table.unique(['app_pk', 'key'], { indexName: 'brphone_app_key_unique' });
table.index(['app_pk'], 'brphone_app_idx');
table.index(['app_pk', 'resolved_at'], 'brphone_app_resolved_idx');
});
};
exports.down = function (knex: Knex) {
return knex.schema.dropTable('app_brazilian_phone_numbers_cache');
};
@@ -0,0 +1,474 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import {
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneNumbersCacheConfig,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { RegisterPluginHooks } from '@waha/core/abc/session.plugin.hooks';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {}
function buildSession(): any {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
function buildConfig(
overrides: Partial<BrazilianPhoneNumbersAppConfig> = {},
): BrazilianPhoneNumbersAppConfig {
const config = new BrazilianPhoneNumbersAppConfig();
config.cache = new BrazilianPhoneNumbersCacheConfig();
return Object.assign(config, overrides);
}
interface BuildOptions {
config?: Partial<BrazilianPhoneNumbersAppConfig>;
repository?: any;
pluginClass?: typeof BrazilianPhoneCorePlugin;
}
function buildPlugin(options: BuildOptions = {}) {
const session = buildSession();
const PluginClass = options.pluginClass ?? BrazilianPhoneCorePlugin;
const plugin = new PluginClass(session, logger, buildConfig(options.config), {
repository: options.repository ?? null,
});
RegisterPluginHooks(plugin);
return { session: session, plugin: plugin };
}
function resolveChat(session: any, wid: string, method = 'sendText') {
return session.hooks.wid.chat.promise(wid, method);
}
function stubLookup(session: any, answer: any) {
session.checkNumberStatus = jest.fn(async () => answer);
}
describe('BrazilianPhonePlugin - wid.chat', () => {
it('resolves and caches the canonical phone when the engine answers with a PN', async () => {
// '558591203123' is the real number: DDD 85 with an 8-digit local part.
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
// Second call is served from cache - no extra WhatsApp lookup.
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '558591203123',
session: 'test',
});
});
it('prefers the pn form when the engine answers with both pn and a LID', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '77820596330581@lid',
pn: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
});
it('caches a LID answer as-is instead of stapling a phone suffix on it', async () => {
// Engines answer with a LID only when the account has no phone form. The
// LID is routable, but '77820596330581@c.us' - its digits with a phone
// suffix - addresses nobody.
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '77820596330581@lid',
});
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('77820596330581@lid');
expect(second).toBe('77820596330581@lid');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('reuses the cache across both forms of the same number', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
// Wrong form first, then the already-correct one: same target, one lookup.
const wrongForm = await resolveChat(session, '5585991203123@c.us');
const rightForm = await resolveChat(session, '558591203123@c.us');
expect(wrongForm).toBe('558591203123@c.us');
expect(rightForm).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('shares one in-flight lookup between concurrent sends (single-flight)', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const [first, second] = await Promise.all([
resolveChat(session, '5585991203123@c.us'),
resolveChat(session, '558591203123@c.us'),
]);
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('rewrites a dialed toll-free (0800) to the stored form, no lookup', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
// Dialed forms with and without the country code, plus the already-stored
// form, all address the same chat id - and none hits the WhatsApp lookup.
expect(await resolveChat(session, '08000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(await resolveChat(session, '5508000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(await resolveChat(session, '558000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('applies the static 9th-digit rule for DDD below the lookup range, no lookup', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '551198765432@c.us');
expect(resolved).toBe('5511998765432@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('leaves non-Brazilian numbers untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '12132132130@c.us');
expect(resolved).toBe('12132132130@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('passes recursion-sensitive methods through untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const viaCheck = await resolveChat(
session,
'5585991203123@c.us',
'checkNumberStatus',
);
const viaLidMap = await resolveChat(
session,
'5585991203123@c.us',
'findLIDByPhoneNumber',
);
expect(viaCheck).toBe('5585991203123@c.us');
expect(viaLidMap).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('resolves non-send methods locally only - no lookup, cache still honored', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
// Cache miss on a local-only method: input goes through unresolved.
const cold = await resolveChat(
session,
'5585991203123@c.us',
'startTyping',
);
expect(cold).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
// A send resolves and caches; the local-only method now sees the cache.
await resolveChat(session, '5585991203123@c.us', 'sendText');
const warm = await resolveChat(
session,
'5585991203123@c.us',
'startTyping',
);
expect(warm).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('throws 422 for a malformed number on send, passes it through otherwise', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
await expect(resolveChat(session, '55859912@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
const readOp = await resolveChat(session, '55859912@c.us', 'getPresence');
expect(readOp).toBe('55859912@c.us');
});
it('soft mode: sends the best-guess when the number is confirmed not to exist', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '558591203123@c.us');
// DDD 85 keeps the 8-digit heuristic as the best-guess.
expect(resolved).toBe('558591203123@c.us');
// Both candidates were tried before giving up.
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
});
it('strict mode: rejects a confirmed-nonexistent number and caches the negative', async () => {
const { session } = buildPlugin({ config: { strict: true } });
stubLookup(session, { numberExists: false });
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
const lookups = session.checkNumberStatus.mock.calls.length;
// The negative is cached - the retry rejects without a new lookup...
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
expect(session.checkNumberStatus).toHaveBeenCalledTimes(lookups);
// ...and local-only methods still pass the number through.
const readOp = await resolveChat(
session,
'5585991203123@c.us',
'getPresence',
);
expect(readOp).toBe('5585991203123@c.us');
});
it('sends as-is without caching when the lookup fails', async () => {
const { session } = buildPlugin();
session.checkNumberStatus = jest.fn(async () => {
throw new Error('engine down');
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
const lookups = session.checkNumberStatus.mock.calls.length;
// Not cached: the next send retries the lookup.
await resolveChat(session, '5585991203123@c.us');
expect(session.checkNumberStatus.mock.calls.length).toBeGreaterThan(
lookups,
);
});
it('lookup: false disables the WhatsApp tier', async () => {
const { session } = buildPlugin({ config: { lookup: false } });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
});
describe('BrazilianPhonePlugin - wid.mention', () => {
it('resolves mentions best-effort and never breaks the send', async () => {
const { session } = buildPlugin({ config: { strict: true } });
stubLookup(session, { numberExists: false });
// Strict mode rejects the number on the chat hook, but a mention falls
// back to the input instead of failing the whole message.
const mention = await session.hooks.wid.mention.promise(
'5585991203123@c.us',
'sendText',
);
expect(mention).toBe('5585991203123@c.us');
});
});
describe('BrazilianPhonePlugin - persistent cache tier', () => {
function buildRepository() {
return {
get: jest.fn().mockResolvedValue(null),
setMany: jest.fn().mockResolvedValue(undefined),
};
}
it('serves a persisted resolution without any lookup and warms the memory tier', async () => {
const repository = buildRepository();
repository.get.mockResolvedValue({
key: '5585991203123',
chatId: '558591203123@c.us',
verified: true,
resolvedAt: new Date(),
});
const { session } = buildPlugin({ repository: repository });
stubLookup(session, { numberExists: false });
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
// Second call hits the memory tier - the database is read once.
expect(repository.get).toHaveBeenCalledTimes(1);
});
it('persists verified resolutions under both forms of the number', async () => {
const repository = buildRepository();
const { session } = buildPlugin({ repository: repository });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
await resolveChat(session, '5585991203123@c.us');
expect(repository.setMany).toHaveBeenCalledTimes(1);
const [keys, chatId, verified] = repository.setMany.mock.calls[0];
expect([...keys].sort()).toEqual(['558591203123', '5585991203123']);
expect(chatId).toBe('558591203123@c.us');
expect(verified).toBe(true);
});
it('does not persist best-guesses or static-rule rewrites', async () => {
const repository = buildRepository();
const { session } = buildPlugin({ repository: repository });
stubLookup(session, { numberExists: false });
// Static rule (DDD 11) and a confirmed-nonexistent best-guess (DDD 85).
await resolveChat(session, '551198765432@c.us');
await resolveChat(session, '558591203123@c.us');
expect(repository.setMany).not.toHaveBeenCalled();
});
it('resolves normally when the persistent tier errors out', async () => {
const repository = buildRepository();
repository.get.mockRejectedValue(new Error('db down'));
const { session } = buildPlugin({ repository: repository });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
});
});
describe('BrazilianPhoneGowsPlugin - local LID map tier', () => {
it('resolves via the LID map with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneGowsPlugin });
stubLookup(session, { numberExists: false });
session.findLIDByPhoneNumber = jest.fn(async (phone: string) => {
if (phone === '558591203123') {
return { lid: '77820596330581@lid', pn: '558591203123@c.us' };
}
return { lid: null, pn: null };
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('falls through to the WhatsApp lookup for unknown numbers', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneGowsPlugin });
stubLookup(session, {
numberExists: true,
chatId: '5585991203123@c.us',
});
session.findLIDByPhoneNumber = jest.fn(async () => {
return { lid: null, pn: null };
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalled();
});
});
describe('BrazilianPhoneNowebPlugin - local contact store tier', () => {
it('resolves via the contact store with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneNowebPlugin });
stubLookup(session, { numberExists: false });
session.store = {
getContactById: jest.fn(async (jid: string) => {
if (jid === '558591203123@s.whatsapp.net') {
return { id: '558591203123@s.whatsapp.net' };
}
return null;
}),
};
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('falls through to the WhatsApp lookup when the store has no match', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneNowebPlugin });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
session.store = {
getContactById: jest.fn(async () => null),
};
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalled();
});
});
@@ -0,0 +1,383 @@
import { UnprocessableEntityException } from '@nestjs/common';
import {
BrazilianPhoneMemoryCacheEntry,
BrazilianPhoneMemoryCacheStats,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_MEMORY_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import {
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
extractPhoneDigits,
generateBrazilMobileLookupCandidates,
getBrazilPhoneCacheKeys,
isBrazilCountryCode,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from '@waha/apps/brazilian-phone-numbers/utils/brPhone';
import { ensureSuffix, WhatsappSession } from '@waha/core/abc/session.abc';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginHook } from '@waha/core/abc/session.plugin.hooks';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { WANumberExistResult } from '@waha/structures/chatting.dto';
import * as ms from 'ms';
import * as NodeCache from 'node-cache';
import { Logger } from 'pino';
// Engine methods that call these hooks from inside the resolution tiers
// (WPP 'checkNumberStatus', NOWEB/GOWS/WPP 'findLIDByPhoneNumber' route the
// phone through 'wid.chat' themselves) - resolving here again would recurse.
const SKIP_METHODS = new Set(['checkNumberStatus', 'findLIDByPhoneNumber']);
// Message-send paths get the full resolution, including the WhatsApp lookup,
// and may reject with 422 (malformed number, strict mode). Every other method
// (typing, seen, read, delete, group ops, ...) resolves locally only - cache
// and static rule - and never reaches the network or throws.
const SEND_METHODS = new Set([
'sendText',
'sendImage',
'sendFile',
'sendVideo',
'sendVoice',
'sendMedia',
'sendPoll',
'sendList',
'sendButtons',
'sendButtonsReply',
'sendContactVCard',
'sendLocation',
'sendLinkPreview',
'sendLinkCustomPreview',
'sendEvent',
'reply',
'forwardMessage',
]);
// Confirmed-negative marker in the memory cache ('' is not a valid chat id).
const NOT_FOUND = '';
export interface BrazilianPhoneCorePluginDeps {
repository: BrazilianPhoneCacheRepository | null;
}
/**
* Resolves Brazilian phone numbers into the chat id the account is actually
* registered under (9th-digit ambiguity for DDD 31-99, static rule for
* DDD < 31, 0800 toll-free rewrite), tiered cheapest-first:
*
* 1. in-memory cache, keyed by both forms of the number
* 2. static 9th-digit rule for DDD < 31 - no network
* 3. persistent database cache (verified resolutions only)
* 4. local contact / LID store (engine-specific subclasses) - no network
* 5. WhatsApp lookup, single-flight so concurrent sends share one query
*/
export class BrazilianPhoneCorePlugin extends SessionPlugin<
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneCorePluginDeps
> {
protected memory: NodeCache;
// Single-flight guard: concurrent first-time resolutions of the same number
// share one in-flight WhatsApp lookup instead of each firing its own query.
private inflight: Map<string, Promise<string>> = new Map();
constructor(
session: WhatsappSession,
logger: Logger,
config: BrazilianPhoneNumbersAppConfig,
deps: BrazilianPhoneCorePluginDeps,
) {
super(session, logger, config, deps);
const memoryTtlMs =
parseDurationMs(config?.cache?.memoryTtl) ?? ms(DEFAULT_MEMORY_TTL);
this.memory = new NodeCache({
stdTTL: Math.floor(memoryTtlMs / 1000),
});
}
@PluginHook((hooks) => hooks.wid.chat)
async resolveChatWid(wid: string, method: string): Promise<string> {
if (SKIP_METHODS.has(method)) {
return wid;
}
return await this.resolve(wid, SEND_METHODS.has(method));
}
// Mentions are best-effort: a non-existent mention must never break the send.
@PluginHook((hooks) => hooks.wid.mention)
async resolveMentionWid(wid: string, method: string): Promise<string> {
if (SKIP_METHODS.has(method)) {
return wid;
}
try {
return await this.resolve(wid, SEND_METHODS.has(method));
} catch (error) {
this.logger.warn(
`Could not resolve mention '${wid}', using as-is: ${error}`,
);
return wid;
}
}
public clearMemoryCache(): void {
this.memory.flushAll();
}
public getMemoryCacheStats(): BrazilianPhoneMemoryCacheStats {
return { total: this.memory.keys().length };
}
// Entries sorted by key, so API pagination over them is stable.
public getMemoryCacheEntries(): BrazilianPhoneMemoryCacheEntry[] {
const entries: BrazilianPhoneMemoryCacheEntry[] = [];
for (const key of this.memory.keys().sort()) {
const chatId = this.memory.get<string>(key);
if (chatId === undefined) {
continue;
}
// getTtl(): expiration timestamp in ms, 0 = no TTL
const expiresAtMs = this.memory.getTtl(key);
entries.push({
key: key,
chatId: chatId,
expiresAt: expiresAtMs ? new Date(expiresAtMs) : null,
});
}
return entries;
}
// Optional per-engine tier: resolve a candidate against the local contact /
// LID store without hitting WhatsApp servers. Default: no local store.
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
void candidates;
return null;
}
// Cached values are full chat ids ('5511...@c.us' or '123@lid'), never bare
// digits: stripping the suffix loses which addressing form was resolved, and
// re-adding '@c.us' to LID digits builds an id that addresses nobody.
protected async resolve(wid: string, validate: boolean): Promise<string> {
const withSuffix = ensureSuffix(wid);
if (shouldSkipBrazilPhoneNormalization(withSuffix)) {
return withSuffix;
}
const digits = extractPhoneDigits(withSuffix);
// Brazilian toll-free (0800): deterministic rewrite to the stored form, no
// lookup. Handled before the country-code gate because the dialed form
// ('0800...') has no 55 prefix.
const tollFree = normalizeBrazilTollFreeDigits(digits);
if (tollFree) {
return ensureSuffix(tollFree);
}
if (!isBrazilCountryCode(digits)) {
return withSuffix;
}
// Malformed Brazilian numbers (e.g. 55859912): hard error only on the send
// path; local-only ops just pass it through untouched.
if (isMalformedBrazilPhone(digits)) {
if (validate) {
throw new UnprocessableEntityException(
`Invalid Brazilian phone number '${withSuffix}'.`,
);
}
return withSuffix;
}
// Landlines and already-valid non-mobile numbers are left untouched.
if (!isBrazilMobile(digits)) {
return withSuffix;
}
// Tier 1: in-memory cache. undefined = miss, '' = confirmed-negative
// (strict mode), otherwise the resolved/best-guess chat id.
const cached = this.memory.get<string>(digits);
if (cached !== undefined) {
if (cached === NOT_FOUND) {
if (validate) {
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp.`,
);
}
return withSuffix;
}
return cached;
}
// DDD below the lookup range: static 9th-digit rule, no network needed.
if (!needsBrazilWhatsAppLookup(digits)) {
const normalized = ensureSuffix(
normalizeBrazilMobileForSendDigits(digits),
);
this.cacheInMemory(digits, normalized);
return normalized;
}
// Tier 2: database cache (verified resolutions only).
const fromDb = await this.getFromDb(digits);
if (fromDb) {
this.cacheInMemory(digits, fromDb);
return fromDb;
}
const candidates = generateBrazilMobileLookupCandidates(digits);
// Tier 3: local contact/LID store (engine-specific), no network.
const fromStore = await this.lookupKnownChatId(candidates);
if (fromStore) {
await this.cacheResolved(digits, fromStore);
this.logger.debug(
`BR mobile '${withSuffix}' resolved locally to '${fromStore}' (no WhatsApp lookup).`,
);
return fromStore;
}
// Local-only ops never reach the network: return the input as-is.
if (!validate) {
return withSuffix;
}
// The WhatsApp lookup tier is disabled by config: send as provided.
if (this.config.lookup === false) {
return withSuffix;
}
// Tier 4: WhatsApp lookup as last resort, de-duplicated via single-flight.
return await this.resolveViaWhatsApp(digits, withSuffix, candidates);
}
private resolveViaWhatsApp(
digits: string,
withSuffix: string,
candidates: string[],
): Promise<string> {
const key = getBrazilPhoneCacheKeys(digits).sort().join('|');
const inflight = this.inflight.get(key);
if (inflight) {
return inflight;
}
const promise = this.lookupOnWhatsApp(
digits,
withSuffix,
candidates,
).finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}
private async lookupOnWhatsApp(
digits: string,
withSuffix: string,
candidates: string[],
): Promise<string> {
this.logger.debug(
`BR mobile '${withSuffix}' not found locally, performing WhatsApp lookup for: ${candidates.join(
', ',
)}`,
);
let lookupFailed = false;
for (const candidate of candidates) {
let result: WANumberExistResult;
try {
result = await this.session.checkNumberStatus({
phone: candidate,
session: this.session.name,
});
} catch (error) {
lookupFailed = true;
this.logger.warn(
`Failed to verify Brazilian mobile candidate '${candidate}': ${error}`,
);
continue;
}
// Prefer the phone-number chat id when the engine knows it; fall back to
// whatever chat id was answered (a LID for accounts with no phone form -
// routable, and it must not be reduced to digits).
const chatId = result?.pn || result?.chatId;
if (result?.numberExists && chatId) {
await this.cacheResolved(digits, chatId);
return chatId;
}
}
// Could not validate due to network/engine error: send as-is, do not cache.
if (lookupFailed) {
this.logger.warn(
`Could not validate Brazilian mobile number '${withSuffix}', sending as-is. Tried: ${candidates.join(
', ',
)}`,
);
return withSuffix;
}
// Verified not to exist in any form.
if (this.config.strict) {
this.cacheInMemory(
digits,
NOT_FOUND,
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
);
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp. Tried: ${candidates.join(
', ',
)}`,
);
}
// Soft (default): warn and send the best-guess anyway, so a lookup
// false-negative never blocks a valid send. Short TTL - the number may
// get registered later.
const bestGuess = ensureSuffix(normalizeBrazilMobileForSendDigits(digits));
this.cacheInMemory(digits, bestGuess, BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS);
this.logger.warn(
`Brazilian mobile number '${withSuffix}' not found on WhatsApp, sending best-guess '${bestGuess}'. Tried: ${candidates.join(
', ',
)}`,
);
return bestGuess;
}
// Cache under every form of the number, so both '5585...' and '55859...'
// hit the same entry. Default TTL for resolutions, the short negative TTL
// for best-guesses and confirmed-negatives.
private cacheInMemory(digits: string, chatId: string, ttl?: number): void {
for (const key of getBrazilPhoneCacheKeys(digits)) {
this.memory.set(key, chatId, ttl);
}
}
// Verified resolution: memory plus the database tier (when enabled).
private async cacheResolved(digits: string, chatId: string): Promise<void> {
this.cacheInMemory(digits, chatId);
if (!this.deps.repository) {
return;
}
try {
const keys = getBrazilPhoneCacheKeys(digits);
await this.deps.repository.setMany(keys, chatId, true, new Date());
} catch (error) {
this.logger.warn(
`Failed to persist BR phone resolution for '${digits}': ${error}`,
);
}
}
private async getFromDb(digits: string): Promise<string | null> {
if (!this.deps.repository) {
return null;
}
try {
const entry = await this.deps.repository.get(digits);
return entry?.chatId ?? null;
} catch (error) {
this.logger.warn(
`Failed to read BR phone cache for '${digits}': ${error}`,
);
return null;
}
}
}
@@ -0,0 +1,30 @@
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
/**
* GOWS local tier: the PN<->LID map (whatsmeow's 'whatsmeow_lid_map'), which is
* populated from contact sync and received messages and persisted in the gows
* store. A PN that has a LID mapping is a number this session already knows in
* its canonical form, so the correct 9th-digit variant is picked with zero
* network calls. Only genuinely cold numbers fall through to the WhatsApp
* lookup tier.
*/
export class BrazilianPhoneGowsPlugin extends BrazilianPhoneCorePlugin {
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
for (const candidate of candidates) {
try {
const { lid, pn } = await this.session.findLIDByPhoneNumber(candidate);
// A non-empty LID user part means this exact PN is known locally.
if (lid && lid.split('@')[0]) {
return pn;
}
} catch (error) {
this.logger.debug(
`LID map lookup failed for candidate '${candidate}': ${error}`,
);
}
}
return null;
}
}
@@ -0,0 +1,29 @@
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { toCusFormat, toJID } from '@waha/core/utils/jids';
import type { WhatsappSessionNoWebCore } from '@waha/core/engines/noweb/session.noweb.core';
/**
* NOWEB local tier: the session's contact store, populated from history sync
* and received messages. A contact stored under one of the candidate forms is
* the canonical 9th-digit variant - no network call needed.
*/
export class BrazilianPhoneNowebPlugin extends BrazilianPhoneCorePlugin {
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
const session = this.session as WhatsappSessionNoWebCore;
const store = session.store;
if (!store) {
return null;
}
for (const candidate of candidates) {
const jid = toJID(candidate);
const contact = await store.getContactById(jid).catch(() => null);
if (contact?.id) {
return toCusFormat(contact.id);
}
}
return null;
}
}
@@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { WAHAEngine } from '@waha/structures/enums.dto';
import * as ms from 'ms';
// Engines with a local contact/LID store get their own tier; the rest run the
// core pipeline (cache + static rule + WhatsApp lookup).
const PLUGINS: Record<WAHAEngine, typeof BrazilianPhoneCorePlugin> = {
[WAHAEngine.WEBJS]: BrazilianPhoneCorePlugin,
[WAHAEngine.WPP]: BrazilianPhoneCorePlugin,
[WAHAEngine.GOWS]: BrazilianPhoneGowsPlugin,
[WAHAEngine.NOWEB]: BrazilianPhoneNowebPlugin,
};
@Injectable()
export class BrazilianPhoneNumbersAppService implements IAppService {
constructor(private readonly resolver: UniqueAppResolver) {}
validate(app: App<BrazilianPhoneNumbersAppConfig>): void {
void app;
return;
}
async beforeCreated(app: App<BrazilianPhoneNumbersAppConfig>): Promise<void> {
void app;
return;
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async afterCreated(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async purge(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
await this.purgeCache(manager, app as AppDB);
}
/**
* Deletes all persistent cache entries and clears the in-memory tier when the session is running.
*/
async purgeCache(manager: SessionManager, app: AppDB): Promise<number> {
const knex = manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
const repository = new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
const deleted = await repository.purge();
this.resolver.getPlugin(app, BrazilianPhoneCorePlugin)?.clearMemoryCache();
return deleted;
}
async enrich(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
plugins(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
store?: DataStore,
): PluginOptions[] {
const config = app.config ?? new BrazilianPhoneNumbersAppConfig();
let repository: BrazilianPhoneCacheRepository | null = null;
const persistent = config.cache?.persistent ?? true;
const appPk = (app as AppDB).pk;
if (persistent && store && appPk) {
const ttlMs =
parseDurationMs(config.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
repository = new BrazilianPhoneCacheRepository(
store.getWAHADatabase(),
appPk,
ttlMs,
);
}
const BrazilianPhonePlugin = PLUGINS[session.engine];
return [BrazilianPhonePlugin.with(config, { repository: repository })];
}
beforeSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
}
afterSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
}
}
@@ -0,0 +1,135 @@
import knex, { Knex } from 'knex';
import { BrazilianPhoneCacheRepository } from './BrazilianPhoneCacheRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const initApps = require('../../app_sdk/migrations/001_init_apps');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const initCache = require('../migrations/001_init_brazilian_phone_numbers');
const DAY_MS = 24 * 60 * 60 * 1000;
describe('BrazilianPhoneCacheRepository', () => {
let db: Knex;
let appPk: number;
let repository: BrazilianPhoneCacheRepository;
beforeAll(async () => {
db = knex({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await initApps.up(db);
await initCache.up(db);
const [pk] = await db('apps')
.insert({
id: 'app_test',
session: 'default',
app: 'brazilian-phone-numbers',
config: '{}',
})
.returning('pk');
appPk = typeof pk === 'object' ? pk.pk : pk;
});
afterAll(async () => {
await db.destroy();
});
beforeEach(async () => {
repository = new BrazilianPhoneCacheRepository(db, appPk, 31 * DAY_MS);
await repository.purge();
});
it('stores and reads a resolution under multiple keys', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const byShortForm = await repository.get('558591203123');
const byLongForm = await repository.get('5585991203123');
expect(byShortForm?.chatId).toBe('558591203123@c.us');
expect(byShortForm?.verified).toBe(true);
expect(byLongForm?.chatId).toBe('558591203123@c.us');
});
it('upserts on conflict instead of duplicating keys', async () => {
await repository.setMany(['558591203123'], 'old@c.us', true, new Date());
await repository.setMany(
['558591203123'],
'77820596330581@lid',
true,
new Date(),
);
const entry = await repository.get('558591203123');
const stats = await repository.stats();
expect(entry?.chatId).toBe('77820596330581@lid');
expect(stats.total).toBe(1);
});
it('does not return expired entries', async () => {
const expired = new Date(Date.now() - 40 * DAY_MS);
await repository.setMany(
['558591203123'],
'558591203123@c.us',
true,
expired,
);
const entry = await repository.get('558591203123');
expect(entry).toBeNull();
});
it('purges only entries older than the given date', async () => {
const old = new Date(Date.now() - 10 * DAY_MS);
await repository.setMany(['558591203123'], '558591203123@c.us', true, old);
await repository.setMany(
['5511998765432'],
'5511998765432@c.us',
true,
new Date(),
);
const deleted = await repository.purge(new Date(Date.now() - 5 * DAY_MS));
expect(deleted).toBe(1);
expect(await repository.get('558591203123')).toBeNull();
expect(await repository.get('5511998765432')).not.toBeNull();
});
it('purges everything when no date is given', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const deleted = await repository.purge();
expect(deleted).toBe(2);
expect((await repository.stats()).total).toBe(0);
});
it('reports total and verified counts', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const stats = await repository.stats();
expect(stats.total).toBe(2);
expect(stats.verified).toBe(2);
});
});
@@ -0,0 +1,123 @@
import { Knex } from 'knex';
export interface BrazilianPhoneCacheEntry {
key: string;
chatId: string;
verified: boolean;
resolvedAt: Date;
}
export interface BrazilianPhoneCacheRow extends BrazilianPhoneCacheEntry {
id: number;
}
export interface BrazilianPhoneCacheStats {
total: number;
verified: number;
}
export class BrazilianPhoneCacheRepository {
static tableName = 'app_brazilian_phone_numbers_cache';
constructor(
private readonly knex: Knex,
private readonly appPk: number,
private readonly ttlMs: number,
) {}
get tableName() {
return BrazilianPhoneCacheRepository.tableName;
}
// Entries resolved before this date are expired.
private ttlCutoff(): Date {
return new Date(Date.now() - this.ttlMs);
}
async get(key: string): Promise<BrazilianPhoneCacheEntry | null> {
const row = await this.knex(this.tableName)
.where({
app_pk: this.appPk,
key: key,
})
.where('resolved_at', '>=', this.ttlCutoff())
.first();
if (!row) {
return null;
}
return {
key: row.key,
chatId: row.chat_id,
verified: Boolean(row.verified),
resolvedAt: new Date(row.resolved_at),
};
}
async setMany(
keys: string[],
chatId: string,
verified: boolean,
resolvedAt: Date,
): Promise<void> {
const rows = keys.map((key) => ({
app_pk: this.appPk,
key: key,
chat_id: chatId,
verified: verified,
resolved_at: resolvedAt,
}));
await this.knex(this.tableName)
.insert(rows)
.onConflict(['app_pk', 'key'])
.merge();
}
/**
* Lists cache entries for the app, sorted by id (insertion order).
*/
async list(limit: number, offset: number): Promise<BrazilianPhoneCacheRow[]> {
const rows = await this.knex(this.tableName)
.where({ app_pk: this.appPk })
.orderBy('id', 'asc')
.limit(limit)
.offset(offset);
return rows.map((row) => ({
id: row.id,
key: row.key,
chatId: row.chat_id,
verified: Boolean(row.verified),
resolvedAt: new Date(row.resolved_at),
}));
}
/**
* Deletes cache entries for the app.
* @param olderThan Only entries resolved before this date; all entries when omitted.
* @returns Number of deleted entries
*/
async purge(olderThan?: Date): Promise<number> {
const query = this.knex(this.tableName).where({ app_pk: this.appPk });
if (olderThan) {
query.where('resolved_at', '<', olderThan);
}
return await query.delete();
}
async stats(): Promise<BrazilianPhoneCacheStats> {
const row: any = await this.knex(this.tableName)
.where({ app_pk: this.appPk })
.count({ total: '*' })
.first();
const verifiedRow: any = await this.knex(this.tableName)
.where({
app_pk: this.appPk,
verified: true,
})
.count({ verified: '*' })
.first();
return {
total: Number(row?.total ?? 0),
verified: Number(verifiedRow?.verified ?? 0),
};
}
}
@@ -0,0 +1,120 @@
import {
extractPhoneDigits,
generateBrazilMobileLookupCandidates,
isBrazilCountryCode,
isBrazilLandline,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from './brPhone';
describe('brPhone', () => {
it('skips groups and lids', () => {
expect(shouldSkipBrazilPhoneNormalization('123@g.us')).toBe(true);
expect(shouldSkipBrazilPhoneNormalization('123@lid')).toBe(true);
});
it('detects BR country code', () => {
expect(isBrazilCountryCode('558591203123')).toBe(true);
expect(isBrazilCountryCode('5491123456789')).toBe(false);
});
it('flags malformed BR numbers and accepts valid lengths', () => {
expect(isMalformedBrazilPhone('55859912')).toBe(true);
expect(isMalformedBrazilPhone('558591203123')).toBe(false);
expect(isMalformedBrazilPhone('5585991203123')).toBe(false);
// not a BR number, not our concern
expect(isMalformedBrazilPhone('123')).toBe(false);
});
it('detects BR landline numbers', () => {
expect(isBrazilLandline('558540423147')).toBe(true);
expect(isBrazilMobile('558540423147')).toBe(false);
});
it('detects BR mobile numbers', () => {
expect(isBrazilMobile('558591203123')).toBe(true);
expect(isBrazilMobile('5585991203123')).toBe(true);
});
it('accepts any digit after the leading 9 on a 9-digit local', () => {
// Brazil has no 9-digit landline, so '9' + 8 digits is always mobile.
// The old rule required 6-9 right after the 9 and rejected real SP lines.
expect(isBrazilMobile('5511953523741')).toBe(true);
expect(isBrazilLandline('5511953523741')).toBe(false);
expect(isBrazilMobile('5511912345678')).toBe(true);
expect(isBrazilMobile('5511902345678')).toBe(true);
});
it('keeps 9-digit mobiles out of the lookup range untouched', () => {
// DDD 11 is below the lookup range: no candidates, no WhatsApp lookup.
expect(needsBrazilWhatsAppLookup('5511953523741')).toBe(false);
expect(normalizeBrazilMobileForSendDigits('5511953523741')).toBe(
'5511953523741',
);
});
it('detects landlines in both DDD ranges', () => {
expect(isBrazilLandline('551151923057')).toBe(true);
expect(isBrazilLandline('558540428310')).toBe(true);
expect(isBrazilMobile('551151923057')).toBe(false);
expect(isBrazilMobile('558540428310')).toBe(false);
});
it('rewrites toll-free (0800) numbers to the stored form', () => {
// Dialed '0800' + 7 digits -> stored '55800' + 7 digits (leading 0 dropped,
// country code added). Both the bare and the 55-prefixed dialed forms map
// to the same stored number the server resolves them to.
expect(normalizeBrazilTollFreeDigits('08000464636')).toBe('558000464636');
expect(normalizeBrazilTollFreeDigits('5508000464636')).toBe('558000464636');
});
it('leaves non-toll-free and already-stored numbers untouched', () => {
// Already-stored form routes through normally, so no rewrite here.
expect(normalizeBrazilTollFreeDigits('558000464636')).toBeNull();
expect(normalizeBrazilTollFreeDigits('5585991203123')).toBeNull();
expect(normalizeBrazilTollFreeDigits('551151923057')).toBeNull();
// 0300/0500/0900 share the shape but are not handled here.
expect(normalizeBrazilTollFreeDigits('03001234567')).toBeNull();
});
it('requires lookup only for DDD 31-99 mobile numbers', () => {
expect(needsBrazilWhatsAppLookup('5511987654321')).toBe(false);
expect(needsBrazilWhatsAppLookup('558591203123')).toBe(true);
expect(needsBrazilWhatsAppLookup('558540423147')).toBe(false);
});
it('keeps send heuristic without 9 for DDD 31-99 until lookup resolves', () => {
expect(normalizeBrazilMobileForSendDigits('558591203123')).toBe(
'558591203123',
);
expect(normalizeBrazilMobileForSendDigits('555399034520')).toBe(
'555399034520',
);
});
it('normalizes low DDD mobile numbers for send with 9 digits', () => {
expect(normalizeBrazilMobileForSendDigits('551198765432')).toBe(
'5511998765432',
);
});
it('generates with and without 9 candidates', () => {
expect(generateBrazilMobileLookupCandidates('558591203123')).toEqual([
'558591203123',
'5585991203123',
]);
expect(generateBrazilMobileLookupCandidates('5585991203123')).toEqual([
'558591203123',
'5585991203123',
]);
});
it('extracts digits from chat ids', () => {
expect(extractPhoneDigits('558591203123@c.us')).toBe('558591203123');
expect(extractPhoneDigits('+558591203123')).toBe('558591203123');
});
});
@@ -0,0 +1,178 @@
import {
isJidBroadcast,
isJidGroup,
isJidMetaAI,
isJidNewsletter,
isLidUser,
} from '@waha/core/utils/jids';
const COUNTRY_CODE = '55';
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
const MOBILE_FIRST_DIGIT = /^[6-9]/;
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
// Unverified best-guesses and confirmed-negatives live only in the in-memory
// cache with this short TTL, so a number registered later is re-checked soon.
export const BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS = 10 * 60;
// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits.
const BR_PHONE_MIN_LENGTH = 12;
const BR_PHONE_MAX_LENGTH = 13;
export function isBrazilCountryCode(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE);
}
export function isMalformedBrazilPhone(digits: string): boolean {
if (!isBrazilCountryCode(digits)) {
return false;
}
return (
digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH
);
}
export function extractPhoneDigits(value: string): string {
if (!value) {
return '';
}
const local = value.split('@')[0] ?? value;
return local.split(':')[0].replace(/\D/g, '');
}
export function shouldSkipBrazilPhoneNormalization(chatId: string): boolean {
if (!chatId) {
return true;
}
if (isJidGroup(chatId)) {
return true;
}
if (isJidBroadcast(chatId)) {
return true;
}
if (isLidUser(chatId)) {
return true;
}
if (isJidNewsletter(chatId)) {
return true;
}
if (isJidMetaAI(chatId)) {
return true;
}
if (chatId === 'me') {
return true;
}
return false;
}
export function isBrazilPhone(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE) && digits.length >= 12;
}
export function getBrazilDdd(digits: string): number {
return parseInt(digits.substring(2, 4), 10);
}
export function getBrazilLocalPart(digits: string): string {
return digits.substring(4);
}
export function isBrazilLandline(digits: string): boolean {
if (!isBrazilPhone(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
if (local.length !== 8) {
return false;
}
return LANDLINE_FIRST_DIGIT.test(local);
}
export function isBrazilMobile(digits: string): boolean {
if (!isBrazilPhone(digits) || isBrazilLandline(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
// 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart
// from a landline - 2-5 is genuinely ambiguous and treated as a landline.
if (local.length === 8) {
return MOBILE_FIRST_DIGIT.test(local);
}
// 9-digit local starting with 9: unambiguously mobile. Brazil has no
// 9-digit landline, and the digit after the leading 9 is unrestricted -
// e.g. SP 11 9535-23741. Do not test it.
if (local.length === 9 && local[0] === '9') {
return true;
}
return false;
}
// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus
// 7 subscriber digits. WhatsApp stores them under country code 55 with the
// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed
// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp
// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free
// number in a dialed form (the stored form '55800...' already routes untouched).
const BR_TOLLFREE_DIALED = /^0800\d{7}$/;
const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/;
export function normalizeBrazilTollFreeDigits(digits: string): string | null {
if (BR_TOLLFREE_DIALED.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(1)}`;
}
if (BR_TOLLFREE_DIALED_CC.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(3)}`;
}
return null;
}
export function needsBrazilWhatsAppLookup(digits: string): boolean {
if (!isBrazilMobile(digits)) {
return false;
}
const ddd = getBrazilDdd(digits);
return ddd >= BR_PHONE_DDD_LOOKUP_MIN && ddd <= BR_PHONE_DDD_LOOKUP_MAX;
}
export function normalizeBrazilMobileForSendDigits(digits: string): string {
if (!isBrazilMobile(digits)) {
return digits;
}
const ddd = getBrazilDdd(digits);
if (ddd >= BR_PHONE_DDD_LOOKUP_MIN) {
return digits;
}
const local = getBrazilLocalPart(digits);
if (local.length === 8 && MOBILE_FIRST_DIGIT.test(local)) {
return `${COUNTRY_CODE}${ddd}9${local}`;
}
return digits;
}
export function generateBrazilMobileLookupCandidates(digits: string): string[] {
if (!isBrazilMobile(digits)) {
return [digits];
}
const ddd = digits.substring(2, 4);
const local = getBrazilLocalPart(digits);
let without9 = digits;
let with9 = digits;
if (local.length === 9 && local[0] === '9') {
without9 = `${COUNTRY_CODE}${ddd}${local.substring(1)}`;
with9 = `${COUNTRY_CODE}${ddd}${local}`;
} else if (local.length === 8) {
without9 = `${COUNTRY_CODE}${ddd}${local}`;
with9 = `${COUNTRY_CODE}${ddd}9${local}`;
}
const candidates = [without9, with9];
return [...new Set(candidates)];
}
export function getBrazilPhoneCacheKeys(digits: string): string[] {
const candidates = generateBrazilMobileLookupCandidates(digits);
return [...new Set([digits, ...candidates])];
}
+26
View File
@@ -0,0 +1,26 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
const CallsAppModule: AppModule = {
name: AppName.calls,
openapi: {
title: 'Calls',
description: 'Handle incoming calls - reject, message back',
},
definition: {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [],
providers: [CallsAppService],
},
Service: CallsAppService,
};
export default CallsAppModule;
-7
View File
@@ -1,7 +0,0 @@
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
export const CallsAppExports = {
providers: [CallsAppService],
imports: [],
controllers: [],
};
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { Type } from 'class-transformer';
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
+18 -9
View File
@@ -1,19 +1,14 @@
import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { CallsPlugin } from '@waha/apps/calls/services/CallsPlugin';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@Injectable()
export class CallsAppService implements IAppService {
constructor(
@InjectPinoLogger('CallsAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<CallsAppConfig>): void {
// The DTO validation covers structure; no extra validation rules.
void app;
@@ -79,6 +74,14 @@ export class CallsAppService implements IAppService {
void app;
}
async purge(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<CallsAppConfig>,
@@ -87,9 +90,15 @@ export class CallsAppService implements IAppService {
void app;
}
plugins(app: App<CallsAppConfig>, session: WhatsappSession): PluginOptions[] {
void session;
return [CallsPlugin.with(app.config, null)];
}
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
const listener = new CallsListener(app, session, this.logger);
listener.attach();
void app;
void session;
return;
}
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
@@ -1,62 +1,29 @@
import { Subscription } from 'rxjs';
import {
CallsAppChannelConfig,
CallsAppConfig,
} from '@waha/apps/calls/dto/config.dto';
import { Logger } from 'pino';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { PinoLogger } from 'nestjs-pino';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginEvent } from '@waha/core/abc/session.plugin.events';
import { CallData } from '@waha/structures/calls.dto';
import { MessageTextRequest } from '@waha/structures/chatting.dto';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Observable } from 'rxjs';
export class CallsListener {
private subscription?: Subscription;
private config: CallsAppConfig;
private readonly log: Logger;
private readonly session: WhatsappSession;
constructor(
app: App<CallsAppConfig>,
session: WhatsappSession,
logger: PinoLogger,
) {
this.session = session;
this.config = app.config;
this.log = logger.logger.child({
app: 'calls',
session: app.session,
});
}
attach(): void {
this.detach();
const observable = this.session.getEventObservable(
WAHAEvents.CALL_RECEIVED,
);
if (!observable) {
this.log.warn('CALL_RECEIVED event stream is not available, skipping');
return;
}
this.subscription = observable.subscribe((payload) => {
this.handleCall(payload as CallData).catch((error) => {
this.log.error(
{ err: error, callId: (payload as any)?.id },
/**
* Rejects incoming calls and optionally sends an auto-reply message.
*/
export class CallsPlugin extends SessionPlugin<CallsAppConfig> {
@PluginEvent(WAHAEvents.CALL_RECEIVED)
onCallReceived(calls$: Observable<CallData>) {
calls$.subscribe((call: CallData) => {
this.handleCall(call).catch((error) => {
this.logger.error(
{ err: error, callId: call?.id },
'Failed to handle incoming call',
);
});
});
this.log.info('Calls app listener is attached');
}
detach(): void {
this.subscription?.unsubscribe();
this.subscription = undefined;
}
private configFor(call: CallData): CallsAppChannelConfig {
@@ -65,17 +32,17 @@ export class CallsListener {
private async handleCall(call: CallData): Promise<void> {
if (!call.from) {
this.log.warn({ call: call?.id }, 'Incoming call has no chat id');
this.logger.warn({ call: call?.id }, 'Incoming call has no chat id');
return;
}
if (!call.id) {
this.log.warn({ from: call.from }, 'Incoming call has no from');
this.logger.warn({ from: call.from }, 'Incoming call has no from');
return;
}
const config = this.configFor(call);
if (!config) {
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
this.logger.warn({ callId: call.id }, 'No calls config found, skipping');
return;
}
@@ -84,7 +51,7 @@ export class CallsListener {
const shouldMessage = message.length > 0;
if (!shouldReject && !shouldMessage) {
this.log.debug(
this.logger.debug(
{ callId: call.id, chatId: call.from },
'No actions configured for this call',
);
@@ -105,16 +72,19 @@ export class CallsListener {
}
private async rejectCall(call: CallData): Promise<void> {
this.log.debug({ from: call.from, id: call.id }, 'Rejecting incoming call');
this.logger.debug(
{ from: call.from, id: call.id },
'Rejecting incoming call',
);
await this.session.rejectCall(call.from, call.id);
this.log.info({ from: call.from, id: call.id }, 'Call rejected');
this.logger.info({ from: call.from, id: call.id }, 'Call rejected');
}
private async replyWithTyping(
chatId: string,
message: string,
): Promise<void> {
this.log.info(
this.logger.info(
{ chatId: chatId },
'Sending auto-response for rejected call',
);
@@ -132,7 +102,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to set typing presence before reply',
);
@@ -146,7 +116,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to clear typing presence after reply',
);
@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { sortBy } from 'lodash';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import { i18n } from '@waha/apps/chatwoot/i18n';
interface LanguageResponse {
@@ -10,7 +10,6 @@ interface LanguageResponse {
@Controller('api/apps/chatwoot')
@ApiSecurity('api_key')
@ApiTags('🧩 Apps')
export class ChatwootLocalesController {
@Get('locales')
@ApiOperation({
@@ -5,7 +5,7 @@ import {
Param,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation } from '@nestjs/swagger';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
@@ -15,7 +15,6 @@ import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
@Controller('webhooks/chatwoot/')
@ApiTags('🧩 Apps')
export class ChatwootWebhookController {
constructor(
private readonly chatWootQueueService: ChatWootQueueService,
@@ -1,3 +1,5 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
import {
ExponentialRetriesJobOptions,
@@ -37,10 +39,15 @@ import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/con
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
import { BullModule } from '@nestjs/bullmq';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
const IMPORTS = lodash.flatten([
HttpPathsRegistration({
prefix: '/webhooks/',
include: { authBasic: false },
}),
BullModule.registerFlowProducer({
name: FlowProducerName.MESSAGES_PULL_FLOW,
}),
@@ -160,8 +167,25 @@ const PROVIDERS = [
QueueManager,
];
export const ChatWootExports = {
providers: PROVIDERS,
imports: IMPORTS,
controllers: CONTROLLERS,
const ChatWootAppModule: AppModule = {
name: AppName.chatwoot,
openapi: {
title: 'Chatwoot',
description: 'Chatwoot integration',
},
definition: {
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: IMPORTS,
controllers: CONTROLLERS,
providers: PROVIDERS,
},
Service: ChatWootAppService,
};
export default ChatWootAppModule;
@@ -24,6 +24,21 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
/**
* Linked id - @lid, null when unknown
*/
LidId(): Promise<string | null>;
/**
* Phone number jid - @c.us, null when unknown
*/
JidId(): Promise<string | null>;
/**
* Resolved E.164 phone number, null when not applicable
*/
PhoneNumberE164(): Promise<string | null>;
AvatarUrl(): Promise<string | null>;
Attributes(): Promise<any>;
+93 -57
View File
@@ -40,6 +40,18 @@ export function sanitizeName(name: string) {
return clean.slice(0, 255).trim();
}
function SearchClauseEqualTo(key: string, values: string[]) {
// equal_to with multiple values acts as IN
return {
attribute_key: key,
filter_operator: 'equal_to',
values: values,
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
};
}
export class ContactService {
constructor(
private config: ChatWootAPIConfig,
@@ -52,8 +64,11 @@ export class ContactService {
contactInfo: ContactInfo,
): Promise<[ContactResponse, boolean]> {
const chatId = contactInfo.ChatId();
let contact = await this.searchByAnyID(chatId);
const lid = await contactInfo.LidId();
const jid = await contactInfo.JidId();
let contact = await this.search(chatId, lid, jid);
if (contact) {
await this.upsertPhoneNumber(contact, contactInfo);
return [contact, false];
}
@@ -62,73 +77,94 @@ export class ContactService {
return [contact, true];
}
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
const payload: any[] = [
{
attribute_key: AttributeKey.WA_CHAT_ID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: AttributeKey.WA_JID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: AttributeKey.WA_LID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
attribute_key: 'identifier',
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
},
];
if (isJidCusFormat(chatId)) {
// Search by phone
const phoneNumberE164 = E164Parser.fromJid(chatId);
const phone_number = phoneNumberE164.replace('+', '');
payload[payload.length - 1].query_operator = 'OR';
payload.push({
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phone_number],
});
async search(
chatId: string,
lid: string | null,
jid: string | null,
): Promise<ContactResponse | null> {
// The chat id attribute holds the latest used address, so match any known form there
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
if (chatIds.length == 0) {
return null;
}
const payload: any[] = [
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
SearchClauseEqualTo('identifier', chatIds),
];
if (jid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
}
if (lid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
}
if (jid && isJidCusFormat(jid)) {
// Search by phone - both with and without the leading '+'
const phoneNumberE164 = E164Parser.fromJid(jid);
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
payload.push(SearchClauseEqualTo('phone_number', phones));
}
// The terminal clause must have no query_operator
delete payload[payload.length - 1].query_operator;
const response: any = await this.accountAPI.contacts.filter({
accountId: this.config.accountId,
payload: payload as any,
});
const contacts = response.payload;
if (contacts.length == 0) {
const candidates: ContactResponse[] = [];
for (const contact of contacts) {
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
continue;
}
candidates.push({
data: contact,
sourceId: inboxes[0].source_id,
});
}
if (candidates.length == 0) {
return null;
}
const contact = contacts[0];
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
return null;
// Prefer a contact with a phone number over a phone-less duplicate
const withPhone = candidates.find((candidate) =>
Boolean(candidate.data.phone_number),
);
return withPhone ?? candidates[0];
}
private async upsertPhoneNumber(
contact: ContactResponse,
contactInfo: ContactInfo,
): Promise<void> {
if (contact.data.phone_number) {
return;
}
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
if (!phoneNumberE164) {
return;
}
try {
await this.accountAPI.contacts.update({
id: contact.data.id,
accountId: this.config.accountId,
data: { phone_number: phoneNumberE164 },
});
contact.data.phone_number = phoneNumberE164;
this.logger.info(
`Set phone_number for contact.id: ${
contact.data.id
}, chat.id: ${contactInfo.ChatId()}`,
);
} catch (err) {
// Chatwoot returns 422 when another contact already owns the phone number
this.logger.warn(
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
);
}
return {
data: contact,
sourceId: inboxes[0].source_id,
};
}
public async upsertCustomAttributes(
+1 -1
View File
@@ -1,7 +1,7 @@
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
import * as lodash from 'lodash';
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
import { buildMessageId } from '@waha/core/engines/noweb/utils';
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
export function GetJID(contact: any): string | null {
@@ -221,10 +221,23 @@ export class MessageHandler {
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
await session.startTyping({ chatId: chatId, session: '' });
// Best effort - do not block sending on read-receipt failure
await session.readMessages(chatId).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error reading messages in chat '${chatId}': ${err}`,
);
});
await session.startTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error starting typing in chat '${chatId}': ${err}`,
);
});
await sleep(2000);
await session.stopTyping({ chatId: chatId, session: '' });
await session.stopTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error stopping typing in chat '${chatId}': ${err}`,
);
});
const msg = await session.sendText(request);
return msg;
}
+13
View File
@@ -0,0 +1,13 @@
export function clearContent(attachments) {
/**
* Remove actual base64 "content" from attachment
*/
if (!attachments) {
return attachments;
}
return attachments.map((attachment) => {
attachment = { ...attachment };
attachment.content = '';
return attachment;
});
}
+4
View File
@@ -39,6 +39,7 @@ import {
ChatWootAppConfig,
ChatWootConfig,
} from '@waha/apps/chatwoot/dto/config.dto';
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
@@ -319,6 +320,7 @@ export abstract class MessageBaseHandler<
this.session,
EngineHelper.ChatID(payload as any),
this.l,
EngineHelper.PhoneNumber(payload as any),
);
const conversation = await this.repo.ConversationByContact(contactInfo);
this.info.onConversationId(conversation.conversationId);
@@ -329,6 +331,8 @@ export abstract class MessageBaseHandler<
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
);
await this.saveMapping(response, payload);
// Clear attachments content to avoid saving it in the task result
message.attachments = clearContent(message.attachments);
return message;
}
@@ -14,6 +14,18 @@ export class InboxContactInfo implements ContactInfo {
return INBOX_CONTACT_CHAT_ID;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
async AvatarUrl() {
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
}
@@ -32,6 +32,18 @@ abstract class ChatContactInfo implements ContactInfo {
return this.chatId;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
abstract AvatarUrl(): Promise<string | null>;
abstract Attributes(): Promise<any>;
@@ -53,6 +65,19 @@ class JidContactInfo extends ChatContactInfo {
return await this.session.findLIDByPN(this.chatId);
}
async LidId(): Promise<string | null> {
const lid = await this.fetchLid().catch(() => null);
return lid || null;
}
async JidId(): Promise<string | null> {
return this.chatId;
}
async PhoneNumberE164(): Promise<string | null> {
return E164Parser.fromJid(this.chatId);
}
@CacheAsync()
async Attributes() {
const attributes = {
@@ -67,7 +92,9 @@ class JidContactInfo extends ChatContactInfo {
}
async PublicContactCreate(): Promise<Contact> {
const contact: any = await this.session.getContact(this.chatId);
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
@@ -89,15 +116,44 @@ class JidContactInfo extends ChatContactInfo {
* LID contact info
*/
class LidContactInfo extends ChatContactInfo {
constructor(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
private pn: string | null = null,
) {
super(session, chatId, locale);
}
@CacheAsync()
async jid() {
const pn = await this.session.findPNByLid(this.chatId);
let pn = this.pn;
if (!pn) {
pn = await this.session.findPNByLid(this.chatId);
}
if (!pn) {
return null;
}
return new JidContactInfo(this.session, pn, this.locale);
}
async LidId(): Promise<string | null> {
return this.chatId;
}
async JidId(): Promise<string | null> {
const jid = await this.jid();
return jid?.ChatId() ?? null;
}
async PhoneNumberE164(): Promise<string | null> {
const jid = await this.jid();
if (!jid) {
return null;
}
return await jid.PhoneNumberE164();
}
async AvatarUrl(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
@@ -123,10 +179,15 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
result = await jid.PublicContactCreate();
} else {
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
result = {
inbox_id: 0,
identifier: this.chatId,
name: this.chatId,
name: name,
};
}
result.custom_attributes = await this.Attributes();
@@ -261,6 +322,7 @@ export function WhatsAppContactInfo(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
pn: string | null = null,
): ContactInfo {
if (isJidGroup(chatId)) {
return new GroupContactInfo(session, chatId, locale);
@@ -271,7 +333,7 @@ export function WhatsAppContactInfo(
} else if (isJidBroadcast(chatId)) {
return new BroadcastContactInfo(session, chatId, locale);
} else if (isLidUser(chatId)) {
return new LidContactInfo(session, normalizeJid(chatId), locale);
return new LidContactInfo(session, normalizeJid(chatId), locale, pn);
} else if (isPnUser(chatId)) {
return new JidContactInfo(session, chatId, locale);
} else {
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { Type } from 'class-transformer';
export class ChatWootAppDto extends App<ChatWootAppConfig> {
@Type(() => ChatWootAppConfig)
config: ChatWootAppConfig;
}
@@ -1,11 +1,13 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { CacheForConfig } from '@waha/apps/chatwoot/cache/ConversationCache';
import { CHATWOOT_CUSTOM_ATTRIBUTES } from '@waha/apps/chatwoot/const';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
import { App } from '@waha/apps/chatwoot/storage';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@@ -111,6 +113,17 @@ export class ChatWootAppService implements IAppService {
void app;
}
async purge(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
const appDb = app as unknown as AppDB;
const knex = manager.store.getWAHADatabase();
const di = new DIContainer(appDb.pk, app.config, this.logger, knex);
await di.MessageMappingService().purge();
this.cleanCache(app);
}
async enrich(
manager: SessionManager,
app: App<ChatWootAppConfig>,
@@ -152,6 +165,15 @@ export class ChatWootAppService implements IAppService {
await conversation.incoming(updated);
}
plugins(
app: App<ChatWootAppConfig>,
session: WhatsappSession,
): PluginOptions[] {
void app;
void session;
return [];
}
beforeSessionStart(app: App<ChatWootAppConfig>, session: WhatsappSession) {
this.chatWootWAHAQueueService.listenEvents(app, session);
}
@@ -69,4 +69,11 @@ export class ChatwootMessageRepository {
.del();
return result;
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
@@ -90,4 +90,11 @@ export class MessageMappingRepository {
.orderBy('id', 'desc')
.first();
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
@@ -44,6 +44,26 @@ export class MessageMappingService {
}
}
/**
* Deletes all stored messages and mappings for the app.
*/
async purge(): Promise<number> {
const trx = await this.knex.transaction();
try {
const mappings =
await this.messageMappingRepository.deleteAllWithTrx(trx);
const chatwoot =
await this.chatwootMessageRepository.deleteAllWithTrx(trx);
const whatsapp =
await this.whatsAppMessageRepository.deleteAllWithTrx(trx);
await trx.commit();
return mappings + chatwoot + whatsapp;
} catch (e) {
await trx.rollback();
throw e;
}
}
async map(
chatwoot: ChatwootMessage,
whatsapp: WhatsAppMessage,
@@ -69,4 +69,11 @@ export class WhatsAppMessageRepository {
.del();
return result;
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
+57 -1
View File
@@ -5,7 +5,12 @@ import { WAHAEngine } from '@waha/structures/enums.dto';
import { getEngineName } from '@waha/version';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { Jid } from '@waha/core/engines/const';
import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids';
import {
isLidUser,
isPnUser,
jidsFromKey,
toCusFormat,
} from '@waha/core/utils/jids';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { WAMessage } from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
@@ -22,6 +27,12 @@ export interface QuotedMedia {
interface IEngineHelper {
ChatID(message: WAMessage | any): string;
/**
* Phone number chat id (@c.us) of the DM contact extracted from the message payload
* (alt jid fields), when the engine provides it. Null otherwise.
*/
PhoneNumber(message: WAMessage | any): string | null;
CallChatID(call: CallData | any): string;
IsReplyToStatus(
@@ -49,6 +60,15 @@ class NOWEBHelper implements IEngineHelper {
return message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
const key = message._data?.key;
if (!key) {
return null;
}
const jids = jidsFromKey(key);
return pnChatIdOrNull(jids?.pn);
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -100,6 +120,21 @@ class GOWSHelper implements IEngineHelper {
return message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
const info = message._data?.Info;
if (!info) {
return null;
}
// SenderAlt / RecipientAlt carry the phone number for @lid-addressed chats
let pn: string | null;
if (info.IsFromMe) {
pn = info.RecipientAlt || null;
} else {
pn = info.SenderAlt || null;
}
return pnChatIdOrNull(pn);
}
CallChatID(call: CallData): string {
return call._data?.CallCreator || call.from;
}
@@ -156,6 +191,11 @@ class WEBJSHelper implements IEngineHelper {
return message._data?.id?.remote || message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
void message;
return null;
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -222,6 +262,11 @@ class WPPHelper implements IEngineHelper {
return toCusFormat(parseMessageIdSerialized(message.id as any).remoteJid);
}
PhoneNumber(message: WAMessage | any): string | null {
void message;
return null;
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -367,6 +412,17 @@ function extractBrowserQuotedMedia(replyData: any): QuotedMedia | null {
};
}
function pnChatIdOrNull(pn: string | null | undefined): string | null {
if (!pn) {
return null;
}
const chatId = toCusFormat(pn);
if (isPnUser(chatId)) {
return chatId;
}
return null;
}
function preferPnChats(chats: string[]): string[] {
const unique = lodash.uniq(chats ?? []);
const hasPn = unique.some(isPnUser);
-2
View File
@@ -1,10 +1,8 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { McpService } from '../mcp.service';
@Controller('mcp')
@ApiTags('🧩 Apps')
export class McpController {
constructor(private readonly mcp: McpService) {}
+34
View File
@@ -0,0 +1,34 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const McpAppModule: AppModule = {
name: AppName.mcp,
openapi: {
title: 'MCP',
description: 'Model Context Protocol (MCP) server for AI clients',
},
definition: {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
unique: false,
},
nestjs: {
imports: [
HttpPathsRegistration(
{ prefix: '/mcp', include: { authBasic: false } },
{ prefix: 'mcp', include: { authApiKey: true } },
),
],
controllers: [McpController],
providers: [McpService, McpAppService],
},
Service: McpAppService,
};
export default McpAppModule;
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
-9
View File
@@ -1,9 +0,0 @@
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
export const McpModuleExports = {
imports: [],
controllers: [McpController],
providers: [McpService, McpAppService],
};
+2
View File
@@ -8,6 +8,7 @@ import { SessionTools } from '@waha/apps/mcp/tools/sessions.tools';
import { ChatTools } from '@waha/apps/mcp/tools/chats.tools';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { ApiTools } from '@waha/apps/mcp/tools/api.tools';
import { AppsTools } from '@waha/apps/mcp/tools/apps.tools';
import { CallTools } from '@waha/apps/mcp/tools/calls.tools';
import { ChannelTools } from '@waha/apps/mcp/tools/channels.tools';
import { ContactTools } from '@waha/apps/mcp/tools/contacts.tools';
@@ -44,6 +45,7 @@ export class McpService {
new StatusTools(api),
new ServerTools(api),
new KeysTools(api),
new AppsTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
+12
View File
@@ -1,6 +1,7 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
@@ -102,6 +103,11 @@ export class McpAppService implements IAppService {
await this.deleteKey(manager, app);
}
async purge(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
void manager;
void app;
}
async enrich(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
@@ -114,6 +120,12 @@ export class McpAppService implements IAppService {
app.config = { ...(app.config ?? {}), key: keyDto.key } as McpAppConfig;
}
plugins(app: App<McpAppConfig>, session: WhatsappSession): PluginOptions[] {
void app;
void session;
return [];
}
beforeSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
+30
View File
@@ -0,0 +1,30 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { AppPurgeInput } from '@waha/apps/mcp/tools/apps.zod';
import { z } from 'zod';
export class AppsTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('apps-purge', {
title: 'Purge app storage',
description:
"Purge an app's stored data (messages, caches) for a session. " +
'The app itself stays configured and keeps working.',
inputSchema: AppPurgeInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
})
async purge({ app, session }: z.infer<typeof AppPurgeInput>) {
return this.textRequest({
method: 'POST',
url: `/api/apps/${app}/${session}/purge`,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { z } from 'zod';
export const AppPurgeInput = z.object({
app: z.nativeEnum(AppName).describe('App name'),
session: z.string().describe('Session name'),
});
+4 -1
View File
@@ -107,7 +107,10 @@ export class ChannelTools extends McpController {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}/messages/preview`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
}
+10 -3
View File
@@ -145,7 +145,8 @@ export class ChatTools extends McpController {
description:
'Get messages in a chat. ' +
'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' +
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.',
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here. ' +
'Use downloadMediaMimetypes to download only media with the given mimetypes (prefix match).',
inputSchema: ChatMessagesInput,
annotations: {
readOnlyHint: true,
@@ -162,7 +163,10 @@ export class ChatTools extends McpController {
const response = await this.request({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
let messages = response.data;
if (!_data && Array.isArray(messages)) {
@@ -229,7 +233,10 @@ export class ChatTools extends McpController {
const result = await this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
if (query.downloadMedia) {
const mediaKey = await this.mediaApiKey(session);
+103
View File
@@ -8,6 +8,7 @@ import {
GroupDescriptionInput,
GroupIdInput,
GroupJoinInput,
GroupMembershipApprovalInput,
GroupParticipantsInput,
GroupPictureInput,
GroupsListInput,
@@ -363,6 +364,108 @@ export class GroupTools extends McpController {
});
}
@Tool('groups-get-membership-approval', {
title: 'Get membership approval setting',
description:
'Get whether admin approval is required for users requesting to join the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMembershipApproval({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/membership-approval`,
});
}
@Tool('groups-set-membership-approval', {
title: 'Set membership approval setting',
description:
'Enable or disable admin approval for users requesting to join the group',
inputSchema: GroupMembershipApprovalInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setMembershipApproval({
session,
id,
...body
}: z.infer<typeof GroupMembershipApprovalInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/membership-approval`,
data: body,
});
}
@Tool('groups-get-join-requests', {
title: 'Get pending requests to join the group',
description: 'Get pending requests to join the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getJoinRequests({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/participants/join-requests`,
});
}
@Tool('groups-approve-join-requests', {
title: 'Approve pending requests to join the group',
description: 'Approve pending requests to join the group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async approveJoinRequests({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/join-requests/approve`,
data: body,
});
}
@Tool('groups-reject-join-requests', {
title: 'Reject pending requests to join the group',
description: 'Reject pending requests to join the group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async rejectJoinRequests({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/join-requests/reject`,
data: body,
});
}
@Tool('groups-get-invite-code', {
title: 'Get group invite code',
description: 'Get the invite code for a group',
+8
View File
@@ -7,6 +7,7 @@ import {
GroupsPaginationParams,
JoinGroupRequest,
ParticipantsRequest,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '@waha/structures/groups.dto';
@@ -64,3 +65,10 @@ export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupMembershipApprovalInput = DtoToZod(
SettingsMembershipApproval,
).extend({
session: SessionField,
id: GroupIdField,
});
+21
View File
@@ -17,6 +17,7 @@ import {
SendPollInput,
SendPollVoteInput,
SendSeenInput,
SendStickerInput,
SendTextInput,
SendVideoInput,
SendVoiceInput,
@@ -126,6 +127,26 @@ export class SendTools extends McpController {
});
}
@Tool('send-sticker', {
title: 'Send a sticker',
description:
'Send a sticker to a chat. The file must already be in WebP format. ' +
FileNote,
inputSchema: SendStickerInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendSticker(data: z.infer<typeof SendStickerInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendSticker',
data: data,
});
}
@Tool('send-link-custom-preview', {
title: 'Send a text message with a custom link preview',
description:
+2
View File
@@ -13,6 +13,7 @@ import {
MessagePollVoteRequest,
MessageReactionRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
@@ -27,6 +28,7 @@ export const SendImageInput = DtoToZod(MessageImageRequest);
export const SendFileInput = DtoToZod(MessageFileRequest);
export const SendVoiceInput = DtoToZod(MessageVoiceRequest);
export const SendVideoInput = DtoToZod(MessageVideoRequest);
export const SendStickerInput = DtoToZod(MessageStickerRequest);
export const SendLinkCustomPreviewInput = DtoToZod(
MessageLinkCustomPreviewRequest,
);
+43 -3
View File
@@ -5,6 +5,7 @@ import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
SessionCreateInput,
SessionListInput,
SessionLogoutInput,
SessionNameInput,
SessionUpdateInput,
} from '@waha/apps/mcp/tools/sessions.zod';
@@ -140,18 +141,20 @@ export class SessionTools extends McpController {
@Tool('sessions-logout', {
title: 'Logout session',
description: 'Logout from a WhatsApp session',
inputSchema: SessionNameInput,
description:
'Logout from a WhatsApp session. Optionally purge app storage via apps.purge.',
inputSchema: SessionLogoutInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async logout({ session }: z.infer<typeof SessionNameInput>) {
async logout({ session, ...body }: z.infer<typeof SessionLogoutInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/logout`,
data: body,
});
}
@@ -171,4 +174,41 @@ export class SessionTools extends McpController {
url: `/api/sessions/${session}/restart`,
});
}
@Tool('sessions-capping', {
title: 'Get message capping',
description:
'Fetch the account new-chat message capping (per-cycle quota).',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async capping({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}/capping`,
});
}
@Tool('sessions-timelock', {
title: 'Get reachout timelock',
description:
'Fetch the account reachout timelock state ' +
'(the restriction behind error 463 when messaging new contacts).',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async timelock({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}/timelock`,
});
}
}
+5
View File
@@ -4,6 +4,7 @@ import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ListSessionsQuery,
SessionCreateRequest,
SessionLogoutRequest,
SessionUpdateRequest,
} from '@waha/structures/sessions.dto';
@@ -18,3 +19,7 @@ export const SessionCreateInput = DtoToZod(SessionCreateRequest);
export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({
session: z.string().describe('Session name'),
});
export const SessionLogoutInput = DtoToZod(SessionLogoutRequest).extend({
session: z.string().describe('Session name'),
});
+46 -26
View File
@@ -1,20 +1,18 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GlobalWebhookConfigConfig } from '@waha/core/config/GlobalWebhookConfig';
import { MediaDownloadOptions } from '@waha/core/media/IMediaManager';
import { IgnoreJidConfig } from '@waha/core/utils/jids';
import * as lodash from 'lodash';
import { parseBool } from './helpers';
import { WebhookConfig } from './structures/webhooks.config.dto';
import { Auth } from '@waha/core/auth/config';
@Injectable()
export class WhatsappConfigService implements OnApplicationBootstrap {
export class WhatsappConfigService {
private logger: Logger;
private webhookConfig: GlobalWebhookConfigConfig;
constructor(private configService: ConfigService) {
this.logger = new Logger('WhatsappConfigService');
this.webhookConfig = new GlobalWebhookConfigConfig(configService);
}
get schema() {
@@ -63,17 +61,50 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
}
}
get mimetypes(): string[] {
if (!this.shouldDownloadMedia) {
return ['mimetype/ignore-all-media'];
}
const types = this.configService.get('WHATSAPP_FILES_MIMETYPES', '');
return types ? types.split(',') : [];
private get mediaGlobalParams() {
return {
download: this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true'),
mimetypes: this.configService.get('WHATSAPP_FILES_MIMETYPES', ''),
};
}
get shouldDownloadMedia(): boolean {
const value = this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true');
return parseBool(value);
private get mediaApiConfig(): MediaDownloadOptions {
const params = {
download: this.configService.get('WAHA_API_DOWNLOAD_MEDIA'),
mimetypes: this.configService.get('WAHA_API_DOWNLOAD_MEDIA_MIMETYPES'),
};
const config = lodash.defaults({}, params, this.mediaGlobalParams);
return this.parseMediaDownloadOptions(config);
}
private get mediaEventsConfig(): MediaDownloadOptions {
const params = {
download: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA'),
mimetypes: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES'),
};
const config = lodash.defaults({}, params, this.mediaGlobalParams);
return this.parseMediaDownloadOptions(config);
}
private parseMediaDownloadOptions(config: {
download: string;
mimetypes: string;
}): MediaDownloadOptions {
const types = config.mimetypes;
const mimetypes = types
? types.split(',').map((type: string) => type.trim())
: [];
return {
download: parseBool(config.download),
mimetypes: mimetypes,
};
}
get mediaConfig() {
return {
api: this.mediaApiConfig,
events: this.mediaEventsConfig,
};
}
get startSessions(): string[] {
@@ -120,10 +151,6 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
return this.configService.get('WHATSAPP_PROXY_SERVER_PASSWORD', undefined);
}
getWebhookConfig(): WebhookConfig | undefined {
return this.webhookConfig.config;
}
getSessionMongoUrl(): string | undefined {
return this.configService.get('WHATSAPP_SESSIONS_MONGO_URL', undefined);
}
@@ -201,11 +228,4 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
);
return { status, groups, channels, broadcast };
}
onApplicationBootstrap() {
const error = this.webhookConfig.validateConfig();
if (error) {
throw new Error(`Invalid global webhook config:\n${error}\n`);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { MessageCappingData } from '@waha/structures/sessions.dto';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Observable, Subject } from 'rxjs';
/**
* WhatsApp new-chat message capping (per-cycle quota).
* The volume counterpart of the reachout timelock: it caps how many new chats
* the account may start per cycle. Keeps the latest known state and emits
* changes. Unlike the timelock there is no client-side expiry - the cycle
* resets on the server and the state is refreshed by re-fetching.
*/
export class MessageCappingTracker {
private capping: MessageCappingData | null = null;
private changes: Subject<MessageCappingData | null> = new Subject();
constructor(private logger: Logger) {}
get value(): MessageCappingData | null {
return this.capping;
}
get changes$(): Observable<MessageCappingData | null> {
return this.changes;
}
update(capping: MessageCappingData | null) {
if (lodash.isEqual(this.capping, capping)) {
return;
}
this.apply(capping);
}
private apply(capping: MessageCappingData | null) {
this.capping = capping;
this.logger.info({ messageCapping: capping }, 'Message capping updated');
this.changes.next(capping);
}
}
+68
View File
@@ -0,0 +1,68 @@
import { ReachoutTimelockData } from '@waha/structures/sessions.dto';
import { LongTimeout, setLongTimeout } from '@waha/utils/promiseTimeout';
import { EnsureMilliseconds } from '@waha/utils/timehelper';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Observable, Subject } from 'rxjs';
/**
* WhatsApp "reachout timelock"
* The account is restricted from messaging new contacts for a period of time (the cause of 463 errors on send).
* Keeps the latest known state, expires it when the enforcement ends and emits changes.
*/
export class ReachoutTimelockTracker {
private timelock: ReachoutTimelockData | null = null;
private timeout?: LongTimeout;
private changes: Subject<ReachoutTimelockData | null> = new Subject();
constructor(private logger: Logger) {}
get value(): ReachoutTimelockData | null {
return this.timelock;
}
get changes$(): Observable<ReachoutTimelockData | null> {
return this.changes;
}
update(timelock: ReachoutTimelockData | null) {
if (timelock && !timelock.isActive && !this.timelock) {
// No enforcement has been seen for the account - keep it null instead of storing an inactive record
// (startup fetch on a clean account lands here)
return;
}
if (lodash.isEqual(this.timelock, timelock)) {
return;
}
this.timeout?.clear();
if (timelock?.isActive && timelock.timeEnforcementEnds) {
const endsAtMs = EnsureMilliseconds(timelock.timeEnforcementEnds);
const delay = Math.max(endsAtMs - Date.now(), 0);
this.timeout = setLongTimeout(() => this.expire(), delay).unref();
}
this.apply(timelock);
}
/**
* Stop the expiry timer, keeping the last known state.
*/
stop() {
this.timeout?.clear();
}
private expire() {
if (!this.timelock?.isActive) {
return;
}
this.apply({ ...this.timelock, isActive: false });
}
private apply(timelock: ReachoutTimelockData | null) {
this.timelock = timelock;
this.logger.info(
{ reachoutTimelock: timelock },
'Reachout timelock updated',
);
this.changes.next(timelock);
}
}
+33
View File
@@ -0,0 +1,33 @@
import {
MessageCappingData,
MessageCappingStatus,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
function parseCappingTimestamp(value: any): number | null {
if (!value) {
return null;
}
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : EnsureSeconds(parsed);
}
/**
* Normalize the message capping payload (xwa2_message_capping_info) - the shape is the same
* across engines: GOWS gRPC event, NOWEB Baileys fetch/push, WEBJS injected fetch/local read.
*/
export function parseMessageCapping(data: any): MessageCappingData {
const cappingStatus = (data?.capping_status ??
MessageCappingStatus.NONE) as MessageCappingStatus;
return {
cappingStatus: cappingStatus,
// -1 total means "no cap"
totalQuota: typeof data?.total_quota === 'number' ? data.total_quota : -1,
usedQuota: typeof data?.used_quota === 'number' ? data.used_quota : 0,
// cycle timestamps are unix seconds strings (or ms numbers from WEBJS local reads)
cycleStart: parseCappingTimestamp(data?.cycle_start_timestamp),
cycleEnd: parseCappingTimestamp(data?.cycle_end_timestamp),
mvStatus: data?.mv_status ?? null,
oteStatus: data?.ote_status ?? null,
};
}
+163
View File
@@ -0,0 +1,163 @@
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
import {
MeInfo,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { WASessionStatusBody } from '@waha/structures/webhooks.dto';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {
public getSessionMeInfo(): MeInfo | null {
// pushName and id must be set so the SESSION_STATUS pipeline does not delay WORKING statuses
return { id: '123@c.us', pushName: 'Test' };
}
public setStatusPublic(status: WAHASessionStatus) {
this.setStatus(status);
}
public updateReachoutTimelockPublic(timelock: ReachoutTimelockData | null) {
this.reachoutTimelock.update(timelock);
}
}
function buildSession(): TestSession {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
const ACTIVE_TIMELOCK: ReachoutTimelockData = {
enforcementType: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
isActive: true,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 3600,
};
describe('WhatsappSession reachout timelock', () => {
let session: TestSession;
let statuses: WASessionStatusBody[];
beforeEach(() => {
session = buildSession();
statuses = [];
session
.getEventObservable(WAHAEvents.SESSION_STATUS)
.subscribe((body: WASessionStatusBody) => statuses.push(body));
});
it('re-issues WORKING with data when a timelock arrives', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
expect(statuses).toHaveLength(2);
expect(statuses[1].status).toEqual(WAHASessionStatus.WORKING);
expect(statuses[1].data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('keeps attaching the timelock on plain WORKING assignments', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
// Reconnect flows assign 'status = WORKING' with no data
session.setStatusPublic(WAHASessionStatus.STARTING);
session.setStatusPublic(WAHASessionStatus.WORKING);
const last = statuses.at(-1);
expect(last.status).toEqual(WAHASessionStatus.WORKING);
expect(last.data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('collapses consecutive WORKING statuses with the same data', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.setStatusPublic(WAHASessionStatus.WORKING);
session.setStatusPublic(WAHASessionStatus.WORKING);
expect(statuses).toHaveLength(2);
});
it('ignores duplicate timelock updates', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.updateReachoutTimelockPublic({ ...ACTIVE_TIMELOCK });
expect(statuses).toHaveLength(2);
});
it('ignores an inactive timelock when none has been seen', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
isActive: false,
timeEnforcementEnds: null,
});
expect(statuses).toHaveLength(1);
expect(statuses[0].data).toBeNull();
});
it('re-issues WORKING when the timelock is lifted by an event', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
const lifted = { ...ACTIVE_TIMELOCK, isActive: false };
session.updateReachoutTimelockPublic(lifted);
expect(statuses).toHaveLength(3);
expect(statuses[2].data).toEqual({ reachoutTimelock: lifted });
});
it('marks the timelock inactive when the enforcement expires', () => {
jest.useFakeTimers();
try {
const endsAt = Math.floor(Date.now() / 1000) + 600;
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: endsAt,
});
jest.advanceTimersByTime(601 * 1000);
const last = statuses.at(-1);
expect(last.data.reachoutTimelock.isActive).toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not fire the expiry timer after the session stops', () => {
jest.useFakeTimers();
try {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 600,
});
session.setStatusPublic(WAHASessionStatus.STOPPED);
const count = statuses.length;
jest.advanceTimersByTime(601 * 1000);
expect(statuses).toHaveLength(count);
} finally {
jest.useRealTimers();
}
});
});
+145 -114
View File
@@ -1,5 +1,8 @@
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { PluginRegistry } from '@waha/core/abc/session.plugin.registry';
import { IMediaConverter } from '@waha/core/media/IMediaConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
import { MessagesForRead } from '@waha/core/utils/convertors';
import {
@@ -35,7 +38,7 @@ import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { MessageSource, WAMessage } from '@waha/structures/responses.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import { DefaultMap } from '@waha/utils/DefaultMap';
import { generatePrefixedId } from '@waha/utils/ids';
@@ -84,6 +87,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
@@ -104,16 +108,24 @@ import { EventMessageRequest } from '../../structures/events.dto';
import {
CreateGroupRequest,
GroupField,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupsListFields,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
} from '../../structures/groups.dto';
import { WAHAChatPresences } from '../../structures/presence.dto';
import {
MeInfo,
MessageCappingData,
MessageCappingStatus,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
SessionInfo,
} from '../../structures/sessions.dto';
import {
DeleteStatusRequest,
@@ -130,15 +142,11 @@ import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '../exceptions';
import { IMediaManager } from '../media/IMediaManager';
import { IMediaManager, MediaDownloadOptions } from '../media/IMediaManager';
import { QR } from '../QR';
import { DataStore } from './DataStore';
import { fetchBuffer } from '@waha/utils/fetch';
import {
PRESENCE_AUTO_ONLINE,
PRESENCE_AUTO_ONLINE_DURATION_SECONDS,
} from '@waha/core/env';
import { Activity } from '@waha/core/abc/activity';
import { SessionHooks } from './session.hooks';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const qrcode = require('qrcode-terminal');
@@ -153,6 +161,11 @@ export function ensureSuffix(phone) {
return phone + suffix;
}
interface MediaConfig {
api: MediaDownloadOptions;
events: MediaDownloadOptions;
}
export interface SessionParams {
name: string;
printQR: boolean;
@@ -165,6 +178,8 @@ export interface SessionParams {
engineConfig?: any;
// Ignore settings
ignore: IgnoreJidConfig;
// Media config
media: MediaConfig;
}
/**
@@ -190,21 +205,19 @@ export abstract class WhatsappSession {
protected sessionStore: DataStore;
protected proxyConfig?: ProxyConfig;
public sessionConfig?: SessionConfig;
protected media: MediaConfig;
protected engineConfig?: any;
protected unpairing: boolean = false;
protected jids: JidFilter;
private _status: WAHASessionStatus;
private _statusData: any = null;
protected reachoutTimelock: ReachoutTimelockTracker;
protected messageCapping: MessageCappingTracker;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
| null = null;
private lastActivityTimestamp?: number;
protected presenceAutoOnlineConfig = {
enabled: PRESENCE_AUTO_ONLINE,
duration: PRESENCE_AUTO_ONLINE_DURATION_SECONDS * 1000,
};
private shouldPrintQR: boolean;
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
@@ -213,15 +226,9 @@ export abstract class WhatsappSession {
stdTTL: 24 * 60 * 60, // 1 day
});
// Save sent messages ids in cache so we can determine if a message was sent
// via API or APP
private sentMessageIds: NodeCache = new NodeCache({
stdTTL: 10 * 60, // 10 minutes
});
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter;
public hooks: SessionHooks;
public plugins: PluginRegistry;
public constructor({
name,
@@ -233,15 +240,36 @@ export abstract class WhatsappSession {
sessionConfig,
engineConfig,
ignore,
media,
}: SessionParams) {
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<SessionStatusUpdate>();
this.name = name;
this.proxyConfig = proxyConfig;
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.hooks = new SessionHooks();
this.plugins = new PluginRegistry(this);
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
this.reachoutTimelock.changes$.subscribe((timelock) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
reachoutTimelock: timelock,
});
}
});
this.messageCapping = new MessageCappingTracker(this.logger);
this.messageCapping.changes$.subscribe((capping) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
messageCapping: capping,
});
}
});
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -280,11 +308,12 @@ export abstract class WhatsappSession {
}
return of(update);
}),
// Remove consecutive duplicate WORKING statuses
// Remove consecutive duplicate WORKING statuses, but let through WORKING re-issued with new data
distinctUntilChanged(
(prev, curr) =>
prev.status === curr.status &&
curr.status === WAHASessionStatus.WORKING,
curr.status === WAHASessionStatus.WORKING &&
lodash.isEqual(prev.data, curr.data),
),
// attach current time (ms)
timestamp(),
@@ -332,6 +361,17 @@ export abstract class WhatsappSession {
'The session ignores the following chat ids',
);
this.jids = new JidFilter(ignore);
//
// Media options
//
this.media = media;
const mimetypes = this.media.events.mimetypes;
if (mimetypes && mimetypes.length > 0) {
const str = mimetypes.join(',');
const msg = `Only '${str}' mimetypes will be downloaded for the session`;
this.logger.info(msg);
}
}
public getEventObservable(event: WAHAEvents) {
@@ -349,6 +389,27 @@ export abstract class WhatsappSession {
// wait for STOPPED event, ignore the rest
return;
}
if (status === WAHASessionStatus.WORKING && data == null) {
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the
// active account-restriction info so 'session.status' consumers do not lose it
const carry: any = {};
if (this.reachoutTimelock.value?.isActive) {
carry.reachoutTimelock = this.reachoutTimelock.value;
}
const capping = this.messageCapping.value;
if (capping && capping.cappingStatus !== MessageCappingStatus.NONE) {
carry.messageCapping = capping;
}
if (Object.keys(carry).length > 0) {
data = carry;
}
}
if (
status === WAHASessionStatus.STOPPED ||
status === WAHASessionStatus.FAILED
) {
this.reachoutTimelock?.stop();
}
this._status = status;
this._statusData = data;
this.status$.next({ status: status, data: data });
@@ -366,7 +427,7 @@ export abstract class WhatsappSession {
return this._statusData;
}
protected set presence(value: WAHAPresenceStatus) {
public set presence(value: WAHAPresenceStatus) {
switch (value) {
case null:
this._presence = null;
@@ -479,6 +540,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public fetchMessageCapping(): Promise<MessageCappingData> {
throw new NotImplementedByEngineError();
}
public fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
throw new NotImplementedByEngineError();
}
/**
* Auth methods
*/
@@ -513,6 +582,14 @@ export abstract class WhatsappSession {
return null;
}
/**
* Builds the runtime session info via the "session.info" hook.
*/
public getSessionInfo(): Promise<SessionInfo> {
const info = {} as SessionInfo;
return this.hooks.session.info.promise(info);
}
/**
* Profile methods
*/
@@ -609,6 +686,10 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
sendSticker(request: MessageStickerRequest) {
throw new NotImplementedByEngineError();
}
sendButtons(request: SendButtonsRequest) {
throw new NotImplementedByEngineError();
}
@@ -629,75 +710,6 @@ export abstract class WhatsappSession {
abstract stopTyping(chat: ChatRequest);
/**
* Activity tracking and presence management
*/
/**
* Returns the timestamp of the last "activity" in the session
* @returns Timestamp in milliseconds or undefined if there was never any activity
*/
public getLastActivityTimestamp(): number | undefined {
return this.lastActivityTimestamp;
}
/**
* Maintains ONLINE presence active while there is activity
* Resets the timer on each activity, only goes OFFLINE after Xs without activity
*/
async maintainPresenceOnline(): Promise<void> {
if (!this.presenceAutoOnlineConfig.enabled) {
return;
}
if (this.status !== WAHASessionStatus.WORKING) {
return;
}
this.lastActivityTimestamp = Date.now();
// If not ONLINE yet, send ONLINE
if (this._presence !== WAHAPresenceStatus.ONLINE) {
try {
// Force set ONLINE in case of many requests comes at the same time
// So we'll set ONLINE exactly once
this.presence = WAHAPresenceStatus.ONLINE;
await this.setPresence(WAHAPresenceStatus.ONLINE);
this.logger.debug('Set presence to ONLINE due to activity');
} catch (error) {
this.logger.debug('Failed to set presence ONLINE', error);
return;
}
}
// Cancel the previous timeout (if exists)
this.cleanupPresenceTimeout();
// Schedule to go back OFFLINE after timeout without activity
this.presenceOfflineTimeout = setTimeout(async () => {
try {
const working = this.status === WAHASessionStatus.WORKING;
const online = this.presence === WAHAPresenceStatus.ONLINE;
if (!working || !online) {
// Nothing to do
return;
}
await this.setPresence(WAHAPresenceStatus.OFFLINE);
this.logger.debug(
'Auto-set presence to OFFLINE after time without activity',
);
} catch (error) {
this.presence = WAHAPresenceStatus.OFFLINE;
this.logger.debug('Failed to set presence OFFLINE', error);
}
this.cleanupPresenceTimeout();
}, this.presenceAutoOnlineConfig.duration);
}
/**
* Cleans up the timeout when the session stops
*/
protected cleanupPresenceTimeout() {
clearTimeout(this.presenceOfflineTimeout);
this.presenceOfflineTimeout = null;
}
abstract setReaction(request: MessageReactionRequest);
setStar(request: MessageStarRequest): Promise<void> {
@@ -1005,6 +1017,45 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getMemberAddMode(id): Promise<SettingsMemberAddMode> {
throw new NotImplementedByEngineError();
}
public setMemberAddMode(id, value) {
throw new NotImplementedByEngineError();
}
public getMembershipApprovalMode(
id: string,
): Promise<SettingsMembershipApproval> {
throw new NotImplementedByEngineError();
}
public setMembershipApprovalMode(
id: string,
value: boolean,
): Promise<boolean> {
throw new NotImplementedByEngineError();
}
public getGroupJoinRequests(id: string): Promise<GroupJoinRequest[]> {
throw new NotImplementedByEngineError();
}
public approveGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
throw new NotImplementedByEngineError();
}
public rejectGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
throw new NotImplementedByEngineError();
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
@@ -1199,14 +1250,6 @@ export abstract class WhatsappSession {
* END - Methods for API
*/
/**
* Add WhatsApp suffix (@c.us) to the phone number if it doesn't have it yet
* @param phone
*/
protected ensureSuffix(phone) {
return ensureSuffix(phone);
}
protected deserializeId(messageId: string): MessageId {
const parts = messageId.split('_');
return {
@@ -1233,18 +1276,6 @@ export abstract class WhatsappSession {
qrcode.generate(qr.raw, { small: true });
}
protected saveSentMessageId(id: string) {
this.sentMessageIds.set(id, true);
}
protected getMessageSource(id: string): MessageSource {
if (!id) {
return MessageSource.APP;
}
const api = this.sentMessageIds.has(id);
return api ? MessageSource.API : MessageSource.APP;
}
/**
* Fetches the content from the specified URL and returns it as a Buffer.
*/
@@ -1,8 +1,7 @@
import type { WhatsappSession } from '@waha/core/abc/session.abc';
/**
* Decorator to mark a method as an activity that
* keeps the WhatsApp session online.
* Decorator to call "activity" hook
* @constructor
*/
export function Activity() {
@@ -16,7 +15,7 @@ export function Activity() {
this: WhatsappSession,
...args: Parameters<T>
): Promise<ReturnType<T>> {
await this.maintainPresenceOnline();
await this.hooks.activity.promise(String(propertyKey));
return await original.apply(this, args);
} as T;
+64
View File
@@ -0,0 +1,64 @@
import { MessageSource } from '@waha/structures/responses.dto';
import { SessionInfo } from '@waha/structures/sessions.dto';
import {
AsyncSeriesBailHook,
AsyncSeriesHook,
AsyncSeriesWaterfallHook,
SyncHook,
} from 'tapable';
/**
* Well-known stages for hook taps. Lower stage runs earlier, taps without a stage run in between (default stage is 0).
*/
export const Stage = {
FIRST: -100,
LAST: 100,
};
export class SessionHooks {
/**
* Called before an engine method that makes a network call to WhatsApp.
* method - the name of the method about to run.
*/
readonly activity = new AsyncSeriesHook<[string]>(['method'], 'activity');
readonly session = Object.freeze({
/**
* Builds the runtime session info (waterfall) - starts with an empty object, each tap merges its part in.
* Called when the API reports session state, e.g. GET /api/sessions.
*/
info: new AsyncSeriesWaterfallHook<[SessionInfo]>(['info'], 'session.info'),
});
/**
* Converts a WhatsApp identifier (wid) supplied by the API caller into the form the engine addresses.
* "wid.chat" converts the chat target, "wid.mention" converts a mention entry.
* method - the engine method the wid is being converted for, e.g. 'sendText'.
*/
readonly wid = Object.freeze({
chat: new AsyncSeriesWaterfallHook<[string, string]>(
['wid', 'method'],
'wid.chat',
),
mention: new AsyncSeriesWaterfallHook<[string, string]>(
['wid', 'method'],
'wid.mention',
),
});
readonly message = Object.freeze({
/**
* Called with the message id when the session sends a message via API.
* Fire and forget - return values are ignored.
*/
sent: new SyncHook<[string]>(['id'], 'message.sent'),
/**
* Resolves the MessageSource (api/app) for a message id. The first tap returning a non-undefined result wins,
* if all return undefined - callers fall back to MessageSource.APP.
*/
source: new AsyncSeriesBailHook<[string], MessageSource | undefined>(
['id'],
'message.source',
),
});
}
+62
View File
@@ -0,0 +1,62 @@
import type { SessionPlugin } from '@waha/core/abc/session.plugin';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { Observable } from 'rxjs';
type EventFn = (events$: Observable<any>) => void;
interface EventSubscriptionMetadata {
event: WAHAEvents;
propertyKey: string;
}
const EVENT_SUBSCRIPTIONS = Symbol('PluginEventSubscriptions');
function getOwnEventSubscriptions(ctor: any): EventSubscriptionMetadata[] {
if (!Object.prototype.hasOwnProperty.call(ctor, EVENT_SUBSCRIPTIONS)) {
ctor[EVENT_SUBSCRIPTIONS] = [];
}
return ctor[EVENT_SUBSCRIPTIONS];
}
function collectEventSubscriptions(ctor: any): EventSubscriptionMetadata[] {
const subscriptions: EventSubscriptionMetadata[] = [];
let current = ctor;
while (current) {
if (Object.prototype.hasOwnProperty.call(current, EVENT_SUBSCRIPTIONS)) {
subscriptions.push(...current[EVENT_SUBSCRIPTIONS]);
}
current = Object.getPrototypeOf(current);
}
return subscriptions;
}
/**
* Passes the session event observable to the decorated (public) method once, the method subscribes itself:
*
* @PluginEvent(WAHAEvents.SESSION_STATUS)
* onSessionStatus(events$: Observable<WASessionStatusBody>) {
* events$.subscribe({ next: ..., complete: ... });
* }
*/
export function PluginEvent(event: WAHAEvents) {
return function <K extends string, T extends Record<K, EventFn>>(
target: T,
propertyKey: K,
descriptor: PropertyDescriptor,
): void {
getOwnEventSubscriptions(target.constructor).push({
event: event,
propertyKey: propertyKey,
});
};
}
/**
* Calls all @PluginEvent methods of the plugin's class with the session event observables.
*/
export function RegisterPluginEvents(plugin: SessionPlugin<any, any>) {
for (const meta of collectEventSubscriptions(plugin.constructor)) {
const method = (plugin as any)[meta.propertyKey].bind(plugin);
method(plugin.session.getEventObservable(meta.event));
}
}
+129
View File
@@ -0,0 +1,129 @@
import { SessionHooks } from '@waha/core/abc/session.hooks';
import type { SessionPlugin } from '@waha/core/abc/session.plugin';
import {
SyncBailHook,
SyncHook,
SyncLoopHook,
SyncWaterfallHook,
} from 'tapable';
/**
* Which tapable method the tap is registered with.
*/
export enum TapType {
/** tap() for sync hooks, tapPromise() for async ones - the default */
Auto = 'auto',
/** tap() - the method runs synchronously, its return value is used as is */
Sync = 'sync',
/** tapPromise() - the method result is awaited (works for sync methods too) */
Promise = 'promise',
}
/**
* Tapable tap options (tapable does not export its TapOptions type) and the tap type.
*/
export interface HookTapOptions {
before?: string;
stage?: number;
type?: TapType;
}
/**
* The method signature a hook accepts, sync or async - inferred from the hook's tap() method.
*/
type HookFn<H> = H extends {
tap(options: any, fn: (...args: infer A) => infer R): void;
}
? (...args: A) => R | Promise<R>
: never;
type TapableHook = {
tap(options: any, fn: any): void;
};
interface HookTapMetadata {
selector: (hooks: SessionHooks) => TapableHook;
propertyKey: string;
options?: HookTapOptions;
}
const HOOK_TAPS = Symbol('PluginHookTaps');
function getOwnHookTaps(ctor: any): HookTapMetadata[] {
if (!Object.prototype.hasOwnProperty.call(ctor, HOOK_TAPS)) {
ctor[HOOK_TAPS] = [];
}
return ctor[HOOK_TAPS];
}
function collectHookTaps(ctor: any): HookTapMetadata[] {
const taps: HookTapMetadata[] = [];
let current = ctor;
while (current) {
if (Object.prototype.hasOwnProperty.call(current, HOOK_TAPS)) {
taps.push(...current[HOOK_TAPS]);
}
current = Object.getPrototypeOf(current);
}
return taps;
}
// identity check, not instanceof - tapable reassigns hook.constructor while all hooks share the base Hook prototype
const SYNC_HOOK_CLASSES: any[] = [
SyncHook,
SyncBailHook,
SyncLoopHook,
SyncWaterfallHook,
];
function isSyncHook(hook: any): boolean {
return SYNC_HOOK_CLASSES.includes(hook.constructor);
}
function useSyncTap(type: TapType, hook: any): boolean {
if (type === TapType.Sync) {
return true;
}
if (type === TapType.Promise) {
return false;
}
return isSyncHook(hook);
}
/**
* Taps the decorated (public) method into a session hook, stackable:
* @PluginHook((hooks) => hooks.wid.chat, { stage: Stage.FIRST })
*/
export function PluginHook<H extends TapableHook>(
selector: (hooks: SessionHooks) => H,
options?: HookTapOptions,
) {
return function <K extends string, T extends Record<K, HookFn<H>>>(
target: T,
propertyKey: K,
descriptor: PropertyDescriptor,
): void {
getOwnHookTaps(target.constructor).push({
selector: selector,
propertyKey: propertyKey,
options: options,
});
};
}
/**
* Applies all @PluginHook taps of the plugin's class to its session hooks, named after the plugin class.
*/
export function RegisterPluginHooks(plugin: SessionPlugin<any, any>) {
for (const meta of collectHookTaps(plugin.constructor)) {
const hook: any = meta.selector(plugin.session.hooks);
const { type, ...tapOptions } = meta.options ?? {};
const options = { name: plugin.constructor.name, ...tapOptions };
const method = (plugin as any)[meta.propertyKey].bind(plugin);
if (useSyncTap(type ?? TapType.Auto, hook)) {
hook.tap(options, method);
} else {
hook.tapPromise(options, async (...args: any[]) => method(...args));
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { WhatsappSession } from './session.abc';
import { PluginOptions, SessionPlugin } from './session.plugin';
import { RegisterPluginEvents } from './session.plugin.events';
import { RegisterPluginHooks } from './session.plugin.hooks';
interface PluginEntry {
id: string | null;
plugin: SessionPlugin<any, any>;
}
export class PluginRegistry {
private entries: PluginEntry[] = [];
constructor(private readonly session: WhatsappSession) {}
/**
* Construct the plugin with the session and a child logger, and store it. Hooks and events are registered later, in attach().
* id distinguishes multiple instances of the same plugin class (e.g. apps pass their app id) and enables get(Class, id).
*/
add<Plugin extends SessionPlugin<Config, Deps>, Config, Deps = null>(
options: PluginOptions<Plugin, Config, Deps>,
id?: string,
): Plugin {
const PluginClass = options.plugin;
const logger = this.session.loggerBuilder.child({
plugin: PluginClass.name,
});
const plugin = new PluginClass(
this.session,
logger,
options.config,
options.deps,
);
this.entries.push({ id: id ?? null, plugin: plugin });
return plugin;
}
/**
* The first plugin matching the class (instanceof), and the id when provided.
*/
get<Plugin extends SessionPlugin<any, any>>(
PluginClass: abstract new (...args: any[]) => Plugin,
id?: string,
): Plugin | null {
for (const entry of this.entries) {
if (!(entry.plugin instanceof PluginClass)) {
continue;
}
if (id != null && entry.id !== id) {
continue;
}
return entry.plugin;
}
return null;
}
/**
* Register @PluginHook and @PluginEvent handlers for all plugins and run their attach().
* Deferred until all plugins (core, engine, apps) are added.
*/
attach(): void {
for (const entry of this.entries) {
RegisterPluginHooks(entry.plugin);
RegisterPluginEvents(entry.plugin);
entry.plugin.attach();
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { WhatsappSession } from '@waha/core/abc/session.abc';
import { Logger } from 'pino';
/**
* Constructor contract for session plugins - SessionPlugin (and any subclass) conforms to it: (session, logger, config, deps).
* deps holds extra plugin-specific dependencies (e.g. { repository }); plugins without dependencies just omit the parameter.
*/
export interface SessionPluginConstructor<
Plugin extends SessionPlugin<Config, Deps>,
Config,
Deps = null,
> {
new (
session: WhatsappSession,
logger: Logger,
config: Config,
deps: Deps,
): Plugin;
}
/**
* A plugin class with its config and deps bound, ready to be constructed with a session and logger -
* build it with Plugin.with(config, deps). config and deps are always explicit - pass null when the plugin has none.
*/
export interface PluginOptions<
Plugin extends SessionPlugin<Config, Deps> = SessionPlugin<any, any>,
Config = any,
Deps = any,
> {
plugin: SessionPluginConstructor<Plugin, Config, Deps>;
config: NoInfer<Config>;
deps: NoInfer<Deps>;
}
export abstract class SessionPlugin<Config = null, Deps = null> {
constructor(
public readonly session: WhatsappSession,
protected logger: Logger,
protected config: Config,
protected deps: Deps,
) {}
/**
* Override for custom wiring (event subscriptions, extra hooks); keep constructors construction-only.
*/
attach(): void {}
/**
* Bind config and deps to the plugin class, type-checked against its constructor.
* Whoever registers the plugin supplies session and logger.
*/
static with<Plugin extends SessionPlugin<Config, Deps>, Config, Deps = null>(
this: SessionPluginConstructor<Plugin, Config, Deps>,
config: NoInfer<Config>,
deps: NoInfer<Deps>,
): PluginOptions<Plugin, Config, Deps> {
return { plugin: this, config: config, deps: deps };
}
}
+88 -68
View File
@@ -5,7 +5,6 @@ import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
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';
@@ -19,15 +18,33 @@ import { ContactsSessionController } from '@waha/api/contacts.session.controller
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
import { HttpPathsModule } from '@waha/plugins/http.paths.module';
import { HttpPathsService } from '@waha/plugins/HttpPathsService';
import { AppBootstrapModule } from '@waha/plugins/app.bootstrap.module';
import { SessionPluginsModule } from '@waha/plugins/session.plugins.module';
import { isDashboardEnabled } from '@waha/modules/waha-dashboard/dashboard.config';
import { DashboardModule } from '@waha/modules/waha-dashboard/dashboard.module';
import { isSwaggerEnabled } from '@waha/modules/waha-swagger/swagger.config';
import { SwaggerEnabledModule } from '@waha/modules/waha-swagger/swagger.module.enabled';
import { isPresenceAutoOnlineEnabled } from '@waha/modules/waha-maintain-online-status/maintain-online-status.config';
import { MaintainOnlineStatusModule } from '@waha/modules/waha-maintain-online-status/maintain-online-status.module';
import { isJidEngine } from '@waha/modules/waha-wid-jid/wid-jid.plugins';
import { MessageSourceModule } from '@waha/modules/waha-message-source/message-source.module';
import { isPrometheusEnabled } from '@waha/modules/waha-prometheus/prometheus.config';
import { PrometheusModule } from '@waha/modules/waha-prometheus/prometheus.module';
import { WebhookModule } from '@waha/modules/waha-webhook/webhook.module';
import { SessionRuntimeInfoModule } from '@waha/modules/waha-session-runtime-info/session-runtime-info.module';
import { WidJIDModule } from '@waha/modules/waha-wid-jid/wid-jid.module';
import { WidSuffixModule } from '@waha/modules/waha-wid-suffix/wid-suffix.module';
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
@@ -44,7 +61,6 @@ import {
import * as Joi from 'joi';
import { LoggerModule } from 'nestjs-pino';
import { Logger as NestJSPinoLogger } from 'nestjs-pino';
import { join } from 'path';
import { Logger } from 'pino';
import { AuthController } from '../api/auth.controller';
@@ -67,9 +83,7 @@ import { WhatsappConfigService } from '../config.service';
import { SessionManager } from './abc/manager.abc';
import { WAHAHealthCheckService } from './abc/WAHAHealthCheckService';
import { ApiKeyAuthFactory } from './auth/ApiKeyAuthFactory';
import { DashboardConfigServiceCore } from './config/DashboardConfigServiceCore';
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';
@@ -79,40 +93,37 @@ import { SessionService } from '@waha/core/services/SessionService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
LoggerModule.forRoot({
renameContext: 'name',
pinoHttp: {
quietReqLogger: true,
level: getPinoLogLevel(),
useLevel: getPinoHttpUseLevel(),
transport: getPinoTransport(),
autoLogging: {
ignore: (req) => {
return (
req.url.startsWith('/ping') ||
req.url.startsWith('/dashboard/') ||
req.url.startsWith('/api/files/') ||
req.url.startsWith('/api/s3/') ||
req.url.startsWith('/jobs/')
);
LoggerModule.forRootAsync({
imports: [HttpPathsModule],
inject: [HttpPathsService],
useFactory: (httpPaths: HttpPathsService) => {
return {
renameContext: 'name',
pinoHttp: {
level: getPinoLogLevel(),
useLevel: getPinoHttpUseLevel(),
transport: getPinoTransport(),
autoLogging: {
ignore: (req) => httpPaths.isAccessLogIgnored(req.url),
},
redact: {
paths: ['req.query["x-api-key"]'],
censor: '[REDACTED]',
},
customAttributeKeys: { req: 'req', res: 'res' },
serializers: {
req: (req) => ({
method: req.method,
url: redactUrlParams('x-api-key', req.url, req.query),
query: req.query,
params: req.params,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
},
},
redact: {
paths: ['req.query["x-api-key"]'],
censor: '[REDACTED]',
},
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: redactUrlParams('x-api-key', req.url, req.query),
query: req.query,
params: req.params,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
};
},
}),
ConfigModule.forRoot({
@@ -121,24 +132,32 @@ export const IMPORTS_CORE = [
WHATSAPP_API_SCHEMA: Joi.string().valid('http', 'https').default('http'),
}),
}),
ServeStaticModule.forRootAsync({
imports: [],
extraProviders: [DashboardConfigServiceCore],
inject: [DashboardConfigServiceCore],
useFactory: (dashboardConfig: DashboardConfigServiceCore) => {
if (!dashboardConfig.enabled) {
return [];
}
return [
{
rootPath: join(__dirname, '..', 'dashboard'),
serveRoot: dashboardConfig.dashboardUri,
},
];
},
}),
PassportModule,
TerminusModule,
AppBootstrapModule,
HttpPathsModule,
SessionPluginsModule,
SessionRuntimeInfoModule,
WebhookModule,
MessageSourceModule,
WidSuffixModule,
ConditionalModule.registerWhen(WidJIDModule, isJidEngine, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(
MaintainOnlineStatusModule,
isPresenceAutoOnlineEnabled,
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(PrometheusModule, isPrometheusEnabled, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(DashboardModule, isDashboardEnabled, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(SwaggerEnabledModule, isSwaggerEnabled, {
debug: isDebugEnabled(),
}),
];
const IMPORTS_MEDIA = [
@@ -200,11 +219,10 @@ export const PROVIDERS_BASE: Provider[] = [
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
},
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
NowebEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
@@ -248,9 +266,21 @@ export class AppModuleCore {
constructor(
protected config: WhatsappConfigService,
private dashboardConfig: DashboardConfigServiceCore,
private httpPaths: HttpPathsService,
) {
this.startTimestamp = Date.now();
this.httpPaths.register(
{ prefix: '/ping', include: { accessLog: false, authBasic: false } },
{ prefix: '/api/', include: { authBasic: false } },
{ prefix: 'api', include: { authApiKey: true } },
{ prefix: '/health', include: { authBasic: false } },
{ prefix: 'health', include: { authApiKey: true } },
{ prefix: '/ws', include: { authBasic: false } },
);
// WHATSAPP_API_KEY_EXCLUDE_PATH - env-driven auth exclusions
for (const path of this.config.getExcludedFullPaths()) {
this.httpPaths.register({ prefix: path, include: { authBasic: false } });
}
}
static getHttpsOptions(logger: Logger) {
@@ -279,16 +309,6 @@ export class AppModuleCore {
consumer
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'mcp');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
const password = dashboardCredentials[1];
consumer
.apply(BasicAuthFunction(username, password))
.forRoutes('dashboard');
}
.forRoutes(...this.httpPaths.apiKeyRoutes());
}
}
@@ -1,38 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { parseBool } from '../../helpers';
import { Auth } from '@waha/core/auth/config';
@Injectable()
export class DashboardConfigServiceCore {
public dashboardUri = '/dashboard';
constructor(
protected configService: ConfigService,
@InjectPinoLogger('DashboardConfigService')
protected logger: PinoLogger,
) {}
get enabled(): boolean {
const value = this.configService.get('WAHA_DASHBOARD_ENABLED', 'true');
return parseBool(value);
}
get credentials(): [string, string] | null {
const user = Auth.dashboard.username.value || '';
const password = Auth.dashboard.password.value || '';
if (!user && !password) {
return null;
}
if ((user && !password) || (!user && password)) {
this.logger.warn(
'Set up both WAHA_DASHBOARD_USERNAME and WAHA_DASHBOARD_PASSWORD ' +
'to enable dashboard authentication.',
);
return null;
}
return [user, password];
}
}
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more