Compare commits

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

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

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

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

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

Co-authored-by: Anderson Lemes <andersonlemes@outlook.pt>
2026-08-31 10:44:22 +07:00
devlikepro d2545c2de6 feat: use traceparent from opentelemetry
fix #2176 closes #2179
2026-08-31 10:44:22 +07:00
devlikepro 12a774c864 up(GOWS)
- Fix maps mirrored fromMe messages to the wrong contact/conversation - fix #2241
- Unable to retrieve WhatsApp groups - fix #2234
2026-08-31 10:44:22 +07:00
devlikepro 186847a455 up(WPP) 2026-08-19 15:51:46 +07:00
devlikepro 8c44b70109 version: 2026.8.2 2026-08-14 19:15:57 +07:00
devlikepro 382c1a7e94 fix(WEBJS): phantom AUTHENTICATED/READY on logout and onNewMessageId binding race - fix #2222
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-08-14 18:48:18 +07:00
devlikepro d7fb3091a6 build: ignore on watch 2026-08-14 18:48:18 +07:00
devlikepro 1fbe60e646 fix(WEBJS): session stuck in STARTING 2026-08-14 18:48:18 +07:00
devlikepro 841063739e feat: GET /api/sessions/{name}/timelock - fix #2219 2026-08-14 18:48:18 +07:00
devlikepro 8d4eb02202 up(WEBJS): hide "What's new on WhatsApp Web" - fix #2217 2026-08-14 18:48:18 +07:00
Moshe Grunwald f6ed147acc fix(s3): set ContentType on uploaded media objects 2026-08-14 17:45:14 +07:00
devlikepro 96a106cff7 up(WPP): fix promote to admin types 2026-08-14 17:39:23 +07:00
devlikepro 98854e2da3 up(WPP) 2026-08-14 17:25:11 +07:00
devlikepro 48c34cbc17 up(dashboard) 2026-08-05 16:00:16 +07:00
devlikepro 926b5c1b66 fea(GOWS): disable contacts, messageSecrets - mention #2207 2026-08-05 15:31:40 +07:00
devlikepro 608bad07a9 fix(ChatWoot): populate phone number if available fix #2208 2026-08-05 15:00:43 +07:00
devlikepro 64c6a32786 fix(GOWS): populate jid if available fix #2208 2026-08-05 14:34:59 +07:00
devlikepro f33ca787a5 up(NOWEB): fix presense chat issue 2026-08-05 14:34:59 +07:00
devlikepro 78aea9aa51 fix: set participant.pn if available 2026-08-05 14:34:59 +07:00
devlikepro 5e75d47149 up(WPP) 2026-08-05 14:34:59 +07:00
devlikepro c5a53d36bb fix(NOWEB): Fix group.v2.leave - fix #2206 closes #2209 2026-08-05 14:34:59 +07:00
devlikepro 0213692705 fix(ChatWot): do not save full attachments content in a task result - fix #2201 closes #2202 2026-08-05 14:34:59 +07:00
devlikepro a112fde248 fix(WEBJS): set group description #2199 2026-08-05 14:34:59 +07:00
devlikepro c207f3c08c fix(WEBJS): kill chrome at the end 2026-08-05 14:34:59 +07:00
devlikepro 1b8027473e fix(NOWEB): stop session properly 2026-08-05 14:34:59 +07:00
devlikepro 976066b216 chore(WEBJS): Up chrome to 142.0.7444.134-1 2026-08-05 14:34:59 +07:00
devlikepro af229a1d40 fix(GOWS): SIGKILL if hanged 2026-08-05 14:34:59 +07:00
devlikepro d14126d8a6 fix: process catch on promise timeout 2026-08-05 14:34:59 +07:00
devlikepro d4ce0e6541 fix: process.once on signals 2026-08-05 14:34:59 +07:00
devlikepro 5ddbeb995e feat(WEBJS, NOWEB): message capping API and events mention #2186 2026-08-05 14:34:59 +07:00
devlikepro f26d43b299 fix(WEBJS) - send button reply fix #2183 2026-08-05 14:34:59 +07:00
Berg Pinheiro ff998579f0 feat(GOWS): expose new-chat message capping (per-cycle quota) (#2186) 2026-08-05 14:34:59 +07:00
devlikepro 3cae921fba up(WPP): up engine 2026-08-05 14:34:59 +07:00
devlikepro 4682dddb4e chore(WPP): make with ignore sharp 2026-08-05 14:34:59 +07:00
devlikepro 2e6683d26f up(WEBJS): fix add group participants 2026-08-05 14:34:59 +07:00
devlikepro e97107ba1a up(GOWS)
- Update proto
- Fix disable whole store - fix #2207
2026-08-05 14:34:59 +07:00
devlikepro 2d96a57f72 feat(NOWEB): WAHA_NOWEB_WA_VERSION=auto-web|auto-baileys - fetch the latest WhatsApp Web version on start 2026-07-29 16:36:19 +07:00
devlikepro 9adcd3b133 version: 2026.8.1 2026-07-29 15:53:48 +07:00
devlikepro 79233e09e3 fix(NOWEB): Add WAHA_NOWEB_WA_VERSION / WAHA_NOWEB_WA_VERSION_FORCE - closes #2193
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-29 15:18:03 +07:00
devlikepro a96a4b2161 up(NOWEB): fix version - fix #2191 2026-07-29 14:31:41 +07:00
devlikepro f736105873 up(dashboard): session info, copy button in Event Monitor 2026-07-22 17:42:41 +07:00
devlikepro 2c1ea2c7c1 feat: /settings/security/member-add-mode - fix #2165 2026-07-22 17:35:49 +07:00
Ali White 216e00cc25 [core] feat(NOWEB): expose group member-add-mode as a settable endpoint close #2172 2026-07-22 17:19:34 +07:00
devlikeproandBerg Pinheiro ac1a015046 [core] Fix restartStoppedSessions aborting on one stuck session, closes #2169
Co-Authored-By: Berg Pinheiro <berg.pinheiro2009@gmail.com>
2026-07-22 17:17:35 +07:00
devlikepro 8f4af6bb08 feat: expose Reachout Timelock data - WORKING status and /me info fix #2166 2026-07-22 16:56:44 +07:00
devlikepro acee1b7d79 chore: timehelper docs 2026-07-22 16:12:21 +07:00
devlikepro 99241c3089 chore: SessionName to validation 2026-07-22 16:00:28 +07:00
devlikepro 8e764372d2 fix: LongTimeout and unref() for not keeping the process alive 2026-07-22 15:53:05 +07:00
devlikepro fa21a60cf3 fix(ChatWoot): safe read, typing when sending messages - fix #2173 2026-07-22 13:20:18 +07:00
devlikepro 02fa0ea06b ci: build dev nightly 2026-07-22 13:20:18 +07:00
devlikepro 8d1ad04625 fix(NOWEB): fix empty message.edited body - fix #2168 2026-07-22 13:20:18 +07:00
devlikepro a639baad8d fix(NOWEB): up engine 2026-07-22 13:20:18 +07:00
devlikepro 2e24107018 chore: up yarn@4.17.1 2026-07-22 13:20:18 +07:00
devlikepro 750ce208b3 chore(WEBJS): Up chrome 140.0.7339.207-1
The previous version is not found
2026-07-22 13:20:18 +07:00
devlikepro 057e0a0e70 up(GOWS): fix channels link preview - mention #2163, fix unknown field "faviconMMSMetadata" - fix #2172 2026-07-22 13:20:18 +07:00
devlikepro 255f84a12d fix(NOWEB): sending preview in channels mention #2163 2026-07-17 19:00:38 +07:00
devlikepro ac6d14394a chore: publish dev on core 2026-07-17 19:00:38 +07:00
devlikepro cb77c2fc49 fix(WEBJS): up engine. Fix sending link preview to channels fix #2163 2026-07-17 19:00:38 +07:00
devlikepro 5c279c5240 [core] 2026.7.2 2026-07-17 14:55:42 +07:00
devlikepro 7e719d8894 [core] 2026.7.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-15 20:04:29 +07:00
devlikepro 8faa3ca5a4 [core] Up dashboard 2026-07-15 20:04:29 +07:00
devlikepro 5dd8bf140d [core] Up WEBJS 2026-07-15 20:04:29 +07:00
devlikepro 0501c37500 [core] WEBJS - add lid to /me 2026-07-15 20:04:29 +07:00
devlikepro 1496274a99 [core] Up WEBJS - fix _serialized id rename fix #2157 fix #2158 fix #2159 fix #2160 fix #2162 2026-07-15 20:04:29 +07:00
devlikepro b6ba3951da [core] Up NOWEB 2026-07-15 20:04:29 +07:00
devlikepro a4a3dc93ad [core] Up WPP - fix _serialized issues 2026-07-15 20:04:28 +07:00
devlikepro 57eb0e14ca [core] Up dashboard - fix image emoji 2026-07-15 20:04:28 +07:00
devlikepro d8970326be [core] GOWS - fix "Session silently stops sending webhook events after <stream:error> (media ack)" - fix #2151 2026-07-15 20:04:28 +07:00
devlikepro 06412c2c1e [core] Up WEBJS - fix sending image with "msg.avParams is not a function" fix #2149 2026-07-15 20:04:28 +07:00
devlikepro 0b39645c50 [core] Update Dashboard - passkey flow driven by session.status
Picks up the UI side of the passkey rework: no more passkey.* events,
PASSKEY_CONFIRMATION_REQUIRED handled, GET /auth/passkey/challenge.
2026-07-15 20:04:28 +07:00
devlikepro 76f8cc6f53 [core] Don't watch src/dashboard assets - fixes ENOSPC inotify limit on start:dev 2026-07-15 20:04:28 +07:00
devlikepro ea79b1d886 [core] Passkey - collapse events into session.status, add GET /auth/passkey/challenge
- Remove 'passkey.required' and 'passkey.confirmation.required' events.
  Passkey pairing is a session state, so it rides on 'session.status'
  instead - one new status value per pairing step WhatsApp adds, not
  one new event.
- Add PASSKEY_CONFIRMATION_REQUIRED session status.
- Add 'data' to the session.status payload - the extra info that belongs
  to the current status. PASSKEY_REQUIRED carries the WebAuthn challenge,
  PASSKEY_CONFIRMATION_REQUIRED carries { code }, null otherwise.
- Base session gets setStatus(status, data); the plain 'status = value'
  setter delegates to it with no data, so the data clears itself as soon
  as the session moves on (WORKING, STOPPED, ...) with no extra bookkeeping.
- REST: GET /auth/passkey/challenge (was GET /auth/passkey) returns the
  challenge object, GET /auth/passkey/confirmation returns { code }.
  Both throw 422 when nothing is pending.
- MCP: auth-passkey-challenge, auth-passkey-submit, auth-passkey-confirmation,
  auth-passkey-confirm.
- Drop the SkipHandoffUX auto-confirm branch - it was dead code. whatsmeow
  confirms on its own in that case and only emits passkey-confirmation for
  the manual one (qrchan.go).
- Keep QR rotation from bouncing PASSKEY_CONFIRMATION_REQUIRED back to
  SCAN_QR_CODE, same as PASSKEY_REQUIRED.
2026-07-15 20:04:27 +07:00
devlikepro e54604eb97 [core] rm settings.local.json 2026-07-15 20:04:27 +07:00
Berg Pinheiro d267a29e87 [core] Update Dashboard - adds Passkey UI (extension-assisted + manual DevTools fallback) 2026-07-15 20:04:27 +07:00
Berg Pinheiro d4625359e6 [core] Up GOWS - v1.0.43, adds SubmitPasskeyResponse/ConfirmPasskey RPCs 2026-07-15 20:04:27 +07:00
Berg Pinheiro 3618b92c3e feat(passkey): Add Passkey (WebAuthn) session pairing
- New engine step: gows emits passkey-request/passkey-confirmation, session
  status PASSKEY_REQUIRED, challenge stored and exposed via getPasskeyChallenge().
- REST: GET/POST /api/:session/auth/passkey, GET /api/:session/auth/passkey/confirmation,
  POST /api/:session/auth/passkey/confirm.
- Webhooks: passkey.required (challenge) and passkey.confirmation.required (manual
  code case; most pairings auto-confirm server-side right after the assertion).
- QR rotation no longer bounces PASSKEY_REQUIRED back to SCAN_QR_CODE.
2026-07-15 20:04:27 +07:00
devlikepro 982092cf2b [core] MCP - do no share real api key for media and auth - qr or screenshot - fix #2146 2026-07-15 20:04:27 +07:00
devlikepro 6a452cd66e [core] Up WPP. Use 'main' branch versions for wa-js and wppconnect 2026-07-15 20:04:27 +07:00
devlikepro 19625e38e9 [core] Up NOWEB. Fix chat history ordering fix #2139. 2026-07-15 20:04:27 +07:00
devlikepro 208f4f3d78 [core] GOWS - fix document media — 403 on live media + media-retry never completes/refreshes directPath - fix ##2131
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-06-27 16:49:26 +07:00
devlikepro a9ff7d763d [core] GOWS - use gows-plus repo 2026-06-27 16:49:26 +07:00
devlikepro e290fd545f [core] make release 2026-06-27 16:32:05 +07:00
devlikepro 84f111e2c1 [core] 2026.6.2 2026-06-27 16:32:05 +07:00
devlikepro 55dddd5991 [core] WEBJS - remove all mentions of window.WAHA 2026-06-27 16:32:05 +07:00
devlikepro 638555297c [core] Up WEBJS 2026-06-27 16:32:04 +07:00
devlikepro 0e4de03da3 [core] Up GOWS 2026-06-27 16:32:04 +07:00
devlikepro c6219be356 [core] Up NOWEB 2026-06-27 16:32:04 +07:00
devlikepro 2460a23cab [core] Up WPP 2026-06-27 16:32:04 +07:00
devlikepro c2ed34a171 [core] 2026.6.1
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
2026-06-22 15:14:17 +07:00
devlikepro 1db8ae3423 [core] Merge PLUS functionality into CORE
Copy the storage (Mongo/Postgres/SQLite), media (S3/Postgres), engine
auth/store and util implementations from src/plus into src/core, and fold
the *Plus engine sessions, session manager, factories, health and channels
services into their *Core classes.

Core now supports multi-session orchestration, Mongo/Postgres session
storage, S3/Postgres media storage, FFmpeg media conversion, real health
checks and channels metadata. mediaConverter is set once in the base
session, and AppModuleCore wires the conditional media modules and health
indicators.
2026-06-22 15:14:17 +07:00
devlikepro 8a06ee3d44 [core] Up GOWS 2026-06-22 15:14:17 +07:00
294 changed files with 20573 additions and 2473 deletions

No files matched your search

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