Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab642b20d | ||
|
|
93fa55b7b7 | ||
|
|
2881458baf | ||
|
|
2595ccfe7b | ||
|
|
b7c4eac93b | ||
|
|
89543ab371 | ||
|
|
86aba9ea94 | ||
|
|
6d25680d43 | ||
|
|
068658df23 | ||
|
|
69306942ee | ||
|
|
e968b49349 | ||
|
|
6dc19229ae | ||
|
|
63d56b8467 | ||
|
|
53687647e9 | ||
|
|
fdf0589b68 | ||
|
|
f32dace3f8 | ||
|
|
ed056cb1f6 | ||
|
|
0e9b02d92c | ||
|
|
fc556dad77 | ||
|
|
5780bc09f9 | ||
|
|
d79940e494 | ||
|
|
1df6ec68db | ||
|
|
a06a72901a | ||
|
|
7e859b4ca9 | ||
|
|
9490a05586 | ||
|
|
b244115600 | ||
|
|
287d52fe1a | ||
|
|
21efcf07b1 | ||
|
|
aaa0eb12d5 | ||
|
|
1c5ece5a86 | ||
|
|
061601826e | ||
|
|
af51c0ae65 | ||
|
|
16b50c672b | ||
|
|
dbda9c0457 | ||
|
|
ff30e5ca90 | ||
|
|
2770c649c6 | ||
|
|
61f1f71343 | ||
|
|
99e4dfcfb5 | ||
|
|
cc12380e48 | ||
|
|
b5e223a333 | ||
|
|
f6db89c141 | ||
|
|
d2545c2de6 | ||
|
|
12a774c864 | ||
|
|
186847a455 | ||
|
|
8c44b70109 | ||
|
|
382c1a7e94 | ||
|
|
d7fb3091a6 | ||
|
|
1fbe60e646 | ||
|
|
841063739e | ||
|
|
8d4eb02202 | ||
|
|
f6ed147acc | ||
|
|
96a106cff7 | ||
|
|
98854e2da3 | ||
|
|
48c34cbc17 | ||
|
|
926b5c1b66 | ||
|
|
608bad07a9 | ||
|
|
64c6a32786 | ||
|
|
f33ca787a5 | ||
|
|
78aea9aa51 | ||
|
|
5e75d47149 | ||
|
|
c5a53d36bb | ||
|
|
0213692705 | ||
|
|
a112fde248 | ||
|
|
c207f3c08c | ||
|
|
1b8027473e | ||
|
|
976066b216 | ||
|
|
af229a1d40 | ||
|
|
d14126d8a6 | ||
|
|
d4ce0e6541 | ||
|
|
5ddbeb995e | ||
|
|
f26d43b299 | ||
|
|
ff998579f0 | ||
|
|
3cae921fba | ||
|
|
4682dddb4e | ||
|
|
2e6683d26f | ||
|
|
e97107ba1a | ||
|
|
2d96a57f72 | ||
|
|
9adcd3b133 | ||
|
|
79233e09e3 | ||
|
|
a96a4b2161 | ||
|
|
f736105873 | ||
|
|
2c1ea2c7c1 | ||
|
|
216e00cc25 | ||
|
|
ac1a015046 | ||
|
|
8f4af6bb08 | ||
|
|
acee1b7d79 | ||
|
|
99241c3089 | ||
|
|
8e764372d2 | ||
|
|
fa21a60cf3 | ||
|
|
02fa0ea06b | ||
|
|
8d1ad04625 | ||
|
|
a639baad8d | ||
|
|
2e24107018 | ||
|
|
750ce208b3 | ||
|
|
057e0a0e70 | ||
|
|
255f84a12d | ||
|
|
ac6d14394a | ||
|
|
cb77c2fc49 | ||
|
|
5c279c5240 | ||
|
|
7e719d8894 | ||
|
|
8faa3ca5a4 | ||
|
|
5dd8bf140d | ||
|
|
0501c37500 | ||
|
|
1496274a99 | ||
|
|
b6ba3951da | ||
|
|
a4a3dc93ad | ||
|
|
57eb0e14ca | ||
|
|
d8970326be | ||
|
|
06412c2c1e | ||
|
|
0b39645c50 | ||
|
|
76f8cc6f53 | ||
|
|
ea79b1d886 | ||
|
|
e54604eb97 | ||
|
|
d267a29e87 | ||
|
|
d4625359e6 | ||
|
|
3618b92c3e | ||
|
|
982092cf2b | ||
|
|
6a452cd66e | ||
|
|
19625e38e9 | ||
|
|
208f4f3d78 | ||
|
|
a9ff7d763d | ||
|
|
e290fd545f | ||
|
|
84f111e2c1 | ||
|
|
55dddd5991 | ||
|
|
638555297c | ||
|
|
0e4de03da3 | ||
|
|
c6219be356 | ||
|
|
2460a23cab | ||
|
|
c2ed34a171 | ||
|
|
1db8ae3423 | ||
|
|
8a06ee3d44 | ||
|
|
9e11312b75 | ||
|
|
1c7ce47dbb | ||
|
|
1d72e2666d | ||
|
|
7ae2f7999e | ||
|
|
68dc4ad642 | ||
|
|
e8f63aeaad | ||
|
|
f4e19c7664 | ||
|
|
6d8ef2e375 | ||
|
|
024cbffb13 | ||
|
|
ad399d9b01 | ||
|
|
6053c162c0 | ||
|
|
6654a7b3d0 | ||
|
|
4ff1c01e38 | ||
|
|
dfe0a3c8c4 | ||
|
|
04121bda52 | ||
|
|
7b6f522f92 |
No files matched your search
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(yarn build:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm show:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(yarn test:unit:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(pre-commit run:*)",
|
||||
"Bash(yarn test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
approvedGitRepositories:
|
||||
- '**'
|
||||
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 0
|
||||
|
||||
supportedArchitectures:
|
||||
cpu:
|
||||
- arm
|
||||
|
||||
+3
-3
@@ -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 \
|
||||
@@ -247,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
|
||||
ENV WAHA_ZIPPER=ZIPUNZIP
|
||||
|
||||
# GOWS - use libc DNS resolver
|
||||
ENV GODEBUG netdns=cgo
|
||||
ENV GODEBUG=netdns=cgo
|
||||
|
||||
# Run command, etc
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -41,11 +41,11 @@ up-noweb-libsignal:
|
||||
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||
|
||||
up-webjs:
|
||||
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18
|
||||
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
|
||||
|
||||
up-wpp:
|
||||
yarn up @wppconnect-team/wppconnect
|
||||
yarn up @wppconnect/wa-js
|
||||
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
|
||||
@@ -61,6 +61,17 @@ gows:
|
||||
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
|
||||
make all
|
||||
|
||||
ORIGIN ?= waha-plus
|
||||
CORE_REMOTE ?= waha
|
||||
|
||||
release:
|
||||
node scripts/release.js
|
||||
|
||||
release-push: release
|
||||
git push $(ORIGIN) core plus
|
||||
git push --force-with-lease $(ORIGIN) dev
|
||||
git push $(CORE_REMOTE) core
|
||||
|
||||
up-dashboard:
|
||||
node scripts/up-dashboard.js
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+6
-6
@@ -4,12 +4,12 @@
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"assets": [
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
"plus/engines/webjs/*",
|
||||
"apps/chatwoot/i18n/locales/*.yaml",
|
||||
"apps/chatwoot/i18n/locales/*.yml"
|
||||
{ "include": "dashboard/**", "watchAssets": false },
|
||||
{ "include": "core/engines/webjs/*", "watchAssets": true },
|
||||
{ "include": "plus/engines/webjs/*", "watchAssets": true },
|
||||
{ "include": "apps/chatwoot/i18n/locales/*.yaml", "watchAssets": true },
|
||||
{ "include": "apps/chatwoot/i18n/locales/*.yml", "watchAssets": true }
|
||||
],
|
||||
"watchAssets": true
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -39,7 +39,9 @@
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||
"@liaoliaots/nestjs-redis": "^9",
|
||||
"@lottiefiles/dotlottie-web": "^0.72.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -52,19 +54,25 @@
|
||||
"@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",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@wppconnect-team/wppconnect": "^2.0.2",
|
||||
"@wppconnect/wa-js": "^4.1.0",
|
||||
"@wppconnect-team/wppconnect": "github:wppconnect-team/wppconnect#master",
|
||||
"@wppconnect/wa-js": "github:wppconnect-team/wa-js#main",
|
||||
"adm-zip": "0.5.10",
|
||||
"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",
|
||||
@@ -94,9 +102,11 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-webpmux": "^3.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-copy-streams": "^6.0.0",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@@ -111,11 +121,12 @@
|
||||
"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",
|
||||
"user-agents": "^1.1.669",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26",
|
||||
"write-file-atomic": "^6.0.0",
|
||||
"yaml": "^2.7.1",
|
||||
"zod": "^4.3.6"
|
||||
@@ -129,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": {
|
||||
@@ -201,5 +212,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.2"
|
||||
"packageManager": "yarn@4.17.1"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execFileSync } = require('child_process');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const readline = require('readline');
|
||||
|
||||
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
|
||||
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
|
||||
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
|
||||
|
||||
const CORE_PREFIX = '[core]';
|
||||
const PLUS_PREFIX = '[PLUS]';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const assumeYes = args.includes('--yes') || args.includes('-y');
|
||||
|
||||
function git(gitArgs, options) {
|
||||
const opts = options || {};
|
||||
return execFileSync('git', gitArgs, {
|
||||
encoding: 'utf8',
|
||||
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function gitOut(gitArgs) {
|
||||
return git(gitArgs, { inherit: false }).trim();
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n✗ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureCleanTree() {
|
||||
const status = gitOut(['status', '--porcelain']);
|
||||
if (status) {
|
||||
fail(
|
||||
'Working tree is not clean. Commit or stash your changes before releasing.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBranchExists(branch) {
|
||||
try {
|
||||
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
||||
} catch {
|
||||
fail(`Branch "${branch}" does not exist locally.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
|
||||
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
|
||||
// plus every release, so `plus..dev` is exactly the new, unreleased work.
|
||||
// We intentionally do not use `git cherry` (patch-id matching) because core is
|
||||
// a rewritten history whose old commits share no patch-ids with dev.
|
||||
function commitsToCherryPick(boundary, head, prefix) {
|
||||
const format = '%H%x09%s';
|
||||
const output = gitOut([
|
||||
'rev-list',
|
||||
'--reverse',
|
||||
'--no-merges',
|
||||
`--format=${format}`,
|
||||
`${boundary}..${head}`,
|
||||
]);
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
const picks = [];
|
||||
for (const line of output.split('\n')) {
|
||||
// rev-list --format prefixes each entry with a "commit <sha>" header line.
|
||||
if (!line || line.startsWith('commit ')) {
|
||||
continue;
|
||||
}
|
||||
const tab = line.indexOf('\t');
|
||||
const sha = line.slice(0, tab);
|
||||
const subject = line.slice(tab + 1);
|
||||
if (subject.startsWith(prefix)) {
|
||||
picks.push({ sha: sha, subject: subject });
|
||||
}
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
function cherryPickAll(picks) {
|
||||
for (const pick of picks) {
|
||||
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
|
||||
if (dryRun) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
git(['cherry-pick', pick.sha], { inherit: true });
|
||||
} catch {
|
||||
git(['cherry-pick', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
|
||||
`Aborted the cherry-pick — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkout(branch) {
|
||||
log(`\n→ checkout ${branch}`);
|
||||
if (!dryRun) {
|
||||
git(['checkout', branch], { inherit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(question) {
|
||||
if (assumeYes || dryRun) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise(function resolver(resolve) {
|
||||
rl.question(`${question} [y/N] `, function onAnswer(answer) {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureCleanTree();
|
||||
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
|
||||
|
||||
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
|
||||
if (dryRun) {
|
||||
log('Running in --dry-run mode: no branches will be modified.\n');
|
||||
}
|
||||
|
||||
// Capture the last-released plus tip before we touch anything. Step 3 merges
|
||||
// core into plus and moves the branch, so both pick sets must be computed
|
||||
// against this saved boundary, not the live plus ref.
|
||||
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
|
||||
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
|
||||
|
||||
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
|
||||
log(
|
||||
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
|
||||
// Step 1 + 2: bring missing [core] commits onto core.
|
||||
checkout(CORE_BRANCH);
|
||||
cherryPickAll(corePicks);
|
||||
|
||||
// Step 3: merge the freshly updated core into plus.
|
||||
checkout(PLUS_BRANCH);
|
||||
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['merge', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the merge — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
|
||||
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
|
||||
log(
|
||||
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
cherryPickAll(plusPicks);
|
||||
|
||||
// Step 5: rebase dev onto the freshly built plus.
|
||||
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
|
||||
const proceed = await confirm(
|
||||
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
|
||||
);
|
||||
if (!proceed) {
|
||||
fail('Aborted before rebasing dev. core/plus changes are kept.');
|
||||
}
|
||||
checkout(DEV_BRANCH);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['rebase', PLUS_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['rebase', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the rebase — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
checkout(startBranch);
|
||||
log('\nDry run complete. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
log(
|
||||
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
|
||||
);
|
||||
log(
|
||||
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
|
||||
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(function onError(error) {
|
||||
fail(error.message || String(error));
|
||||
});
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
import { CanServer, CanSession, FromBody } from '@waha/core/auth/policies';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
|
||||
import {
|
||||
ApiKeyDTO,
|
||||
ApiKeyRequest,
|
||||
ScopedApiKeyRequest,
|
||||
} from '@waha/structures/apikeys.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/keys')
|
||||
@@ -44,6 +48,26 @@ export class ApiKeysController {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post('/media')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a media-download-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetMediaKey(body.session);
|
||||
}
|
||||
|
||||
@Post('/control')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a control-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetControlKey(body.session);
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an API key' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
import {
|
||||
QRCodeSessionParam,
|
||||
@@ -19,6 +24,9 @@ import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
import {
|
||||
PasskeyAssertionRequest,
|
||||
PasskeyChallenge,
|
||||
PasskeyConfirmationResponse,
|
||||
QRCodeFormat,
|
||||
QRCodeQuery,
|
||||
QRCodeValue,
|
||||
@@ -68,6 +76,54 @@ class AuthController {
|
||||
) {
|
||||
return session.requestCode(request.phoneNumber, request.method, request);
|
||||
}
|
||||
|
||||
@Get('passkey/challenge')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey (WebAuthn) challenge.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_REQUIRED status. ' +
|
||||
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
|
||||
'on the https://web.whatsapp.com origin.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyChallenge })
|
||||
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyChallenge();
|
||||
}
|
||||
|
||||
@Post('passkey')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
|
||||
})
|
||||
submitPasskey(
|
||||
@SessionParam session: WhatsappSession,
|
||||
@Body() request: PasskeyAssertionRequest,
|
||||
) {
|
||||
return session.sendPasskeyResponse(JSON.stringify(request));
|
||||
}
|
||||
|
||||
@Get('passkey/confirmation')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey confirmation code.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
|
||||
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyConfirmationResponse })
|
||||
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyConfirmation();
|
||||
}
|
||||
|
||||
@Post('passkey/confirm')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Confirm passkey pairing (only needed for the manual code case).',
|
||||
})
|
||||
confirmPasskey(@SessionParam session: WhatsappSession) {
|
||||
return session.confirmPasskey();
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthController };
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum AppName {
|
||||
chatwoot = 'chatwoot',
|
||||
calls = 'calls',
|
||||
mcp = 'mcp',
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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])];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
||||
|
||||
export const CallsAppExports = {
|
||||
providers: [CallsAppService],
|
||||
imports: [],
|
||||
controllers: [],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -31,4 +31,28 @@ export class McpController {
|
||||
});
|
||||
return ImageMcpResponse(Buffer.from(response.data));
|
||||
}
|
||||
|
||||
protected async scopedApiKey(
|
||||
url: string,
|
||||
session: string,
|
||||
): Promise<string | null> {
|
||||
const response = await this.request({
|
||||
method: 'POST',
|
||||
url: url,
|
||||
data: { session: session },
|
||||
});
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response.data?.key ?? null;
|
||||
}
|
||||
// e.g. 403 (caller lacks the scope) or 422 (session missing) → fall back
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async mediaApiKey(session: string): Promise<string | null> {
|
||||
return this.scopedApiKey('/api/keys/media', session);
|
||||
}
|
||||
|
||||
protected async controlApiKey(session: string): Promise<string | null> {
|
||||
return this.scopedApiKey('/api/keys/control', session);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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';
|
||||
@@ -19,6 +20,7 @@ import { PresenceTools } from '@waha/apps/mcp/tools/presence.tools';
|
||||
import { ProfileTools } from '@waha/apps/mcp/tools/profile.tools';
|
||||
import { StatusTools } from '@waha/apps/mcp/tools/status.tools';
|
||||
import { ServerTools } from '@waha/apps/mcp/tools/server.tools';
|
||||
import { KeysTools } from '@waha/apps/mcp/tools/keys.tools';
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
@@ -42,6 +44,8 @@ export class McpService {
|
||||
new ProfileTools(api),
|
||||
new StatusTools(api),
|
||||
new ServerTools(api),
|
||||
new KeysTools(api),
|
||||
new AppsTools(api),
|
||||
]);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // stateless
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -3,17 +3,24 @@ 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 {
|
||||
AuthPasskeyChallengeInput,
|
||||
AuthPasskeyConfirmationInput,
|
||||
AuthPasskeyConfirmInput,
|
||||
AuthPasskeySubmitInput,
|
||||
AuthQRInput,
|
||||
AuthRequestCodeInput,
|
||||
ScreenshotInput,
|
||||
} from '@waha/apps/mcp/tools/auth.zod';
|
||||
|
||||
function AuthContent(key: string): any {
|
||||
function AuthContent(key: string | null): any {
|
||||
const open = key
|
||||
? `add "?x-api-key=${key}" to the query params (this is a control-only key scoped to this session)`
|
||||
: `append "?x-api-key=YOUR_API_KEY" to the query params, using the key you already have`;
|
||||
return {
|
||||
type: 'text' as const,
|
||||
text: `
|
||||
You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +.
|
||||
If the user wants to open the QR code or screenshot in a browser, add "?x-api-key=${key}" to the query params.
|
||||
If the user wants to open the QR code or screenshot in a browser, ${open}.
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -39,7 +46,8 @@ export class AuthTools extends McpController {
|
||||
})
|
||||
async authQR({ session }: z.infer<typeof AuthQRInput>) {
|
||||
const result = await this.imageRequest(`/api/${session}/auth/qr`);
|
||||
result.content.push(AuthContent(this.api.key));
|
||||
const key = await this.controlApiKey(session);
|
||||
result.content.push(AuthContent(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -58,7 +66,8 @@ export class AuthTools extends McpController {
|
||||
const result = await this.imageRequest(
|
||||
`/api/screenshot?session=${session}`,
|
||||
);
|
||||
result.content.push(AuthContent(this.api.key));
|
||||
const key = await this.controlApiKey(session);
|
||||
result.content.push(AuthContent(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -94,4 +103,95 @@ export class AuthTools extends McpController {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-challenge', {
|
||||
title: 'Get passkey challenge',
|
||||
description:
|
||||
'Get the pending passkey (WebAuthn) challenge for a session in PASSKEY_REQUIRED status. ' +
|
||||
'Fails with 422 when nothing is pending. ' +
|
||||
'You cannot sign the challenge yourself - the assertion has to be produced by an authenticator ' +
|
||||
'on the https://web.whatsapp.com origin (the WAHA browser extension, or the DevTools fallback). ' +
|
||||
'Hand the challenge to the user, then submit the result with auth-passkey-submit.',
|
||||
inputSchema: AuthPasskeyChallengeInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyChallenge({
|
||||
session,
|
||||
}: z.infer<typeof AuthPasskeyChallengeInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/auth/passkey/challenge`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-submit', {
|
||||
title: 'Submit passkey assertion',
|
||||
description:
|
||||
'Submit the WebAuthn assertion produced by navigator.credentials.get() to finish passkey pairing. ' +
|
||||
'Get the challenge from auth-passkey-challenge first. ' +
|
||||
'After this the session usually goes straight to WORKING; ' +
|
||||
'if it goes to PASSKEY_CONFIRMATION_REQUIRED instead, follow up with auth-passkey-confirmation.',
|
||||
inputSchema: AuthPasskeySubmitInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async passkeySubmit({
|
||||
session,
|
||||
...body
|
||||
}: z.infer<typeof AuthPasskeySubmitInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/auth/passkey`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-confirmation', {
|
||||
title: 'Get passkey confirmation code',
|
||||
description:
|
||||
'Get the pending passkey confirmation code for a session in PASSKEY_CONFIRMATION_REQUIRED status. ' +
|
||||
'Fails with 422 when nothing is pending. ' +
|
||||
'Show the code to the user, ask them to check it matches the one on their phone, ' +
|
||||
'then call auth-passkey-confirm.',
|
||||
inputSchema: AuthPasskeyConfirmationInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyConfirmation({
|
||||
session,
|
||||
}: z.infer<typeof AuthPasskeyConfirmationInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/auth/passkey/confirmation`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-confirm', {
|
||||
title: 'Confirm passkey pairing',
|
||||
description:
|
||||
'Finish passkey pairing after the user confirmed the code matches the one shown on their phone. ' +
|
||||
'Only call it once the user has verified the code from auth-passkey-confirmation.',
|
||||
inputSchema: AuthPasskeyConfirmInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyConfirm({ session }: z.infer<typeof AuthPasskeyConfirmInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/auth/passkey/confirm`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import { RequestCodeRequest } from '@waha/structures/auth.dto';
|
||||
import {
|
||||
PasskeyAssertionRequest,
|
||||
RequestCodeRequest,
|
||||
} from '@waha/structures/auth.dto';
|
||||
|
||||
export const AuthQRInput = z.object({
|
||||
session: z.string(),
|
||||
@@ -13,3 +16,19 @@ export const ScreenshotInput = z.object({
|
||||
export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyChallengeInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyConfirmationInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeySubmitInput = DtoToZod(PasskeyAssertionRequest).extend({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyConfirmInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
@@ -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(','),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,22 @@ import {
|
||||
UnpinMessageInput,
|
||||
} from '@waha/apps/mcp/tools/chats.zod';
|
||||
|
||||
function FetchMediaUsingApiKeyContent(key: string) {
|
||||
function FetchMediaContent(key: string | null) {
|
||||
if (key) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
text:
|
||||
`To fetch media use "X-Api-Key: ${key}" HTTP header. ` +
|
||||
`To open it in a browser add "?x-api-key=${key}" to the query params. ` +
|
||||
`This is a media-only API key: it can ONLY download files for this session — ` +
|
||||
`it cannot read messages, send, or control the session.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'text' as const,
|
||||
text: `To fetch media use "X-Api-Key: ${key}" HTTP header. If the user wants to open it - add "?x-api-key=${key}" to query params`,
|
||||
text:
|
||||
`To fetch media, use your existing WAHA API key in the "X-Api-Key" HTTP header ` +
|
||||
`(or append "?x-api-key=YOUR_API_KEY" to open it in a browser).`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,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,
|
||||
@@ -150,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)) {
|
||||
@@ -170,7 +186,8 @@ export class ChatTools extends McpController {
|
||||
JSON.stringify({ status: response.status, response: responseText }),
|
||||
);
|
||||
if (query.downloadMedia) {
|
||||
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
|
||||
const mediaKey = await this.mediaApiKey(session);
|
||||
result.content.push(FetchMediaContent(mediaKey));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -216,10 +233,14 @@ 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) {
|
||||
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
|
||||
const mediaKey = await this.mediaApiKey(session);
|
||||
result.content.push(FetchMediaContent(mediaKey));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import { TextMcpResponse } from '@waha/apps/mcp/responses';
|
||||
import { ScopedKeyInput } from '@waha/apps/mcp/tools/keys.zod';
|
||||
|
||||
export class KeysTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('keys-get-scoped-key', {
|
||||
title: 'Get a scoped API key',
|
||||
description:
|
||||
'Create or get a minimal, scoped API key for a session. ' +
|
||||
'Use "media" scope for a download-only key to fetch media files, ' +
|
||||
'or "control" scope for a control-only key to open the QR code / screenshot in a browser. ' +
|
||||
'The returned key is far weaker than your own key and is safe to hand to the user for that single purpose.',
|
||||
inputSchema: ScopedKeyInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async getScopedKey({ session, scope }: z.infer<typeof ScopedKeyInput>) {
|
||||
let key: string | null = null;
|
||||
if (scope === 'media') {
|
||||
key = await this.mediaApiKey(session);
|
||||
} else {
|
||||
key = await this.controlApiKey(session);
|
||||
}
|
||||
if (!key) {
|
||||
return TextMcpResponse(
|
||||
JSON.stringify({
|
||||
error:
|
||||
'Could not mint a scoped API key. Check that the session exists and that your key has access to it.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return TextMcpResponse(JSON.stringify({ scope: scope, key: key }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const SessionField = z.string().describe('Session name');
|
||||
|
||||
export const ScopedKeyInput = z.object({
|
||||
session: SessionField,
|
||||
scope: z
|
||||
.enum(['media', 'control'])
|
||||
.describe(
|
||||
'Scope of the key. ' +
|
||||
'"media" — download-only key for fetching media files of the session. ' +
|
||||
'"control" — control-only key to open QR code / screenshot in a browser.',
|
||||
),
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user