Compare commits

..
86 Commits
Author SHA1 Message Date
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
153 changed files with 11704 additions and 1167 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:*)"
]
}
}
+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
+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
}
}
+4 -4
View File
@@ -60,8 +60,8 @@
"@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",
@@ -119,7 +119,7 @@
"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"
@@ -205,5 +205,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 };
+34
View File
@@ -38,6 +38,7 @@ import {
JoinGroupRequest,
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -309,6 +310,39 @@ 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);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
+35
View File
@@ -34,6 +34,8 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
MessageCappingData,
ReachoutTimelockData,
SessionCreateRequest,
SessionDTO,
SessionExpand,
@@ -98,6 +100,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',
@@ -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;
}
@@ -89,9 +89,21 @@ 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;
}
@@ -261,6 +273,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 +284,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 {
+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);
+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);
}
}
+2
View File
@@ -19,6 +19,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 +43,7 @@ export class McpService {
new ProfileTools(api),
new StatusTools(api),
new ServerTools(api),
new KeysTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
+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(),
});
+18 -4
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).`,
};
}
@@ -170,7 +182,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;
}
@@ -219,7 +232,8 @@ export class ChatTools extends McpController {
params: query,
});
if (query.downloadMedia) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
const mediaKey = await this.mediaApiKey(session);
result.content.push(FetchMediaContent(mediaKey));
}
return result;
}
+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.',
),
});
+37
View File
@@ -171,4 +171,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`,
});
}
}
+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();
}
});
});
+150 -30
View File
@@ -1,8 +1,8 @@
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
} from '@waha/core/media/IConverter';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
import { MessagesForRead } from '@waha/core/utils/convertors';
import {
IgnoreJidConfig,
@@ -65,6 +65,10 @@ import {
import { distinctUntilChanged, map } from 'rxjs/operators';
import { MessageId } from 'whatsapp-web.js';
import {
PasskeyChallenge,
PasskeyConfirmationResponse,
} from '../../structures/auth.dto';
import {
ChatRequest,
CheckNumberStatusQuery,
@@ -105,12 +109,16 @@ import {
GroupParticipant,
GroupsListFields,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '../../structures/groups.dto';
import { WAHAChatPresences } from '../../structures/presence.dto';
import {
MeInfo,
MessageCappingData,
MessageCappingStatus,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
} from '../../structures/sessions.dto';
import {
@@ -165,6 +173,19 @@ export interface SessionParams {
ignore: IgnoreJidConfig;
}
/**
* A status change, along with the extra info that belongs to that status
* (if any) - like the passkey challenge for PASSKEY_REQUIRED.
*/
interface SessionStatusUpdate {
status: WAHASessionStatus;
data: any;
}
interface SessionStatusUpdatePoint extends SessionStatusUpdate {
timestamp: number;
}
export abstract class WhatsappSession {
public engine: WAHAEngine;
@@ -180,6 +201,9 @@ export abstract class WhatsappSession {
protected jids: JidFilter;
private _status: WAHASessionStatus;
private _statusData: any = null;
protected reachoutTimelock: ReachoutTimelockTracker;
protected messageCapping: MessageCappingTracker;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
@@ -192,7 +216,7 @@ export abstract class WhatsappSession {
private shouldPrintQR: boolean;
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
private status$: Subject<WAHASessionStatus>;
private status$: Subject<SessionStatusUpdate>;
protected profilePictures: NodeCache = new NodeCache({
stdTTL: 24 * 60 * 60, // 1 day
});
@@ -205,7 +229,7 @@ export abstract class WhatsappSession {
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public mediaConverter: IMediaConverter;
public constructor({
name,
@@ -219,12 +243,31 @@ export abstract class WhatsappSession {
ignore,
}: SessionParams) {
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<WAHASessionStatus>();
this.status$ = new Subject<SessionStatusUpdate>();
this.name = name;
this.proxyConfig = proxyConfig;
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
this.reachoutTimelock.changes$.subscribe((timelock) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
reachoutTimelock: timelock,
});
}
});
this.messageCapping = new MessageCappingTracker(this.logger);
this.messageCapping.changes$.subscribe((capping) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
messageCapping: capping,
});
}
});
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -253,46 +296,56 @@ export abstract class WhatsappSession {
// Wait for WORKING status to get all the info
// https://github.com/devlikeapro/waha/issues/409
.pipe(
switchMap((status: WAHASessionStatus) => {
switchMap((update: SessionStatusUpdate) => {
const me = this.getSessionMeInfo();
const hasMe = !!me?.pushName && !!me?.id;
// Delay WORKING by 1 second if condition is met
// Usually we get WORKING with all the info after
if (status === WAHASessionStatus.WORKING && !hasMe) {
return of(status).pipe(delay(2000));
if (update.status === WAHASessionStatus.WORKING && !hasMe) {
return of(update).pipe(delay(2000));
}
return of(status);
return of(update);
}),
// Remove consecutive duplicate WORKING statuses
// Remove consecutive duplicate WORKING statuses, but let through WORKING re-issued with new data
distinctUntilChanged(
(prev, curr) => prev === curr && curr === WAHASessionStatus.WORKING,
(prev, curr) =>
prev.status === curr.status &&
curr.status === WAHASessionStatus.WORKING &&
lodash.isEqual(prev.data, curr.data),
),
// attach current time (ms)
timestamp(),
map(
({ value, timestamp }) =>
({
status: value,
status: value.status,
timestamp: timestamp,
}) as SessionStatusPoint,
data: value.data,
}) as SessionStatusUpdatePoint,
),
// keep the last 3 entries
scan<SessionStatusPoint, SessionStatusPoint[]>(
(statuses, status: SessionStatusPoint) => {
const next = [...statuses, status];
scan<SessionStatusUpdatePoint, SessionStatusUpdatePoint[]>(
(points, point: SessionStatusUpdatePoint) => {
const next = [...points, point];
return next.length > 3 ? next.slice(-3) : next;
},
[],
),
// shape final payload
map(
(statuses) =>
({
name: this.name,
status: statuses.at(-1)?.status, // current
statuses: statuses,
}) as WASessionStatusBody,
),
map((points) => {
const current = points.at(-1); // current
return {
name: this.name,
status: current?.status,
statuses: points.map(
(point): SessionStatusPoint => ({
status: point.status,
timestamp: point.timestamp,
}),
),
data: current?.data ?? null,
} as WASessionStatusBody;
}),
),
);
@@ -312,20 +365,55 @@ export abstract class WhatsappSession {
return this.events2.get(event);
}
public set status(value: WAHASessionStatus) {
if (this.unpairing && value !== WAHASessionStatus.STOPPED) {
/**
* Set the status along with the extra info that belongs to it.
* Plain 'status = value' assignments go through here without data,
* so the data is dropped as soon as the session moves on.
*/
protected setStatus(status: WAHASessionStatus, data: any = null) {
if (this.unpairing && status !== WAHASessionStatus.STOPPED) {
// In case of unpairing
// wait for STOPPED event, ignore the rest
return;
}
this._status = value;
this.status$.next(value);
if (status === WAHASessionStatus.WORKING && data == null) {
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the
// active account-restriction info so 'session.status' consumers do not lose it
const carry: any = {};
if (this.reachoutTimelock.value?.isActive) {
carry.reachoutTimelock = this.reachoutTimelock.value;
}
const capping = this.messageCapping.value;
if (capping && capping.cappingStatus !== MessageCappingStatus.NONE) {
carry.messageCapping = capping;
}
if (Object.keys(carry).length > 0) {
data = carry;
}
}
if (
status === WAHASessionStatus.STOPPED ||
status === WAHASessionStatus.FAILED
) {
this.reachoutTimelock?.stop();
}
this._status = status;
this._statusData = data;
this.status$.next({ status: status, data: data });
}
public set status(value: WAHASessionStatus) {
this.setStatus(value);
}
public get status() {
return this._status;
}
public get statusData() {
return this._statusData;
}
protected set presence(value: WAHAPresenceStatus) {
switch (value) {
case null:
@@ -439,6 +527,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public fetchMessageCapping(): Promise<MessageCappingData> {
throw new NotImplementedByEngineError();
}
public fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
throw new NotImplementedByEngineError();
}
/**
* Auth methods
*/
@@ -451,6 +547,22 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getPasskeyChallenge(): PasskeyChallenge {
throw new NotImplementedByEngineError();
}
public async sendPasskeyResponse(responseJson: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public async confirmPasskey(): Promise<void> {
throw new NotImplementedByEngineError();
}
public getPasskeyConfirmation(): PasskeyConfirmationResponse {
throw new NotImplementedByEngineError();
}
abstract getScreenshot(): Promise<Buffer>;
public getSessionMeInfo(): MeInfo | null {
@@ -949,6 +1061,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getMemberAddMode(id): Promise<SettingsMemberAddMode> {
throw new NotImplementedByEngineError();
}
public setMemberAddMode(id, value) {
throw new NotImplementedByEngineError();
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
+28 -4
View File
@@ -2,7 +2,7 @@ import * as process from 'node:process';
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConfigModule } from '@nestjs/config';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
@@ -22,10 +22,15 @@ import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { parseBool } from '@waha/helpers';
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
@@ -34,6 +39,7 @@ import {
getPinoHttpUseLevel,
getPinoLogLevel,
getPinoTransport,
isDebugEnabled,
redactUrlParams,
} from '@waha/utils/logging';
import * as Joi from 'joi';
@@ -144,10 +150,25 @@ const IMPORTS_MEDIA = [
.default('LOCAL'),
}),
}),
MediaLocalStorageModule,
ConditionalModule.registerWhen(
MediaLocalStorageModule,
(env: NodeJS.ProcessEnv) =>
!env['WAHA_MEDIA_STORAGE'] || env['WAHA_MEDIA_STORAGE'] == 'LOCAL',
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(
MediaS3StorageModule,
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'S3',
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(
MediaPsqlStorageModule,
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'POSTGRESQL',
{ debug: isDebugEnabled() },
),
];
const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
@@ -185,10 +206,13 @@ export const PROVIDERS_BASE: Provider[] = [
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
NowebEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
MongoStoreHealthIndicator,
CheckFreeDiskSpaceIndicator,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyAuthService,
@@ -203,7 +227,7 @@ export const PROVIDERS_BASE: Provider[] = [
...AppsModuleExports.providers,
];
const PROVIDERS = [
export const PROVIDERS = [
{
provide: SessionManager,
useClass: SessionManagerCore,
+127
View File
@@ -0,0 +1,127 @@
import type { WAVersion } from '@adiwajshing/baileys';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NowebConfig } from '@waha/core/engines/noweb/session.noweb.core';
import {
formatWaVersion,
isWaVersionHigher,
parseWaVersion,
} from '@waha/core/engines/noweb/waversion';
import { parseBool } from '@waha/helpers';
import esm from '@waha/vendor/esm';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
export enum WAVersionAutoMode {
WEB = 'auto-web',
BAILEYS = 'auto-baileys',
}
const FETCH_WA_VERSION_TIMEOUT_MS = 15_000;
@Injectable()
export class NowebEngineConfigService {
private config: NowebConfig | null = null;
constructor(
protected configService: ConfigService,
@InjectPinoLogger('NowebEngineConfigService')
protected logger: PinoLogger,
) {}
async getConfig(): Promise<NowebConfig> {
if (!this.config) {
const version = await this.resolveWaVersion();
this.config = { waVersion: version };
}
return this.config;
}
/**
* Resolves the WhatsApp Web version to use for NOWEB engine.
* Returns the built-in Baileys version when WAHA_NOWEB_WA_VERSION is not set, invalid, can not be fetched
* or is lower than the built-in one (unless WAHA_NOWEB_WA_VERSION_FORCE is set).
*/
private async resolveWaVersion(): Promise<WAVersion> {
const env = this.configService.get<string>('WAHA_NOWEB_WA_VERSION', '');
const wahaversion = esm.b.DEFAULT_CONNECTION_CONFIG.version as WAVersion;
const force = parseBool(
this.configService.get('WAHA_NOWEB_WA_VERSION_FORCE', 'false'),
);
const options = {
signal: AbortSignal.timeout(FETCH_WA_VERSION_TIMEOUT_MS),
};
let version: WAVersion | null = null;
switch (env) {
case WAVersionAutoMode.WEB: {
const value = await esm.b.fetchLatestWaWebVersion(options);
if (!value.isLatest) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - failed to fetch the latest WhatsApp Web version: ${value.error}`,
);
return wahaversion;
}
this.logger.info(
`WAHA_NOWEB_WA_VERSION='${env}' - fetched the latest WhatsApp Web version: ${formatWaVersion(
value.version,
)}`,
);
version = value.version;
break;
}
case WAVersionAutoMode.BAILEYS: {
const value = await esm.b.fetchLatestBaileysVersion(options);
if (!value.isLatest) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - failed to fetch the latest WhatsApp Web version: ${value.error}`,
);
return wahaversion;
}
this.logger.info(
`WAHA_NOWEB_WA_VERSION='${env}' - fetched the latest WhatsApp Web version: ${formatWaVersion(
value.version,
)}`,
);
version = value.version;
break;
}
case '':
break;
default:
version = parseWaVersion(env);
}
if (!version && env) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - invalid format, expected 'x.y.z', '${WAVersionAutoMode.WEB}' or '${WAVersionAutoMode.BAILEYS}'.`,
);
return wahaversion;
}
if (!version && force) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION_FORCE is set, but WAHA_NOWEB_WA_VERSION is not.`,
);
return wahaversion;
}
if (!version) {
return wahaversion;
}
if (force && version) {
this.logger.debug(
`Using WAHA_NOWEB_WA_VERSION because of WAHA_NOWEB_WA_VERSION_FORCE.`,
);
return version;
}
if (isWaVersionHigher(wahaversion, version)) {
this.logger.debug(
`Built-in wa.version is higher than WAHA_NOWEB_WA_VERSION, using built-in version.`,
);
return wahaversion;
}
return version;
}
}
@@ -0,0 +1,167 @@
import * as grpc from '@grpc/grpc-js';
import {
GowsEventStreamObservable,
GowsStreamEndedError,
} from '@waha/core/engines/gows/GowsEventStreamObservable';
import { EventEmitter } from 'events';
import { merge, Subject } from 'rxjs';
import { retry } from 'rxjs/operators';
/**
* Mimics grpc.ClientReadableStream.
* The important part is failWithStatus - it reproduces what grpc-js does in
* client.js makeServerStreamRequest.onReceiveStatus on a non OK status:
* push(null) schedules 'end' on the next tick, then 'error' is emitted
* synchronously in the current tick.
*/
class FakeStream extends EventEmitter {
public cancelled = false;
cancel() {
this.cancelled = true;
}
failWithStatus(err: any) {
process.nextTick(() => this.emit('end'));
this.emit('error', err);
}
endCleanly() {
process.nextTick(() => this.emit('end'));
}
}
class FakeClient {
public closed = false;
close() {
this.closed = true;
}
}
function buildLogger(): any {
function noop() {
return undefined;
}
return {
debug: noop,
info: noop,
warn: noop,
error: noop,
setBindings: noop,
};
}
function drainTicks(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
function observe(stream: FakeStream, client: FakeClient) {
const observable = new GowsEventStreamObservable(buildLogger(), () => ({
client: client as any,
stream: stream as any,
}));
// Do not wait a real second for the client to close
observable.CLIENT_CLOSE_TIMEOUT = 0;
const next = jest.fn();
const error = jest.fn();
const complete = jest.fn();
const subscription = observable.subscribe({
next: next,
error: error,
complete: complete,
});
return {
next: next,
error: error,
complete: complete,
subscription: subscription,
};
}
describe('GowsEventStreamObservable', () => {
it('errors (not completes) when the stream fails with a non OK status', async () => {
const stream = new FakeStream();
const client = new FakeClient();
const { error, complete } = observe(stream, client);
const err = { code: grpc.status.UNAVAILABLE, message: 'unavailable' };
stream.failWithStatus(err);
await drainTicks();
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(err);
expect(complete).not.toHaveBeenCalled();
expect(stream.cancelled).toBe(true);
expect(client.closed).toBe(true);
});
it('errors (not completes) when the stream ends cleanly', async () => {
const stream = new FakeStream();
const client = new FakeClient();
const { error, complete } = observe(stream, client);
stream.endCleanly();
await drainTicks();
expect(error).toHaveBeenCalledTimes(1);
expect(error.mock.calls[0][0]).toBeInstanceOf(GowsStreamEndedError);
expect(complete).not.toHaveBeenCalled();
});
it('does not error when we cancel the stream ourselves', async () => {
const stream = new FakeStream();
const client = new FakeClient();
const { error, complete, subscription } = observe(stream, client);
subscription.unsubscribe();
stream.emit('error', { code: grpc.status.CANCELLED });
stream.emit('end');
await drainTicks();
expect(error).not.toHaveBeenCalled();
expect(complete).not.toHaveBeenCalled();
expect(stream.cancelled).toBe(true);
expect(client.closed).toBe(true);
});
it('reconnects via retry() when merged with a never ending subject', async () => {
const streams = [new FakeStream(), new FakeStream()];
const clients = [new FakeClient(), new FakeClient()];
let attempt = 0;
const observable = new GowsEventStreamObservable(buildLogger(), () => {
const index = attempt;
attempt += 1;
return { client: clients[index] as any, stream: streams[index] as any };
});
observable.CLIENT_CLOSE_TIMEOUT = 0;
const local$ = new Subject<any>();
const next = jest.fn();
const error = jest.fn();
const subscription = merge(observable, local$)
.pipe(retry({ delay: 1 }))
.subscribe({ next: next, error: error });
streams[0].failWithStatus({ code: grpc.status.INTERNAL });
await drainTicks();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(attempt).toBe(2);
expect(error).not.toHaveBeenCalled();
streams[1].emit('data', {
toObject: () => ({ event: 'Message', data: '{"id":"1"}' }),
});
await drainTicks();
expect(next).toHaveBeenCalledTimes(1);
expect(next.mock.calls[0][0]).toEqual({
event: 'Message',
data: { id: '1' },
});
subscription.unsubscribe();
});
});
@@ -1,14 +1,29 @@
import * as grpc from '@grpc/grpc-js';
import { rand } from '@waha/core/auth/config';
import { messages } from '@waha/core/engines/gows/grpc/gows';
import { EnginePayload } from '@waha/structures/webhooks.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Logger } from 'pino';
import { Observable } from 'rxjs';
import { rand } from '@waha/core/auth/config';
/**
* Raised when the gRPC stream ends without an error.
* The engine event stream is expected to live as long as the session,
* so a clean end still means we lost the events and have to reconnect.
*/
export class GowsStreamEndedError extends Error {
constructor() {
super('gRPC event stream ended');
this.name = 'GowsStreamEndedError';
}
}
/**
* Observable that listens to a gRPC stream and emits EnginePayload objects.
* Pass a factory function that returns a client and a stream.
*
* The observable always terminates with an error, never with a completion,
* so that an upstream retry() reconnects the stream.
*/
export class GowsEventStreamObservable extends Observable<EnginePayload> {
_client: grpc.Client;
@@ -26,63 +41,79 @@ export class GowsEventStreamObservable extends Observable<EnginePayload> {
logger.setBindings({ id: rand() });
const { client, stream } = factory();
this._client = client;
const closeTimeout = this.CLIENT_CLOSE_TIMEOUT;
let closed = false;
const cleanup = async (reason: string) => {
let terminated = false;
let tearingDown = false;
async function cleanup(reason: string) {
if (closed) {
return;
}
closed = true;
logger.debug({ reason }, 'Cancelling gRPC stream...');
logger.debug({ reason: reason }, 'Cancelling gRPC stream...');
try {
stream.cancel();
} catch (err) {
logger.warn({ err }, 'Failed to cancel gRPC stream');
logger.warn({ err: err }, 'Failed to cancel gRPC stream');
}
logger.debug({ reason }, 'Closing gRPC client...');
logger.debug({ reason: reason }, 'Closing gRPC client...');
try {
client.close();
} catch (err) {
logger.warn({ err }, 'Failed to close gRPC client');
logger.warn({ err: err }, 'Failed to close gRPC client');
}
await sleep(this.CLIENT_CLOSE_TIMEOUT);
};
await sleep(closeTimeout);
}
// Must run synchronously from the stream handlers.
// grpc-js calls stream.push(null) - which schedules 'end' on the next tick -
// and only then emits 'error' in the same tick. Erroring the subscriber
// right away wins that race, otherwise 'end' completes the observable
// and the upstream retry() never reconnects.
function terminate(err: Error) {
if (terminated) {
return;
}
terminated = true;
// Erroring the subscriber runs the teardown below, which cleans up.
subscriber.error(err);
}
stream.on('data', (raw) => {
setImmediate(() => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
subscriber.next(obj);
});
});
stream.on('end', (...args) => {
logger.debug('Stream ended', args);
subscriber?.complete();
subscriber = null;
void cleanup('end');
});
stream.on('error', async (err: any) => {
const CLIENT_CANCELLED_CODE = grpc.status.CANCELLED;
if (err.code === CLIENT_CANCELLED_CODE) {
logger.debug('Stream cancelled by client');
await cleanup('cancelled');
stream.on('end', () => {
if (tearingDown || terminated) {
logger.debug('Stream ended');
return;
}
logger.error(err, 'Stream error');
await cleanup('error');
// Give some time to node event loop to process the error
await sleep(100);
subscriber?.error(err);
subscriber = null;
logger.error('Stream ended unexpectedly, reconnecting...');
terminate(new GowsStreamEndedError());
});
return async () => {
await cleanup('teardown');
stream.on('error', (err: any) => {
if (tearingDown || terminated) {
// We cancelled the stream ourselves, no need to reconnect
logger.debug({ err: err }, 'Stream cancelled by client');
return;
}
logger.error(err, 'Stream error, reconnecting...');
terminate(err);
});
return () => {
tearingDown = true;
void cleanup('teardown');
};
});
}
+15 -2
View File
@@ -1,10 +1,11 @@
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { spawn } from 'child_process';
import { Logger } from 'pino';
export class GowsSubprocess {
private checkIntervalMs: number = 100;
private readyDelayMs: number = 1_000;
private stopTimeoutMs: number = 3_000;
private readyText = 'gRPC server started!';
private child: any;
@@ -96,7 +97,19 @@ export class GowsSubprocess {
async stop() {
this.logger.info('Stopping GOWS subprocess...');
this.child?.kill('SIGTERM');
const child = this.child;
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise<void>((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
// the process is detached, so if it ignores SIGTERM nothing else kills it - escalate to SIGKILL
await promiseTimeout(this.stopTimeoutMs, exited).catch(() => {
this.logger.warn(
`GOWS did not exit in ${this.stopTimeoutMs}ms, sending SIGKILL`,
);
child.kill('SIGKILL');
});
this.logger.info('GOWS subprocess stopped');
}
+4
View File
@@ -18,5 +18,9 @@ export function BuildEventStreamClient(
return new messages.EventStreamClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
// Detect a hung server, so the stream errors out and we reconnect
'grpc.keepalive_time_ms': 30_000,
'grpc.keepalive_timeout_ms': 10_000,
'grpc.keepalive_permit_without_calls': 1,
});
}
+4 -2
View File
@@ -4,7 +4,7 @@ import {
GroupInfoFull,
JoinedGroupEvent,
} from '@waha/core/engines/gows/types.group';
import { toCusFormat, toJID } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat, toJID } from '@waha/core/utils/jids';
import {
GroupId,
GroupInfo,
@@ -115,8 +115,10 @@ function getParticipants(jids: string[] | null, type: GroupParticipantType) {
}
const participants: GroupParticipant[] = [];
for (const jid of jids) {
const id = toCusFormat(jid);
participants.push({
id: toCusFormat(jid),
id: id,
pn: isPnUser(id) ? id : null,
role: role,
});
}
+254 -1
View File
@@ -1132,6 +1132,99 @@ export namespace messages {
return PairCodeResponse.deserialize(bytes);
}
}
export class PasskeyResponseRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
response_json?: string;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("response_json" in data && data.response_json != undefined) {
this.response_json = data.response_json;
}
}
}
get session() {
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
}
set session(value: Session) {
pb_1.Message.setWrapperField(this, 1, value);
}
get has_session() {
return pb_1.Message.getField(this, 1) != null;
}
get response_json() {
return pb_1.Message.getFieldWithDefault(this, 2, "") as string;
}
set response_json(value: string) {
pb_1.Message.setField(this, 2, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
response_json?: string;
}): PasskeyResponseRequest {
const message = new PasskeyResponseRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.response_json != null) {
message.response_json = data.response_json;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
response_json?: string;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.response_json != null) {
data.response_json = this.response_json;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_session)
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
if (this.response_json.length)
writer.writeString(2, this.response_json);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): PasskeyResponseRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new PasskeyResponseRequest();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
break;
case 2:
message.response_json = reader.readString();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): PasskeyResponseRequest {
return PasskeyResponseRequest.deserialize(bytes);
}
}
export class Empty extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {}) {
@@ -1330,7 +1423,7 @@ export namespace messages {
}
}
export class SessionStorageConfig extends pb_1.Message {
#one_of_decls: number[][] = [[1], [2], [3], [4]];
#one_of_decls: number[][] = [[1], [2], [3], [4], [5], [6]];
constructor(data?: any[] | ({} & (({
messages?: boolean;
}) | ({
@@ -1339,6 +1432,10 @@ export namespace messages {
chats?: boolean;
}) | ({
labels?: boolean;
}) | ({
contacts?: boolean;
}) | ({
message_secrets?: boolean;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -1355,6 +1452,12 @@ export namespace messages {
if ("labels" in data && data.labels != undefined) {
this.labels = data.labels;
}
if ("contacts" in data && data.contacts != undefined) {
this.contacts = data.contacts;
}
if ("message_secrets" in data && data.message_secrets != undefined) {
this.message_secrets = data.message_secrets;
}
}
}
get messages() {
@@ -1393,6 +1496,24 @@ export namespace messages {
get has_labels() {
return pb_1.Message.getField(this, 4) != null;
}
get contacts() {
return pb_1.Message.getFieldWithDefault(this, 5, false) as boolean;
}
set contacts(value: boolean) {
pb_1.Message.setOneofField(this, 5, this.#one_of_decls[4], value);
}
get has_contacts() {
return pb_1.Message.getField(this, 5) != null;
}
get message_secrets() {
return pb_1.Message.getFieldWithDefault(this, 6, false) as boolean;
}
set message_secrets(value: boolean) {
pb_1.Message.setOneofField(this, 6, this.#one_of_decls[5], value);
}
get has_message_secrets() {
return pb_1.Message.getField(this, 6) != null;
}
get _messages() {
const cases: {
[index: number]: "none" | "messages";
@@ -1429,11 +1550,31 @@ export namespace messages {
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
get _contacts() {
const cases: {
[index: number]: "none" | "contacts";
} = {
0: "none",
5: "contacts"
};
return cases[pb_1.Message.computeOneofCase(this, [5])];
}
get _message_secrets() {
const cases: {
[index: number]: "none" | "message_secrets";
} = {
0: "none",
6: "message_secrets"
};
return cases[pb_1.Message.computeOneofCase(this, [6])];
}
static fromObject(data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
contacts?: boolean;
message_secrets?: boolean;
}): SessionStorageConfig {
const message = new SessionStorageConfig({});
if (data.messages != null) {
@@ -1448,6 +1589,12 @@ export namespace messages {
if (data.labels != null) {
message.labels = data.labels;
}
if (data.contacts != null) {
message.contacts = data.contacts;
}
if (data.message_secrets != null) {
message.message_secrets = data.message_secrets;
}
return message;
}
toObject() {
@@ -1456,6 +1603,8 @@ export namespace messages {
groups?: boolean;
chats?: boolean;
labels?: boolean;
contacts?: boolean;
message_secrets?: boolean;
} = {};
if (this.messages != null) {
data.messages = this.messages;
@@ -1469,6 +1618,12 @@ export namespace messages {
if (this.labels != null) {
data.labels = this.labels;
}
if (this.contacts != null) {
data.contacts = this.contacts;
}
if (this.message_secrets != null) {
data.message_secrets = this.message_secrets;
}
return data;
}
serialize(): Uint8Array;
@@ -1483,6 +1638,10 @@ export namespace messages {
writer.writeBool(3, this.chats);
if (this.has_labels)
writer.writeBool(4, this.labels);
if (this.has_contacts)
writer.writeBool(5, this.contacts);
if (this.has_message_secrets)
writer.writeBool(6, this.message_secrets);
if (!w)
return writer.getResultBuffer();
}
@@ -1504,6 +1663,12 @@ export namespace messages {
case 4:
message.labels = reader.readBool();
break;
case 5:
message.contacts = reader.readBool();
break;
case 6:
message.message_secrets = reader.readBool();
break;
default: reader.skipField();
}
}
@@ -6303,6 +6468,7 @@ export namespace messages {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -6316,6 +6482,9 @@ export namespace messages {
if ("registered" in data && data.registered != undefined) {
this.registered = data.registered;
}
if ("pn" in data && data.pn != undefined) {
this.pn = data.pn;
}
}
}
get phone() {
@@ -6336,10 +6505,17 @@ export namespace messages {
set registered(value: boolean) {
pb_1.Message.setField(this, 3, value);
}
get pn() {
return pb_1.Message.getFieldWithDefault(this, 4, "") as string;
}
set pn(value: string) {
pb_1.Message.setField(this, 4, value);
}
static fromObject(data: {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
}): PhoneInfo {
const message = new PhoneInfo({});
if (data.phone != null) {
@@ -6351,6 +6527,9 @@ export namespace messages {
if (data.registered != null) {
message.registered = data.registered;
}
if (data.pn != null) {
message.pn = data.pn;
}
return message;
}
toObject() {
@@ -6358,6 +6537,7 @@ export namespace messages {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
} = {};
if (this.phone != null) {
data.phone = this.phone;
@@ -6368,6 +6548,9 @@ export namespace messages {
if (this.registered != null) {
data.registered = this.registered;
}
if (this.pn != null) {
data.pn = this.pn;
}
return data;
}
serialize(): Uint8Array;
@@ -6380,6 +6563,8 @@ export namespace messages {
writer.writeString(2, this.jid);
if (this.registered != false)
writer.writeBool(3, this.registered);
if (this.pn.length)
writer.writeString(4, this.pn);
if (!w)
return writer.getResultBuffer();
}
@@ -6398,6 +6583,9 @@ export namespace messages {
case 3:
message.registered = reader.readBool();
break;
case 4:
message.pn = reader.readString();
break;
default: reader.skipField();
}
}
@@ -11053,6 +11241,24 @@ export namespace messages {
responseSerialize: (message: PairCodeResponse) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => PairCodeResponse.deserialize(new Uint8Array(bytes))
},
SubmitPasskeyResponse: {
path: "/messages.MessageService/SubmitPasskeyResponse",
requestStream: false,
responseStream: false,
requestSerialize: (message: PasskeyResponseRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => PasskeyResponseRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
ConfirmPasskey: {
path: "/messages.MessageService/ConfirmPasskey",
requestStream: false,
responseStream: false,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
Logout: {
path: "/messages.MessageService/Logout",
requestStream: false,
@@ -11251,6 +11457,15 @@ export namespace messages {
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
SetGroupMemberAddMode: {
path: "/messages.MessageService/SetGroupMemberAddMode",
requestStream: false,
responseStream: false,
requestSerialize: (message: JidBoolRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => JidBoolRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
UpdateGroupParticipants: {
path: "/messages.MessageService/UpdateGroupParticipants",
requestStream: false,
@@ -11314,6 +11529,24 @@ export namespace messages {
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
FetchReachoutTimelock: {
path: "/messages.MessageService/FetchReachoutTimelock",
requestStream: false,
responseStream: false,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Json) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Json.deserialize(new Uint8Array(bytes))
},
FetchMessageCapping: {
path: "/messages.MessageService/FetchMessageCapping",
requestStream: false,
responseStream: false,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Json) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Json.deserialize(new Uint8Array(bytes))
},
GenerateNewMessageID: {
path: "/messages.MessageService/GenerateNewMessageID",
requestStream: false,
@@ -11599,6 +11832,8 @@ export namespace messages {
abstract StopSession(call: grpc_1.ServerUnaryCall<Session, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract GetSessionState(call: grpc_1.ServerUnaryCall<Session, SessionStateResponse>, callback: grpc_1.sendUnaryData<SessionStateResponse>): void;
abstract RequestCode(call: grpc_1.ServerUnaryCall<PairCodeRequest, PairCodeResponse>, callback: grpc_1.sendUnaryData<PairCodeResponse>): void;
abstract SubmitPasskeyResponse(call: grpc_1.ServerUnaryCall<PasskeyResponseRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract ConfirmPasskey(call: grpc_1.ServerUnaryCall<Session, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract Logout(call: grpc_1.ServerUnaryCall<Session, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetProfileName(call: grpc_1.ServerUnaryCall<ProfileNameRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetProfileStatus(call: grpc_1.ServerUnaryCall<ProfileStatusRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
@@ -11621,6 +11856,7 @@ export namespace messages {
abstract SetGroupPicture(call: grpc_1.ServerUnaryCall<SetPictureRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupLocked(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupAnnounce(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupMemberAddMode(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract UpdateGroupParticipants(call: grpc_1.ServerUnaryCall<UpdateParticipantsRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract GetProfilePicture(call: grpc_1.ServerUnaryCall<ProfilePictureRequest, ProfilePictureResponse>, callback: grpc_1.sendUnaryData<ProfilePictureResponse>): void;
abstract SendPresence(call: grpc_1.ServerUnaryCall<PresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
@@ -11628,6 +11864,8 @@ export namespace messages {
abstract SubscribePresence(call: grpc_1.ServerUnaryCall<SubscribePresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract CheckPhones(call: grpc_1.ServerUnaryCall<CheckPhonesRequest, CheckPhonesResponse>, callback: grpc_1.sendUnaryData<CheckPhonesResponse>): void;
abstract MarkChatUnread(call: grpc_1.ServerUnaryCall<ChatUnreadRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract FetchReachoutTimelock(call: grpc_1.ServerUnaryCall<Session, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract FetchMessageCapping(call: grpc_1.ServerUnaryCall<Session, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract GenerateNewMessageID(call: grpc_1.ServerUnaryCall<Session, NewMessageIDResponse>, callback: grpc_1.sendUnaryData<NewMessageIDResponse>): void;
abstract SendMessage(call: grpc_1.ServerUnaryCall<MessageRequest, MessageResponse>, callback: grpc_1.sendUnaryData<MessageResponse>): void;
abstract SendReaction(call: grpc_1.ServerUnaryCall<MessageReaction, MessageResponse>, callback: grpc_1.sendUnaryData<MessageResponse>): void;
@@ -11676,6 +11914,12 @@ export namespace messages {
RequestCode: GrpcUnaryServiceInterface<PairCodeRequest, PairCodeResponse> = (message: PairCodeRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<PairCodeResponse>, options?: grpc_1.CallOptions | grpc_1.requestCallback<PairCodeResponse>, callback?: grpc_1.requestCallback<PairCodeResponse>): grpc_1.ClientUnaryCall => {
return super.RequestCode(message, metadata, options, callback);
};
SubmitPasskeyResponse: GrpcUnaryServiceInterface<PasskeyResponseRequest, Empty> = (message: PasskeyResponseRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SubmitPasskeyResponse(message, metadata, options, callback);
};
ConfirmPasskey: GrpcUnaryServiceInterface<Session, Empty> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.ConfirmPasskey(message, metadata, options, callback);
};
Logout: GrpcUnaryServiceInterface<Session, Empty> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.Logout(message, metadata, options, callback);
};
@@ -11742,6 +11986,9 @@ export namespace messages {
SetGroupAnnounce: GrpcUnaryServiceInterface<JidBoolRequest, Empty> = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SetGroupAnnounce(message, metadata, options, callback);
};
SetGroupMemberAddMode: GrpcUnaryServiceInterface<JidBoolRequest, Empty> = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SetGroupMemberAddMode(message, metadata, options, callback);
};
UpdateGroupParticipants: GrpcUnaryServiceInterface<UpdateParticipantsRequest, JsonList> = (message: UpdateParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, options?: grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, callback?: grpc_1.requestCallback<JsonList>): grpc_1.ClientUnaryCall => {
return super.UpdateGroupParticipants(message, metadata, options, callback);
};
@@ -11763,6 +12010,12 @@ export namespace messages {
MarkChatUnread: GrpcUnaryServiceInterface<ChatUnreadRequest, Empty> = (message: ChatUnreadRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.MarkChatUnread(message, metadata, options, callback);
};
FetchReachoutTimelock: GrpcUnaryServiceInterface<Session, Json> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Json>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Json>, callback?: grpc_1.requestCallback<Json>): grpc_1.ClientUnaryCall => {
return super.FetchReachoutTimelock(message, metadata, options, callback);
};
FetchMessageCapping: GrpcUnaryServiceInterface<Session, Json> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Json>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Json>, callback?: grpc_1.requestCallback<Json>): grpc_1.ClientUnaryCall => {
return super.FetchMessageCapping(message, metadata, options, callback);
};
GenerateNewMessageID: GrpcUnaryServiceInterface<Session, NewMessageIDResponse> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<NewMessageIDResponse>, options?: grpc_1.CallOptions | grpc_1.requestCallback<NewMessageIDResponse>, callback?: grpc_1.requestCallback<NewMessageIDResponse>): grpc_1.ClientUnaryCall => {
return super.GenerateNewMessageID(message, metadata, options, callback);
};
@@ -488,6 +488,17 @@ function deserialize_messages_PairCodeResponse(buffer_arg) {
return gows_pb.PairCodeResponse.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_PasskeyResponseRequest(arg) {
if (!(arg instanceof gows_pb.PasskeyResponseRequest)) {
throw new Error('Expected argument of type messages.PasskeyResponseRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_PasskeyResponseRequest(buffer_arg) {
return gows_pb.PasskeyResponseRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_PresenceRequest(arg) {
if (!(arg instanceof gows_pb.PresenceRequest)) {
throw new Error('Expected argument of type messages.PresenceRequest');
@@ -764,6 +775,28 @@ startSession: {
responseSerialize: serialize_messages_PairCodeResponse,
responseDeserialize: deserialize_messages_PairCodeResponse,
},
submitPasskeyResponse: {
path: '/messages.MessageService/SubmitPasskeyResponse',
requestStream: false,
responseStream: false,
requestType: gows_pb.PasskeyResponseRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_PasskeyResponseRequest,
requestDeserialize: deserialize_messages_PasskeyResponseRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
confirmPasskey: {
path: '/messages.MessageService/ConfirmPasskey',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
logout: {
path: '/messages.MessageService/Logout',
requestStream: false,
@@ -1017,6 +1050,18 @@ setGroupAnnounce: {
responseDeserialize: deserialize_messages_Empty,
},
// send messages only by admins
setGroupMemberAddMode: {
path: '/messages.MessageService/SetGroupMemberAddMode',
requestStream: false,
responseStream: false,
requestType: gows_pb.JidBoolRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_JidBoolRequest,
requestDeserialize: deserialize_messages_JidBoolRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
// who can add members - true - all members, false - admins only
updateGroupParticipants: {
path: '/messages.MessageService/UpdateGroupParticipants',
requestStream: false,
@@ -1098,6 +1143,33 @@ getProfilePicture: {
responseDeserialize: deserialize_messages_Empty,
},
//
// Account
//
// Fetch the account's current reachout timelock state.
fetchReachoutTimelock: {
path: '/messages.MessageService/FetchReachoutTimelock',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.Json,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_Json,
responseDeserialize: deserialize_messages_Json,
},
// Fetch the account's current new-chat message capping (per-cycle quota).
fetchMessageCapping: {
path: '/messages.MessageService/FetchMessageCapping',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.Json,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_Json,
responseDeserialize: deserialize_messages_Json,
},
//
// Message
//
generateNewMessageID: {
+331 -2
View File
@@ -84,6 +84,7 @@ goog.exportSymbol('proto.messages.Pagination', null, global);
goog.exportSymbol('proto.messages.PairCodeRequest', null, global);
goog.exportSymbol('proto.messages.PairCodeResponse', null, global);
goog.exportSymbol('proto.messages.ParticipantAction', null, global);
goog.exportSymbol('proto.messages.PasskeyResponseRequest', null, global);
goog.exportSymbol('proto.messages.PhoneInfo', null, global);
goog.exportSymbol('proto.messages.PollMessage', null, global);
goog.exportSymbol('proto.messages.PollVoteMessage', null, global);
@@ -395,6 +396,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.PairCodeResponse.displayName = 'proto.messages.PairCodeResponse';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.PasskeyResponseRequest = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.PasskeyResponseRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.PasskeyResponseRequest.displayName = 'proto.messages.PasskeyResponseRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -4190,6 +4212,187 @@ proto.messages.PairCodeResponse.prototype.setCode = function(value) {
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.PasskeyResponseRequest.prototype.toObject = function(opt_includeInstance) {
return proto.messages.PasskeyResponseRequest.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.PasskeyResponseRequest} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.PasskeyResponseRequest.toObject = function(includeInstance, msg) {
var f, obj = {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
responseJson: jspb.Message.getFieldWithDefault(msg, 2, "")
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.PasskeyResponseRequest}
*/
proto.messages.PasskeyResponseRequest.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.PasskeyResponseRequest;
return proto.messages.PasskeyResponseRequest.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.PasskeyResponseRequest} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.PasskeyResponseRequest}
*/
proto.messages.PasskeyResponseRequest.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = new proto.messages.Session;
reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader);
msg.setSession(value);
break;
case 2:
var value = /** @type {string} */ (reader.readString());
msg.setResponseJson(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.PasskeyResponseRequest.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.PasskeyResponseRequest.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.PasskeyResponseRequest} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.PasskeyResponseRequest.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getSession();
if (f != null) {
writer.writeMessage(
1,
f,
proto.messages.Session.serializeBinaryToWriter
);
}
f = message.getResponseJson();
if (f.length > 0) {
writer.writeString(
2,
f
);
}
};
/**
* optional Session session = 1;
* @return {?proto.messages.Session}
*/
proto.messages.PasskeyResponseRequest.prototype.getSession = function() {
return /** @type{?proto.messages.Session} */ (
jspb.Message.getWrapperField(this, proto.messages.Session, 1));
};
/**
* @param {?proto.messages.Session|undefined} value
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.setSession = function(value) {
return jspb.Message.setWrapperField(this, 1, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.clearSession = function() {
return this.setSession(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.PasskeyResponseRequest.prototype.hasSession = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* optional string response_json = 2;
* @return {string}
*/
proto.messages.PasskeyResponseRequest.prototype.getResponseJson = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
/**
* @param {string} value
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.setResponseJson = function(value) {
return jspb.Message.setProto3StringField(this, 2, value);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
@@ -4613,7 +4816,9 @@ proto.messages.SessionStorageConfig.toObject = function(includeInstance, msg) {
messages: jspb.Message.getBooleanFieldWithDefault(msg, 1, false),
groups: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
chats: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false)
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false),
contacts: jspb.Message.getBooleanFieldWithDefault(msg, 5, false),
messageSecrets: jspb.Message.getBooleanFieldWithDefault(msg, 6, false)
};
if (includeInstance) {
@@ -4666,6 +4871,14 @@ proto.messages.SessionStorageConfig.deserializeBinaryFromReader = function(msg,
var value = /** @type {boolean} */ (reader.readBool());
msg.setLabels(value);
break;
case 5:
var value = /** @type {boolean} */ (reader.readBool());
msg.setContacts(value);
break;
case 6:
var value = /** @type {boolean} */ (reader.readBool());
msg.setMessageSecrets(value);
break;
default:
reader.skipField();
break;
@@ -4723,6 +4936,20 @@ proto.messages.SessionStorageConfig.serializeBinaryToWriter = function(message,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 5));
if (f != null) {
writer.writeBool(
5,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 6));
if (f != null) {
writer.writeBool(
6,
f
);
}
};
@@ -4870,6 +5097,78 @@ proto.messages.SessionStorageConfig.prototype.hasLabels = function() {
};
/**
* optional bool contacts = 5;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getContacts = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 5, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setContacts = function(value) {
return jspb.Message.setField(this, 5, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearContacts = function() {
return jspb.Message.setField(this, 5, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasContacts = function() {
return jspb.Message.getField(this, 5) != null;
};
/**
* optional bool message_secrets = 6;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getMessageSecrets = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 6, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setMessageSecrets = function(value) {
return jspb.Message.setField(this, 6, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearMessageSecrets = function() {
return jspb.Message.setField(this, 6, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasMessageSecrets = function() {
return jspb.Message.getField(this, 6) != null;
};
@@ -13666,7 +13965,8 @@ proto.messages.PhoneInfo.toObject = function(includeInstance, msg) {
var f, obj = {
phone: jspb.Message.getFieldWithDefault(msg, 1, ""),
jid: jspb.Message.getFieldWithDefault(msg, 2, ""),
registered: jspb.Message.getBooleanFieldWithDefault(msg, 3, false)
registered: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
pn: jspb.Message.getFieldWithDefault(msg, 4, "")
};
if (includeInstance) {
@@ -13715,6 +14015,10 @@ proto.messages.PhoneInfo.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {boolean} */ (reader.readBool());
msg.setRegistered(value);
break;
case 4:
var value = /** @type {string} */ (reader.readString());
msg.setPn(value);
break;
default:
reader.skipField();
break;
@@ -13765,6 +14069,13 @@ proto.messages.PhoneInfo.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getPn();
if (f.length > 0) {
writer.writeString(
4,
f
);
}
};
@@ -13822,6 +14133,24 @@ proto.messages.PhoneInfo.prototype.setRegistered = function(value) {
};
/**
* optional string pn = 4;
* @return {string}
*/
proto.messages.PhoneInfo.prototype.getPn = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, ""));
};
/**
* @param {string} value
* @return {!proto.messages.PhoneInfo} returns this
*/
proto.messages.PhoneInfo.prototype.setPn = function(value) {
return jspb.Message.setProto3StringField(this, 4, value);
};
/**
* List of repeated fields within this message type.
@@ -0,0 +1,48 @@
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
describe('parseGowsReachoutTimelock', () => {
it('parses a full payload', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
is_active: true,
time_enforcement_ends: '1784477333',
});
expect(timelock).toEqual({
enforcementType: 'RESTRICT_ALL_COMPANIONS',
isActive: true,
timeEnforcementEnds: 1784477333,
});
});
it('treats absent is_active as inactive (omitempty)', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
});
expect(timelock.isActive).toBe(false);
});
it('treats absent time_enforcement_ends as null (omitzero)', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
is_active: true,
});
expect(timelock.timeEnforcementEnds).toBeNull();
});
it('defaults enforcement type on an empty payload', () => {
const timelock = parseGowsReachoutTimelock({});
expect(timelock).toEqual({
enforcementType: 'DEFAULT',
isActive: false,
timeEnforcementEnds: null,
});
});
it('returns null for a non numeric time_enforcement_ends', () => {
const timelock = parseGowsReachoutTimelock({
is_active: true,
time_enforcement_ends: 'not-a-number',
});
expect(timelock.timeEnforcementEnds).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
import {
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
/**
* Normalize whatsmeow's events.NotifyAccountReachoutTimelock payload.
*/
export function parseGowsReachoutTimelock(data: any): ReachoutTimelockData {
// 'time_enforcement_ends' is a unix seconds string, absent when zero (Go's omitzero)
let timeEnforcementEnds: number | null = null;
if (data?.time_enforcement_ends) {
const parsed = parseInt(data.time_enforcement_ends, 10);
timeEnforcementEnds = Number.isNaN(parsed) ? null : EnsureSeconds(parsed);
}
const enforcementType =
data?.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
return {
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
// 'is_active' is absent when false (Go's omitempty)
isActive: data?.is_active === true,
timeEnforcementEnds: timeEnforcementEnds,
};
}
+575 -26
View File
@@ -28,16 +28,15 @@ import {
parseJsonList,
statusToAck,
} from '@waha/core/engines/gows/helpers';
import { parseMessageCapping } from '@waha/core/abc/capping';
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore';
import {
extractBody,
getDestination,
} from '@waha/core/engines/noweb/session.noweb.core';
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
@@ -46,10 +45,15 @@ import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import {
isJidBroadcast,
isJidGroup,
isJidNewsletter,
normalizeJid,
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
import {
PasskeyChallenge,
PasskeyConfirmationResponse,
} from '@waha/structures/auth.dto';
import {
Channel,
ChannelListResult,
@@ -78,6 +82,7 @@ import {
ChatRequest,
CheckNumberStatusQuery,
EditMessageRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
@@ -89,6 +94,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -114,6 +120,7 @@ import {
GroupSortField,
Participant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
@@ -130,13 +137,18 @@ import {
import { CallData } from '@waha/structures/calls.dto';
import {
MeInfo,
MessageCappingData,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
} from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -192,10 +204,17 @@ import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { TmpDir } from '@waha/utils/tmpdir';
import { detectMimetype } from '@waha/utils/files';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { sortObjectByValues } from '@waha/helpers';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import * as path from 'path';
import MessageServiceClient = messages.MessageServiceClient;
import * as fsp from 'fs/promises';
axiosRetry(axios, { retries: 3 });
function getGowsStorageConfig(
sessionConfig?: SessionConfig,
): messages.SessionStorageConfig {
@@ -206,6 +225,8 @@ function getGowsStorageConfig(
groups: storeConfig?.groups !== false,
chats: storeConfig?.chats !== false,
labels: storeConfig?.labels !== false,
contacts: storeConfig?.contacts !== false,
message_secrets: storeConfig?.messageSecrets !== false,
});
}
@@ -221,6 +242,8 @@ enum WhatsMeowEvent {
CHAT_PRESENCE = 'events.ChatPresence',
PUSH_NAME_SETTING = 'events.PushNameSetting',
LOGGED_OUT = 'events.LoggedOut',
NOTIFY_ACCOUNT_REACHOUT_TIMELOCK = 'events.NotifyAccountReachoutTimelock',
MESSAGE_CAPPING = 'gows.MessageCapping',
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
@@ -409,6 +432,25 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (data.Event == 'success') {
return;
}
if (data.Event == 'passkey-request') {
// WhatsApp requires a passkey (WebAuthn) to finish pairing this account.
const challenge = data.PasskeyRequest?.PublicKey ?? null;
this.logger.info('Passkey required to finish pairing');
this.setStatus(WAHASessionStatus.PASSKEY_REQUIRED, challenge);
return;
}
if (data.Event == 'passkey-confirmation') {
// Only the manual case reaches us - when WhatsApp allows skipping the
// handoff UX, whatsmeow confirms on its own and emits nothing.
// The operator must see the code, verify it matches the one shown on
// their phone, then confirm via POST .../auth/passkey/confirm.
const code = data.PasskeyConfirmation?.Code ?? null;
this.logger.info({ code: code }, 'Passkey confirmation code');
this.setStatus(WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED, {
code: code,
});
return;
}
if (data.Event != 'code') {
this.logger.warn(data, 'Failed QR item event');
this.status = WAHASessionStatus.FAILED;
@@ -420,6 +462,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
this.qr.save(qr);
this.printQR(this.qr);
if (
this.status === WAHASessionStatus.PASSKEY_REQUIRED ||
this.status === WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED
) {
// The underlying whatsmeow QR rotation keeps emitting fresh codes in
// parallel while the passkey challenge is pending (it doesn't know
// about the passkey step). Don't let that bounce the session back to
// SCAN_QR_CODE mid-flow — the operator is busy signing the passkey.
// It'd also wipe the passkey data off the status.
return;
}
this.status = WAHASessionStatus.SCAN_QR_CODE;
});
events.on(WhatsMeowEvent.PUSH_NAME_SETTING, (data) => {
@@ -429,6 +482,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.logger.error('Logged out');
this.status = WAHASessionStatus.FAILED;
});
events.on(WhatsMeowEvent.NOTIFY_ACCOUNT_REACHOUT_TIMELOCK, (data) => {
this.reachoutTimelock.update(parseGowsReachoutTimelock(data));
});
events.on(WhatsMeowEvent.MESSAGE_CAPPING, (data) => {
this.messageCapping.update(parseMessageCapping(data));
});
events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => {
if (isJidGroup(event.From)) {
// So group is not "online"
@@ -839,12 +898,49 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return { code: code };
}
public async sendPasskeyResponse(responseJson: string): Promise<void> {
const request = new messages.PasskeyResponseRequest({
session: this.session,
response_json: responseJson,
});
await promisify(this.client.SubmitPasskeyResponse)(request);
}
public async confirmPasskey(): Promise<void> {
await promisify(this.client.ConfirmPasskey)(this.session);
}
public getPasskeyChallenge(): PasskeyChallenge {
if (this.status !== WAHASessionStatus.PASSKEY_REQUIRED) {
throw new UnprocessableEntityException(
'No passkey challenge is pending for the session',
);
}
return this.statusData;
}
public getPasskeyConfirmation(): PasskeyConfirmationResponse {
if (this.status !== WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED) {
throw new UnprocessableEntityException(
'No passkey confirmation is pending for the session',
);
}
return { code: this.statusData?.code };
}
async unpair() {
await promisify(this.client.Logout)(this.session);
}
public getSessionMeInfo(): MeInfo | null {
return this.me;
if (!this.me) {
return null;
}
return {
...this.me,
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
/**
@@ -899,12 +995,46 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
private async fileToMedia(
file: RemoteFile | BinaryFile,
): Promise<messages.Media> {
let content: Buffer;
if ('url' in file) {
// fetch file
content = await this.fetch(file.url);
} else {
// base64 to bytes
content = Buffer.from(file.data, 'base64');
}
return new messages.Media({
content: content,
mimetype: file.mimetype,
filename: file.filename,
});
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
@Activity()
protected async setProfilePicture(
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
const request = new messages.SetProfilePictureRequest({
session: this.session,
picture: media.content,
});
const response = await promisify(this.client.SetProfilePicture)(request);
response.toObject();
return true;
}
protected async deleteProfilePicture(): Promise<boolean> {
const request = new messages.SetProfilePictureRequest({
session: this.session,
});
const response = await promisify(this.client.SetProfilePicture)(request);
response.toObject();
return true;
}
/**
@@ -1008,12 +1138,53 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(jid, data);
}
sendPollVote(request: MessagePollVoteRequest) {
throw new AvailableInPlusVersion('Poll voting');
@Activity()
async sendPollVote(request: MessagePollVoteRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const key = parseMessageIdSerialized(request.pollMessageId, true);
const pollVote = new messages.PollVoteMessage({
pollMessageId: key.id,
options: request.votes,
});
if (request.pollServerId != null) {
// protobuf expects int64 number
pollVote.pollServerId = request.pollServerId;
}
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
pollVote: pollVote,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
sendList(request: SendListRequest): Promise<any> {
throw new AvailableInPlusVersion();
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
);
}
const m = request.message;
const list = messages.ListMessage.fromObject({
title: m.title,
description: m.description,
footer: m.footer,
button: m.button,
sections: m.sections,
});
const message = new messages.MessageRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.reply_to),
list: list,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
@@ -1106,6 +1277,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return {
numberExists: info?.registered || false,
chatId: toCusFormat(info?.jid || null),
pn: toCusFormat(info?.pn || null),
};
}
@@ -1132,22 +1304,209 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
throw new NotImplementedByEngineError();
}
sendImage(request: MessageImageRequest) {
throw new AvailableInPlusVersion();
private async sendMedia(type: messages.MediaType, request: any) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const media = await this.fileToMedia(request.file);
media.type = type;
if (type === messages.MediaType.IMAGE) {
media.mimetype = media.mimetype || WAMimeType.IMAGE;
} else if (type === messages.MediaType.AUDIO) {
media.mimetype = media.mimetype || WAMimeType.VOICE;
} else if (
type === messages.MediaType.VIDEO ||
type === messages.MediaType.PTV
) {
media.mimetype = media.mimetype || WAMimeType.VIDEO;
} else if (type === messages.MediaType.DOCUMENT) {
if (!media.mimetype) {
media.mimetype = await detectMimetype(media.content as Buffer);
}
}
if (request.convert) {
switch (type) {
case messages.MediaType.AUDIO:
media.content = await this.mediaConverter.voice(
media.content as Buffer,
);
media.mimetype = WAMimeType.VOICE;
break;
case messages.MediaType.VIDEO:
media.content = await this.mediaConverter.video(
media.content as Buffer,
);
media.mimetype = WAMimeType.VIDEO;
break;
case messages.MediaType.PTV:
media.content = await this.mediaConverter.video(
media.content as Buffer,
);
media.mimetype = WAMimeType.VIDEO;
break;
default:
this.logger.warn(`No conversion for ${type}`);
break;
}
}
// Only for Voice Status
let backgroundColor: messages.OptionalString | null = null;
if (request.backgroundColor) {
backgroundColor = new messages.OptionalString({
value: request.backgroundColor,
});
}
const participants = await this.prepareJidsForStatus(request.contacts);
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
text: request.caption,
session: this.session,
media: media,
backgroundColor: backgroundColor,
mentions: request.mentions?.map((mention) =>
normalizeJid(toJID(mention)),
),
participants: participants,
});
if (media.type == messages.MediaType.AUDIO) {
const logger: any = this.loggerBuilder.child({});
const buffer = Buffer.from(media.content);
const waveform = await esm.b.getAudioWaveform(buffer, logger);
const duration = await esm.b.getAudioDuration(buffer);
media.audio = new messages.AudioInfo({
waveform: waveform,
duration: duration,
});
}
if (
media.type == messages.MediaType.VIDEO ||
media.type == messages.MediaType.PTV
) {
const buffer = Buffer.from(media.content);
const duration = await esm.b.getAudioDuration(buffer).catch((err) => {
this.logger.warn({ error: err }, 'Failed to get video duration');
return undefined;
});
const isGif = request.file?.mimetype === 'image/gif';
media.video = new messages.VideoInfo({
duration: duration,
gifPlayback: isGif,
externalShareFullVideoDurationInSeconds: isGif ? 0 : undefined,
});
}
message.replyTo = getMessageIdFromSerialized(request.reply_to);
const tmpdir = new TmpDir(this.logger, `waha-smedia-${this.name}-`);
return await tmpdir.use(async (dir) => {
const file = path.join(dir, 'send-media.tmp');
// Try to write to the file
try {
await fsp.writeFile(file, Buffer.from(media.content));
media.contentPath = file;
media.content = null;
} catch (e) {
this.logger.error(`Failed to write media to temp file: ${e.message}`);
}
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
});
}
sendFile(request: MessageFileRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
return await this.sendMedia(messages.MediaType.IMAGE, request);
}
sendVoice(request: MessageVoiceRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
return await this.sendMedia(messages.MediaType.DOCUMENT, request);
}
sendLinkCustomPreview(
@Activity()
async sendVoice(request: MessageVoiceRequest) {
return await this.sendMedia(messages.MediaType.AUDIO, request);
}
@Activity()
async sendVideo(request: MessageVideoRequest) {
const type = request.asNote
? messages.MediaType.PTV
: messages.MediaType.VIDEO;
return await this.sendMedia(type, request);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
throw new AvailableInPlusVersion();
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const media = await this.fileToMedia(request.preview.image as RemoteFile);
const preview = new messages.LinkPreview({
url: request.preview.url,
title: request.preview.title,
description: request.preview.description,
image: media.content,
});
const message = new messages.MessageRequest({
jid: jid,
text: request.text,
session: this.session,
linkPreview: true,
linkPreviewHighQuality: request.linkPreviewHighQuality,
replyTo: getMessageIdFromSerialized(request.reply_to),
preview: preview,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
async sendButtonsReply(request: MessageButtonReply) {
throw new NotImplementedByEngineError();
// Doesn't work yet
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const message = new messages.ButtonReplyRequest({
jid: jid,
session: this.session,
replyTo: getMessageIdFromSerialized(request.replyTo),
selectedDisplayText: request.selectedDisplayText,
selectedButtonID: request.selectedButtonID,
});
const response = await promisify(this.client.SendButtonReply)(message);
const data = response.toObject();
return this.messageResponse(jid, data);
}
@Activity()
public async sendImageStatus(status: ImageStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.IMAGE, request);
}
@Activity()
public async sendVoiceStatus(status: VoiceStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.AUDIO, request);
}
@Activity()
public async sendVideoStatus(status: VideoStatus) {
const request = {
...status,
chatId: Jid.BROADCAST,
};
return await this.sendMedia(messages.MediaType.VIDEO, request);
}
@Activity()
@@ -1306,6 +1665,24 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return;
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.getGroup(id);
return {
membersCanAddNewMember: group.MemberAddMode === 'all_member_add',
};
}
@Activity()
public async setMemberAddMode(id, value) {
const req = new messages.JidBoolRequest({
session: this.session,
jid: id,
value: value,
});
await promisify(this.client.SetGroupMemberAddMode)(req);
return;
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
@@ -1339,6 +1716,33 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
await promisify(this.client.SetGroupName)(req);
}
@Activity()
protected async setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
const request = new messages.SetPictureRequest({
session: this.session,
jid: id,
picture: media.content,
});
const response = await promisify(this.client.SetGroupPicture)(request);
response.toObject();
return true;
}
@Activity()
protected async deleteGroupPicture(id: string): Promise<boolean> {
const request = new messages.SetPictureRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.SetGroupPicture)(request);
response.toObject();
return true;
}
@Activity()
public async getInviteCode(id): Promise<string> {
const req = new messages.JidRequest({
@@ -1612,23 +2016,122 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
@Activity()
public async searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const request = new messages.SearchNewslettersByViewRequest({
session: this.session,
view: query.view,
categories: query.categories,
countries: query.countries,
page: new messages.SearchPage({
limit: query.limit,
startCursor: query.startCursor,
}),
});
const response = await promisify(this.client.SearchNewslettersByView)(
request,
);
return this.channelsRawDataToResponse(response);
}
public searchChannelsByText(
@Activity()
public async searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const request = new messages.SearchNewslettersByTextRequest({
session: this.session,
text: query.text,
categories: query.categories,
page: new messages.SearchPage({
limit: query.limit,
startCursor: query.startCursor,
}),
});
const response = await promisify(this.client.SearchNewslettersByText)(
request,
);
return this.channelsRawDataToResponse(response);
}
private channelsRawDataToResponse(
data: messages.NewsletterSearchPageResult,
): ChannelListResult {
const channels: Channel[] = data.newsletters.newsletters.map(
this.toChannel.bind(this),
);
channels.forEach((channel) => {
delete channel.role;
});
return {
page: {
startCursor: data.page.startCursor,
endCursor: data.page.endCursor,
hasNextPage: data.page.hasNextPage,
hasPreviousPage: data.page.hasPreviousPage,
},
channels: channels,
};
}
@Activity()
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
const downloadMedia = query.downloadMedia;
const request = new messages.GetNewsletterMessagesByInviteRequest({
session: this.session,
invite: inviteCode,
limit: query.limit,
});
const response = await promisify(this.client.GetNewsletterMessagesByInvite)(
request,
);
const resp = parseJson(response);
const promises = [];
if (!resp.Messages) {
return [];
}
for (const msg of resp.Messages) {
promises.push(
this.GowsChannelMessageToChannelMessage(
resp.NewsletterJID,
msg,
downloadMedia,
),
);
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
return result;
}
private async GowsChannelMessageToChannelMessage(
jid: string,
channelMessage: any,
downloadMedia: boolean,
): Promise<ChannelMessage> {
const msg = {
Info: {
ID: channelMessage.MessageID,
ServerID: channelMessage.MessageServerID,
Chat: jid,
Sender: jid,
IsFromMe: false,
Timestamp: channelMessage.Timestamp,
},
Message: channelMessage.Message,
};
const message = await this.processIncomingMessage(msg, downloadMedia);
const reactions: any =
sortObjectByValues(channelMessage.ReactionCounts) || {};
return {
message: message,
reactions: reactions,
viewCount: channelMessage.ViewsCount,
};
}
protected toChannel(newsletter: messages.Newsletter): Channel {
@@ -1676,10 +2179,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async channelsCreateChannel(
request: CreateChannelRequest,
): Promise<Channel> {
let media: messages.Media;
if (request.picture) {
media = await this.fileToMedia(request.picture);
}
const req = new messages.CreateNewsletterRequest({
session: this.session,
name: request.name,
description: request.description,
picture: media?.content,
});
const response = await promisify(this.client.CreateNewsletter)(req);
const newsletter = response.toObject() as messages.Newsletter;
@@ -1781,6 +2289,28 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.toWAContact(data);
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const response = await promisify(this.client.FetchMessageCapping)(
this.session,
);
const capping = parseMessageCapping(parseJson(response));
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const response = await promisify(this.client.FetchReachoutTimelock)(
this.session,
);
const timelock = parseGowsReachoutTimelock(parseJson(response));
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.reachoutTimelock.update(timelock);
return timelock;
}
public async getContacts(pagination: PaginationParams) {
const request = new messages.GetContactsRequest({
session: this.session,
@@ -2555,6 +3085,19 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
}
/**
* gRPC status codes returned by GOWS DownloadMedia that represent a definitive
* failure (the media cannot be fetched right now and retrying won't help). Used
* to tag the error as non-retriable so MediaManager doesn't re-issue the call.
*/
const NON_RETRIABLE_DOWNLOAD_MEDIA_CODES: Set<number> = new Set([
grpc.status.FAILED_PRECONDITION,
grpc.status.NOT_FOUND,
grpc.status.INVALID_ARGUMENT,
grpc.status.PERMISSION_DENIED,
grpc.status.UNIMPLEMENTED,
]);
/**
* Many encrypted stickers carry URL "https://a.whatsapp.net" with no path. If
* that string is passed to DownloadMedia, the Go client may attempt HTTP GET to
@@ -2659,6 +3202,12 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
} catch (err) {
if (err?.code === grpc.status.DEADLINE_EXCEEDED) {
err.message = `DownloadMedia timed out after ${mediaDownloadTimeoutMs}ms for message '${message?.Info?.ID}'`;
} else if (NON_RETRIABLE_DOWNLOAD_MEDIA_CODES.has(err?.code)) {
// The media is not currently downloadable (e.g. CDN 403 after the
// anonymous + media-retry fallbacks, or the object is gone). Retrying
// the gRPC call won't help and each attempt can block on a media-retry
// wait, so mark it so MediaManager stops retrying.
err.nonRetriable = true;
}
throw err;
}
@@ -3,15 +3,23 @@ import * as path from 'node:path';
import { DataStore } from '@waha/core/abc/DataStore';
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { GowsAuth } from './GowsAuth';
export class GowsAuthFactoryCore {
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
await store.init(name);
const connection = store.getSessionDbURL(name);
return new GowsAuthSimple(connection, 'postgres');
}
protected async buildSqlite3(
store: LocalStore,
name: string,
+41 -2
View File
@@ -1,17 +1,56 @@
import { DataStore } from '../../abc/DataStore';
import { LocalStore } from '../../storage/LocalStore';
import { DataStore } from '@waha/core/abc/DataStore';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
import { join } from 'path';
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
import { useMultiFileAuthState } from './useMultiFileAuthState';
export class NowebAuthFactoryCore {
buildAuth(store: DataStore, name: string) {
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
private async buildMongoAuth(store: MongoStore, name: string) {
const db = store.getSessionDb(name);
const authStore = new NoWebMongoDbAuth(db);
await authStore.init();
return authStore.methods();
}
async buildPsql(store: PsqlStore, name: string) {
const knex = store.buildSessionKnex(name, 'Session/Auth');
const authStore = new NoWebPsqlAuth(knex);
await authStore.init();
return authStore.methods();
}
protected async buildLocalAuth(store: LocalStore, name: string) {
// Quick fix for
// https://github.com/devlikeapro/waha/issues/347
// We can remove it after some time and the fix
// https://github.com/WhiskeySockets/Baileys/pull/824
await this.makeCredsJsonValid(store, name);
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
return { state, saveCreds, close };
}
private async makeCredsJsonValid(store: LocalStore, name: string) {
try {
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const credsPath = join(authFolder, 'creds.json');
await makeSureJsonFile(credsPath);
} catch (error) {
console.error('Failed to fix "creds.json" file, continue...', error);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import type makeWASocket from '@adiwajshing/baileys';
import type { QueryIds } from '@adiwajshing/baileys';
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
import {
ChannelPagination,
ChannelSearchByText,
ChannelSearchByView,
} from '@waha/structures/channels.dto';
enum NewsletterMexQueryIds {
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
}
enum NewsletterXWAPaths {
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
}
/**
* NowebClient - wrapper around baileys to have more methods
*/
export interface NewsletterSearchResponse {
page: ChannelPagination;
newsletters: NewsletterMetadata[];
}
export class NowebClient {
constructor(private sock: ReturnType<typeof makeWASocket>) {}
async searchChannelsByView(
query: ChannelSearchByView,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
view: query.view,
filters: {
country_codes: query.countries,
categories: query.categories,
},
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
async searchChannelsByText(
query: ChannelSearchByText,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
search_text: query.text,
categories: query.categories,
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
}
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
const pageInfo = response.page_info;
const page: ChannelPagination = {
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
};
const newsletterResult: any[] = response.result;
const newsletters = newsletterResult.map(toNewsletterMetadata);
return {
page: page,
newsletters: newsletters as any,
};
}
+37 -8
View File
@@ -17,7 +17,7 @@ import {
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import { toCusFormat } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat } from '@waha/core/utils/jids';
import esm from '@waha/vendor/esm';
export function ToGroupInfo(group: Partial<GroupMetadata>): GroupInfo {
@@ -68,7 +68,7 @@ interface GroupParticipantUpdate {
action: ParticipantAction;
}
function getParticipantId(
export function getParticipantId(
participant: string | NOWEBGroupParticipant,
): string | undefined {
if (typeof participant === 'string') {
@@ -77,6 +77,39 @@ function getParticipantId(
return participant?.id;
}
function getParticipantPn(
participant: string | NOWEBGroupParticipant,
): string | null {
if (typeof participant === 'string') {
return isPnUser(participant) ? participant : null;
}
return participant?.phoneNumber || null;
}
function getParticipantIds(
participant: string | NOWEBGroupParticipant,
): string[] {
if (typeof participant === 'string') {
return [participant];
}
return [participant?.id, participant?.phoneNumber].filter(Boolean);
}
/**
* Check if me.id or me.lid in participant list
*/
export function participantsIncludeMe(
me: Contact,
participants: Array<string | NOWEBGroupParticipant>,
): boolean {
const myIds = [me.id, me.lid].filter(Boolean);
return participants.some((participant) =>
getParticipantIds(participant).some((id) =>
myIds.some((meId) => esm.b.areJidsSameUser(id, meId)),
),
);
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
@@ -105,6 +138,7 @@ export function ToGroupV2Participants(
const id = getParticipantId(item);
return {
id: toCusFormat(id),
pn: toCusFormat(getParticipantPn(item)),
role: role,
};
});
@@ -140,12 +174,7 @@ export function ToGroupV2LeaveEvent(
if (!me) {
return null;
}
const meId = esm.b.jidNormalizedUser(me.id);
const includesMe = update.participants.some((participant) => {
const id = getParticipantId(participant);
return id === meId;
});
if (!includesMe) {
if (!participantsIncludeMe(me, update.participants)) {
return null;
}
+642 -70
View File
@@ -23,6 +23,7 @@ import makeWASocket, {
WAMessageContent,
WAMessageKey,
WAMessageUpdate,
WAVersion,
} from '@adiwajshing/baileys';
import { WACallEvent } from '@adiwajshing/baileys/lib/Types/Call';
import { BaileysEventMap } from '@adiwajshing/baileys/lib/Types/Events';
@@ -36,9 +37,14 @@ import {
LabelAssociationType,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message';
import type {
MediaGenerationOptions,
NewsletterFetchedUpdate,
} from '@adiwajshing/baileys/lib/Types';
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { UnprocessableEntityException } from '@nestjs/common';
import { parseMessageCapping } from '@waha/core/abc/capping';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
@@ -58,10 +64,7 @@ import {
} from '@waha/core/engines/noweb/noweb.newsletter';
import { NowebAuthFactoryCore } from '@waha/core/engines/noweb/NowebAuthFactoryCore';
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { toVcardV3 } from '@waha/core/vcard';
import { createAgentProxy } from '@waha/core/helpers.proxy';
import type { Agent } from 'https';
@@ -72,9 +75,19 @@ import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
import { pairs } from '@waha/utils/pairs';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { isJidNewsletter, toCusFormat, toJID } from '@waha/core/utils/jids';
import {
isJidNewsletter,
jidsFromKey,
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
import { flipObject, splitAt } from '@waha/helpers';
import {
flipObject,
parseBool,
sortObjectByValues,
splitAt,
} from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import { CallData } from '@waha/structures/calls.dto';
import {
@@ -118,6 +131,7 @@ import {
MessageReplyRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -137,11 +151,12 @@ import {
WAHASessionStatus,
WAMessageAck,
} from '@waha/structures/enums.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupParticipant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import {
@@ -159,12 +174,21 @@ import {
WAHAPresenceData,
} from '@waha/structures/presence.dto';
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import {
MeInfo,
MessageCappingData,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
StatusRequest,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -175,7 +199,7 @@ import {
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { exclude } from '@waha/utils/reactive/ops/exclude';
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
@@ -197,6 +221,7 @@ import {
} from 'rxjs';
import { debounceTime, map } from 'rxjs/operators';
import { NowebClient } from './NowebClient';
import { INowebStore } from './store/INowebStore';
import { NowebPersistentStore } from './store/NowebPersistentStore';
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
@@ -215,13 +240,21 @@ import {
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import {
WAHA_CLIENT_BROWSER_NAME,
WAHA_CLIENT_DEVICE_NAME,
} from '@waha/core/env';
import { detectMimetype } from '@waha/utils/files';
import esm from '@waha/vendor/esm';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { formatWaVersion } from '@waha/core/engines/noweb/waversion';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
axiosRetry(axios, { retries: 3 });
export const BaileysEvents = {
CONNECTION_UPDATE: 'connection.update',
CREDS_UPDATE: 'creds.update',
@@ -241,11 +274,18 @@ const PresenceStatuses = {
};
const ToEnginePresenceStatus = flipObject(PresenceStatuses);
export interface NowebConfig {
waVersion: WAVersion;
}
export class WhatsappSessionNoWebCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
private AUTO_RESTART_AFTER_SECONDS = 28 * 60;
// how long to wait on stop for the WebSocket close handshake and store close before forcing it
private CLOSE_TIMEOUT_MS = 3_000;
engine = WAHAEngine.NOWEB;
protected engineConfig: NowebConfig;
authFactory = new NowebAuthFactoryCore();
storageFactory = new NowebStorageFactoryCore();
private startDelayedJob: SingleDelayedJobRunner;
@@ -363,7 +403,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (markOnlineOnConnect == undefined) {
markOnlineOnConnect = true;
}
const version = this.engineConfig?.waVersion;
return {
version: version,
agent: agents?.socket,
// Baileys media upload uses Node https.request in Node runtime.
fetchAgent: agents?.fetch as Agent,
@@ -401,6 +443,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
agents,
state,
) as SocketConfig;
this.logger.info(
`Connecting using wa.version = ${formatWaVersion(socketConfig.version)}`,
);
const sock = makeWASocket(socketConfig);
sock.ev.on('creds.update', saveCreds);
return sock;
@@ -522,13 +567,33 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected listenConnectionEvents() {
this.logger.debug(`Start listening ${BaileysEvents.CONNECTION_UPDATE}...`);
this.sock.ev.on('message-capping.update', (data) => {
this.messageCapping.update(parseMessageCapping(data));
});
this.sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr, isNewLogin } = update;
if (update.reachoutTimeLock) {
this.updateReachoutTimelockFromState(update.reachoutTimeLock);
}
if (isNewLogin) {
this.restartClient();
} else if (connection === 'open') {
this.qr.save('');
this.status = WAHASessionStatus.WORKING;
// Ask WhatsApp for the current reachout timelock state, so a restarted session learns about
// an ongoing timelock without waiting for a push. The result arrives via 'connection.update'
this.sock?.fetchAccountReachoutTimelock?.().catch((error) => {
this.logger.warn(`Failed to fetch reachout timelock: ${error}`);
});
// Same for the new-chat message capping - there is no push on (re)connect, only on changes
this.sock
?.fetchNewChatMessageCap?.()
.then((data) => {
this.messageCapping.update(parseMessageCapping(data));
})
.catch((error) => {
this.logger.warn(`Failed to fetch message capping: ${error}`);
});
// Do we need to resubscribe?
// Ideally not, we need to explicitly call interesting
// jids every 1 minute
@@ -606,16 +671,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
});
this.logger.info('Creds saved');
}
await this.end();
await this.closeStores();
this.status = WAHASessionStatus.STOPPED;
this.stopEvents();
this.mediaManager.close();
await this.end();
await this.store?.close();
this.authNOWEBStore?.close().catch((err) => {
this.logger.error('Failed to close NOWEB auth store');
this.logger.error(err, err.stack);
});
}
protected async failed() {
@@ -631,8 +691,34 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
await sleep(1_000);
}
this.stopEvents();
this.mediaManager.close();
await this.end();
await this.store?.close();
await this.closeStores();
}
/**
* Close the data and auth stores with a time bound - close() may flush pending writes,
* and broken storage must not block session stop or process shutdown
*/
private async closeStores() {
if (this.store) {
await promiseTimeout(this.CLOSE_TIMEOUT_MS, this.store.close()).catch(
(err) => {
this.logger.error('Failed to close NOWEB store');
this.logger.error(err, err.stack);
},
);
}
if (this.authNOWEBStore) {
await promiseTimeout(
this.CLOSE_TIMEOUT_MS,
this.authNOWEBStore.close(),
).catch((err) => {
this.logger.error('Failed to close NOWEB auth store');
this.logger.error(err, err.stack);
});
}
}
private fixMessages() {
@@ -812,13 +898,41 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.cleanupPresenceTimeout();
this.presence = null;
this.autoRestartJob.stop();
const sock = this.sock;
// @ts-ignore
this.sock?.ev?.removeAllListeners();
this.sock?.ws?.removeAllListeners();
sock?.ev?.removeAllListeners();
sock?.ws?.removeAllListeners();
// wait until connection is not connecting to avoid error:
// "WebSocket was closed before the connection was established"
await waitUntil(async () => !this.sock?.ws?.isConnecting, 1_000, 10_000);
this.sock?.end(undefined);
await waitUntil(async () => !sock?.ws?.isConnecting, 1_000, 10_000);
if (!sock) {
return;
}
// sock.end() waits for the WebSocket close handshake - on a dead or already closed connection
// it can hang forever, so bound it and destroy the raw TCP socket to let the process exit
const closing = sock.end(undefined);
try {
await promiseTimeout(this.CLOSE_TIMEOUT_MS, closing);
} catch (err) {
this.logger.warn(
`WebSocket did not close in ${this.CLOSE_TIMEOUT_MS}ms, terminating it: ${err}`,
);
this.terminate(sock);
}
}
/**
* Destroy the raw TCP socket behind Baileys WebSocket wrapper.
* ws.terminate() skips the close handshake, so it works even on half-open connections.
*/
private terminate(sock: ReturnType<typeof makeWASocket>) {
// 'socket' is protected on Baileys WebSocketClient, reach it at runtime
const raw = (sock.ws as any)?.socket;
try {
raw?.terminate?.();
} catch (err) {
this.logger.warn(`Failed to terminate WebSocket: ${err}`);
}
}
getSessionMeInfo(): MeInfo | null {
@@ -831,6 +945,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
id: toCusFormat(meId),
pushName: me.name,
lid: jidNormalizedUser(me.lid),
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
@@ -905,12 +1021,53 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
@Activity()
protected async setProfilePicture(
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const content: Buffer = await this.fileToBuffer(file);
const me = this.getSessionMeInfo();
await this.sock.updateProfilePicture(me.id, content);
return true;
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
protected async deleteProfilePicture(): Promise<boolean> {
const me = this.getSessionMeInfo();
await this.sock.removeProfilePicture(me.id);
return true;
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const data = await this.sock.fetchNewChatMessageCap();
const capping = parseMessageCapping(data);
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const state = await this.sock.fetchAccountReachoutTimelock();
return this.updateReachoutTimelockFromState(state);
}
private updateReachoutTimelockFromState(timelock: any): ReachoutTimelockData {
const enforcementType =
timelock.enforcementType ?? ReachoutTimelockEnforcementType.DEFAULT;
let timeEnforcementEnds: number | null = null;
if (timelock.timeEnforcementEnds) {
timeEnforcementEnds = EnsureSeconds(
timelock.timeEnforcementEnds.getTime(),
);
}
const data: ReachoutTimelockData = {
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
isActive: timelock.isActive === true,
timeEnforcementEnds: timeEnforcementEnds,
};
this.reachoutTimelock.update(data);
return data;
}
/**
@@ -1061,32 +1218,217 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return await this.sock.sendMessage(request.chatId, message, options);
}
sendImage(request: MessageImageRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
const message: any = await this.fileToMessage(
request.file,
'image',
request.caption,
);
message.mimetype = message.mimetype || WAMimeType.IMAGE;
const chatId = toJID(this.ensureSuffix(request.chatId));
// Baileys' newsletter media path skips thumbnail and dimension computation.
// Pre-compute them so iOS renders the image with the correct aspect ratio.
if (isJidNewsletter(chatId)) {
try {
const thumb = await esm.b.extractImageThumb(message.image, 72);
message.jpegThumbnail = thumb.buffer;
message.width = thumb.original.width;
message.height = thumb.original.height;
} catch (err) {
this.logger.warn(
{ error: err },
'Failed to generate thumbnail for newsletter image',
);
}
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendFile(request: MessageFileRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
const message: any = await this.fileToMessage(
request.file,
'document',
request.caption,
);
if (!message.mimetype) {
message.mimetype = await detectMimetype(message['document']);
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendVoice(request: MessageVoiceRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendVoice(request: MessageVoiceRequest) {
const message: any = await this.fileToMessage(request.file, 'audio');
message.mimetype = message.mimetype || WAMimeType.VOICE;
if (request.convert) {
message['audio'] = await this.mediaConverter.voice(message['audio']);
message.mimetype = WAMimeType.VOICE;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
sendLinkCustomPreview(
@Activity()
async sendVideo(request: MessageVideoRequest) {
const message: any = await this.fileToMessage(
request.file,
'video',
request.caption,
);
message.mimetype = message.mimetype || WAMimeType.VIDEO;
if (request.convert) {
message['video'] = await this.mediaConverter.video(message['video']);
message.mimetype = WAMimeType.VIDEO;
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
}
const duration = await esm.b
.getAudioDuration(message['video'])
.catch((err) => {
this.logger.warn({ error: err }, 'Failed to get video duration');
return undefined;
});
message.seconds = duration;
const isGif = request.file?.mimetype === 'image/gif';
if (isGif) {
message.gifPlayback = true;
message.externalShareFullVideoDurationInSeconds = 0;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
message.ptv = parseBool(request.asNote);
return this.sock.sendMessage(chatId, message, options);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
throw new AvailableInPlusVersion();
const chatId = toJID(this.ensureSuffix(request.chatId));
const options = await this.getMessageOptions(request);
const preview = request.preview;
const urlInfo = {
'matched-text': preview.url,
title: preview.title,
description: preview.description,
jpegThumbnail: null,
highQualityThumbnail: null,
};
if (request.preview.image) {
const content: Buffer = await this.fileToBuffer(request.preview.image);
if (!request.linkPreviewHighQuality) {
// generate built-in thumbnail
const thumbnail = await esm.b.extractImageThumb(content, 192);
urlInfo.jpegThumbnail = thumbnail.buffer;
} else {
// upload HQ thumbnail
// newsletters need the thumbnail uploaded unencrypted (plaintext path),
// otherwise clients can not render it (white/blurred preview image)
const uploadToServer = this.sock.waUploadToServer;
const uploadThumbnail = async (encFilePath, opts) => {
opts.newsletter = isJidNewsletter(chatId);
return await uploadToServer(encFilePath, opts);
};
const { imageMessage } = await esm.b.prepareWAMessageMedia(
{ image: content },
{
upload: uploadThumbnail,
mediaTypeOverride: 'thumbnail-link',
options: { signal: AbortSignal.timeout(10_000) },
jid: chatId,
},
);
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
? Buffer.from(imageMessage.jpegThumbnail)
: undefined;
urlInfo.highQualityThumbnail = imageMessage;
}
}
const message = {
text: request.text,
linkPreview: urlInfo,
};
return this.sock.sendMessage(chatId, message as any, options);
}
protected async uploadMedia(
file: RemoteFile | BinaryFile,
type,
type: any,
): Promise<any> {
if (file && ('url' in file || 'data' in file)) {
throw new AvailableInPlusVersion('Sending media (image, video, pdf)');
if (!file) {
return;
}
return;
if (!('url' in file || 'data' in file)) {
return;
}
const message: any = await this.fileToMessage(file, type);
const options: MediaGenerationOptions = {
logger: this.engineLogger,
upload: this.sock.waUploadToServer,
};
const { imageMessage } = await esm.b.prepareWAMessageMedia(
message,
options,
);
return imageMessage;
}
get client(): NowebClient {
return new NowebClient(this.sock);
}
protected async fileToMessage(
file: RemoteFile | BinaryFile,
type: any,
caption = '',
) {
let content: Buffer;
if ('url' in file) {
content = await this.fetch(file.url);
} else if ('data' in file) {
content = Buffer.from(file.data, 'base64');
} else {
throw new UnprocessableEntityException(
'Either "file.url" or "file.data" must be specified.',
);
}
return {
[type]: content,
mimetype: file.mimetype,
caption: caption,
fileName: file.filename,
ptt: type === 'audio',
};
}
private async fileToBuffer(file: FileType): Promise<Buffer> {
let content: Buffer;
if ('data' in file) {
content = Buffer.from(file.data, 'base64');
} else if ('url' in file) {
content = await this.fetch(file.url);
} else {
throw new UnprocessableEntityException(
'Either file.url or file.data must be specified.',
);
}
return content;
}
@Activity()
@@ -1104,8 +1446,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
}
sendList(request: SendListRequest): Promise<any> {
throw new AvailableInPlusVersion();
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = toJID(this.ensureSuffix(request.chatId));
if (!isLidUser(jid) && !isPnUser(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
);
}
const message = request.message;
const msg = {
text: message.description || '',
title: message.title,
buttonText: message.button,
footer: message.footer,
sections: message.sections,
} as any;
const options = await this.getMessageOptions(request);
return await this.sock.sendMessage(jid, msg, options);
}
@Activity()
@@ -1625,6 +1983,22 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Group methods
*/
@Activity()
protected async setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const content: Buffer = await this.fileToBuffer(file);
await this.sock.updateProfilePicture(id, content);
return true;
}
@Activity()
protected async deleteGroupPicture(id: string): Promise<boolean> {
await this.sock.removeProfilePicture(id);
return true;
}
@Activity()
public createGroup(request: CreateGroupRequest) {
const participants = request.participants.map(getId);
@@ -1701,6 +2075,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return await this.sock.groupSettingUpdate(id, setting);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.getGroup(id);
return { membersCanAddNewMember: group.memberAddMode };
}
@Activity()
public async setMemberAddMode(id, value) {
const mode = value ? 'all_member_add' : 'admin_add';
return await this.sock.groupMemberAddMode(id, mode);
}
@Activity()
public async leaveGroup(id) {
return this.sock.groupLeave(id);
@@ -1896,6 +2281,84 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
}
@Activity()
public async sendImageStatus(status: ImageStatus) {
const message: any = await this.fileToMessage(
status.file,
'image',
status.caption,
);
message.mimetype = message.mimetype || WAMimeType.IMAGE;
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
@Activity()
public async sendVoiceStatus(status: VoiceStatus) {
const message: any = await this.fileToMessage(status.file, 'audio');
message.mimetype = message.mimetype || WAMimeType.VOICE;
if (status.convert) {
message['audio'] = await this.mediaConverter.voice(message['audio']);
message.mimetype = WAMimeType.VOICE;
}
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
backgroundColor: status.backgroundColor,
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
@Activity()
public async sendVideoStatus(status: VideoStatus) {
const message: any = await this.fileToMessage(
status.file,
'video',
status.caption,
);
message.mimetype = message.mimetype || WAMimeType.VIDEO;
if (status.convert) {
message['video'] = await this.mediaConverter.video(message['video']);
message.mimetype = WAMimeType.VIDEO;
}
const jids = await this.prepareJidsForStatus(status.contacts);
if (!status.id) {
this.upsertMeInJIDs(jids);
}
const messageId = this.prepareMessageIdForStatus(status);
const options = {
statusJidList: jids,
messageId: messageId,
};
return await this.sendStatusMessage(
message,
options,
jids,
status.contacts?.length,
);
}
protected prepareMessageIdForStatus(status: StatusRequest) {
if (status.id) {
this.saveSentMessageId(status.id);
@@ -1955,23 +2418,74 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
@Activity()
public async searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const response = await this.client.searchChannelsByView(query);
const channels: Channel[] = response.newsletters.map(
this.toChannel.bind(this),
);
return {
page: response.page,
channels: channels,
};
}
public searchChannelsByText(
@Activity()
public async searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const response = await this.client.searchChannelsByText(query);
const channels: Channel[] = response.newsletters.map(
this.toChannel.bind(this),
);
return {
page: response.page,
channels: channels,
};
}
@Activity()
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
const downloadMedia = query.downloadMedia;
const updates = await this.sock.newsletterFetchPreviewMessages(
'invite',
inviteCode,
query.limit,
null,
);
const promises = [];
for (const update of updates) {
promises.push(
this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia),
);
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
return result;
}
private async NewsletterFetchedUpdateToChannelMessage(
update: NewsletterFetchedUpdate,
downloadMedia: boolean,
): Promise<ChannelMessage> {
let reactions: any = Object.fromEntries(
update.reactions.map(({ code, count }) => [code, count]),
);
reactions = sortObjectByValues(reactions) || {};
const message = await this.processIncomingMessage(
update.message,
downloadMedia,
);
return {
message: message,
reactions: reactions,
viewCount: update.views,
};
}
protected toChannel(newsletter: NOWEBNewsletterMetadata): Channel {
@@ -2017,7 +2531,23 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
request.name,
request.description,
);
return this.toChannel(toNewsletterMetadata(newsletter));
const channel = this.toChannel(toNewsletterMetadata(newsletter));
if (request.picture) {
let file = request.picture;
let picture: any;
// @ts-ignore
if (file.url) {
file = file as RemoteFile;
picture = await esm.b.getStream({ url: file.url });
// @ts-ignore
} else if (file.data) {
file = file as BinaryFile;
picture = Buffer.from(file.data, 'base64');
}
await this.sock.newsletterUpdatePicture(channel.id, picture);
}
return channel;
}
public async channelsGetChannel(id: string) {
@@ -2502,9 +3032,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!origMsgId) {
return '';
}
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
Boolean,
);
const editKey = editMessage.key as WAMessageKey | undefined;
const jidsToTry = [
targetKey.remoteJid,
editKey?.remoteJid,
editKey?.remoteJidAlt,
].filter(Boolean);
let stored: proto.IWebMessageInfo | undefined;
for (const jid of jidsToTry) {
stored = await this.store?.loadMessage(jid, origMsgId);
@@ -2535,40 +3068,75 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!encPayload || !encIv) {
return '';
}
const editInfo = {
Chat: editMessage.key?.remoteJid,
Sender:
editMessage.key?.participant ||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
};
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
fromMe: targetKey.fromMe,
remoteJID: targetKey.remoteJid,
participant: targetKey.participant,
});
const candidates: string[] = [primaryOrig];
// The editor ("modification sender").
let modificationSenderJids: Array<string | null | undefined>;
if (editKey?.fromMe) {
// Try both user lid and c.us
modificationSenderJids = [this.sock?.user?.lid, this.sock?.user?.id];
} else {
const editJids = editKey ? jidsFromKey(editKey) : null;
modificationSenderJids = [
editKey?.participant || editKey?.remoteJid,
editJids?.lid,
editJids?.pn,
];
}
modificationSenderJids = lodash
.chain(modificationSenderJids)
.filter(Boolean)
.map(jidToNonAD)
.uniq()
.value();
if (modificationSenderJids.length === 0) {
modificationSenderJids.push('');
}
const remoteNonAD = targetKey.remoteJid
? jidToNonAD(targetKey.remoteJid)
: '';
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
candidates.push(remoteNonAD);
}
const participantNonAD = targetKey.participant
? jidToNonAD(targetKey.participant)
: '';
if (participantNonAD && !candidates.includes(participantNonAD)) {
candidates.push(participantNonAD);
// Map dedupes by key and keeps the first-insertion order
const attempts = new Map<
string,
{ origSenderJid: string; modificationSenderJid: string }
>();
for (const modificationSenderJid of modificationSenderJids) {
const primaryOrigSenderJid = getOrigSenderJidForMsgSecret(
{ Chat: editKey?.remoteJid, Sender: modificationSenderJid },
{
fromMe: targetKey.fromMe,
remoteJID: targetKey.remoteJid,
participant: targetKey.participant,
},
);
const origSenderJids = [
primaryOrigSenderJid,
remoteNonAD,
participantNonAD,
].filter(Boolean);
for (const origSenderJid of origSenderJids) {
// Avoid duplicates in attemps
const attemptKey = `${origSenderJid}|${modificationSenderJid}`;
attempts.set(attemptKey, {
origSenderJid: origSenderJid,
modificationSenderJid: modificationSenderJid,
});
}
}
let lastErr: unknown;
for (const origSenderJid of candidates) {
for (const attempt of attempts.values()) {
try {
const decoded = decryptSecretEncryptedMessageEditProto({
encPayload: encPayload,
encIv: encIv,
origMsgId: origMsgId,
origSenderJid: origSenderJid,
modificationSenderJid: modificationSenderJid,
origSenderJid: attempt.origSenderJid,
modificationSenderJid: attempt.modificationSenderJid,
origMsgSecret: origSecret,
});
const text = extractBody(decoded) || '';
@@ -2580,7 +3148,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
this.logger.debug(
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
{
err: lastErr,
origMsgId: origMsgId,
attempts: Array.from(attempts.keys()),
},
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
);
return '';
@@ -11,6 +11,10 @@ import type {
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import {
getParticipantId,
participantsIncludeMe,
} from '@waha/core/engines/noweb/groups.noweb';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
@@ -380,16 +384,13 @@ export class NowebPersistentStore implements INowebStore {
if (!this.jids.include(id)) {
return;
}
const participants: string[] = data.participants;
const participants: Array<string | GroupParticipant> = data.participants;
const action: ParticipantAction = data.action;
if (action == 'remove') {
// Remove the group if the current user is removed
const myJid = this.socket?.authState?.creds?.me?.id;
const participantsIncludesMe = lodash.find(participants, (p) =>
esm.b.areJidsSameUser(p, myJid),
);
if (participantsIncludesMe) {
const me = this.socket?.authState?.creds?.me;
if (me && participantsIncludeMe(me, participants)) {
await this.groupRepo.deleteById(id);
return;
}
@@ -419,24 +420,33 @@ export class NowebPersistentStore implements INowebStore {
private participantUpdate(
participantsById: DefaultMap<string, GroupParticipant>,
participant: string,
participant: string | GroupParticipant,
action: ParticipantAction,
) {
const participantId = getParticipantId(participant);
if (!participantId) {
return;
}
switch (action) {
case 'add':
// if there's no participant - add it (by id)
participantsById.get(participant);
if (typeof participant === 'string') {
// if there's no participant - add it (by id)
participantsById.get(participantId);
} else {
// keep the full object - it carries 'phoneNumber' and 'admin' fields
participantsById.set(participantId, participant);
}
break;
case 'remove':
// remove the participant (by id)
participantsById.delete(participant);
participantsById.delete(participantId);
break;
case 'promote':
// set admin: admin
participantsById.get(participant).admin = 'admin';
participantsById.get(participantId).admin = 'admin';
break;
case 'demote':
participantsById.get(participant).admin = null;
participantsById.get(participantId).admin = null;
break;
}
}
@@ -1,17 +1,37 @@
import { LocalStore } from '@waha/core/storage/LocalStore';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { DataStore } from '../../../abc/DataStore';
import { MongoStorage } from './mongodb/MongoStorage';
import { INowebStorage } from './INowebStorage';
import { PsqlStorage } from './psql/PsqlStorage';
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
export class NowebStorageFactoryCore {
createStorage(store: DataStore, name: string): INowebStorage {
if (store instanceof MongoStore) {
return this.buildStorageMongo(store, name);
}
if (store instanceof PsqlStore) {
return this.buildPsql(store, name);
}
if (store instanceof LocalStore) {
return this.buildStorageSqlite3(store, name);
}
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
private buildStorageMongo(store: MongoStore, name: string) {
const db = store.getSessionDb(name);
return new MongoStorage(db);
}
private buildPsql(store: PsqlStore, name: string) {
const knex = store.buildSessionKnex(name, 'Session/Storage');
return new PsqlStorage(knex);
}
private buildStorageSqlite3(store: LocalStore, name: string) {
const filePath = store.getFilePath(name, 'store.sqlite3');
return new Sqlite3Storage(filePath);
@@ -0,0 +1,43 @@
import type { Chat } from '@adiwajshing/baileys';
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { MongoRepository } from './MongoRepository';
export class MongoChatRepository
extends MongoRepository<Chat>
implements IChatRepository
{
async getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
// Get chats with conversationTimestamp is not Null
const mongoFilter: any = { conversationTimestamp: { $ne: NaN } };
// Build the ID filter conditions
const conditions = [];
if (!broadcast) {
// Filter out chat by id if it ends at @newsletter or @broadcast
conditions.push({ id: { $not: { $regex: /@broadcast|@newsletter/ } } });
}
if (filter?.ids && filter.ids.length > 0) {
// Filter by IDs if provided
conditions.push({ id: { $in: filter.ids } });
}
if (conditions.length != 0) {
// Combine ID conditions with $and if there are multiple conditions
mongoFilter.$and = conditions;
}
let query = this.collection.find(mongoFilter);
// Sort by conversationTimestamp in descending order
query = this.pagination(query, pagination);
const rows = await query.toArray();
return rows.map(MongoRepository.revive);
}
}
@@ -0,0 +1,8 @@
import type { Contact } from '@adiwajshing/baileys';
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
import { MongoRepository } from './MongoRepository';
export class MongoContactRepository
extends MongoRepository<Contact>
implements IContactRepository {}
@@ -0,0 +1,8 @@
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { MongoRepository } from './MongoRepository';
export class MongoGroupRepository
extends MongoRepository<GroupMetadata>
implements IGroupRepository {}
@@ -0,0 +1,37 @@
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { MongoRepository } from '@waha/core/engines/noweb/store/mongodb/MongoRepository';
import { LabelAssociationType } from '@waha/core/engines/noweb/labels/LabelAssociationType';
export class MongoLabelAssociationsRepository
extends MongoRepository<LabelAssociation>
implements ILabelAssociationRepository
{
async deleteOne(association: LabelAssociation): Promise<void> {
await this.deleteBy({
type: association.type,
chatId: association.chatId,
labelId: association.labelId,
// @ts-ignore: messageId doesn't existing in ChatLabelAssociation
messageId: association.messageId || null,
});
}
async deleteByLabelId(labelId: string): Promise<void> {
await this.deleteBy({ labelId: labelId });
}
getAssociationsByLabelId(
labelId: string,
type: LabelAssociationType,
): Promise<LabelAssociation[]> {
return this.getAllBy({
type: type,
labelId: labelId,
});
}
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
return this.getAllBy({ chatId: chatId, type: LabelAssociationType.Chat });
}
}
@@ -0,0 +1,8 @@
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { MongoRepository } from './MongoRepository';
export class MongoLabelsRepository
extends MongoRepository<Label>
implements ILabelsRepository {}
@@ -0,0 +1,34 @@
import {
INowebLidPNRepository,
LidToPN,
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import { MongoRepository } from './MongoRepository';
export class MongoLidPNRepository
extends MongoRepository<LidToPN>
implements INowebLidPNRepository
{
saveLids(lids: LidToPN[]): Promise<void> {
return this.upsertMany(lids);
}
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
return this.getAll(pagination);
}
getLidsCount(): Promise<number> {
return this.getCount();
}
async findLidByPN(pn: string): Promise<string | null> {
const value = await this.getBy({ pn: pn });
return value?.id || null;
}
async findPNByLid(lid: string): Promise<string | null> {
const value = await this.getBy({ id: lid });
return value?.pn || null;
}
}
@@ -0,0 +1,82 @@
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
import { AckToStatus } from '@waha/core/utils/acks';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { FindCursor } from 'mongodb';
import { IMessagesRepository } from '../../../../../core/engines/noweb/store/IMessagesRepository';
import { MongoRepository } from './MongoRepository';
export class MongoMessagesRepository
extends MongoRepository<any>
implements IMessagesRepository
{
upsert(messages: any[]): Promise<void> {
return this.upsertMany(messages);
}
async getAllByJid(
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]> {
const query: any = {};
if (jid !== ALL_JID) {
query['jid'] = jid;
}
if (
filter['filter.timestamp.lte'] != null ||
filter['filter.timestamp.gte'] != null
) {
const filters: any = {};
if (filter['filter.timestamp.lte'] != null) {
filters['$lte'] = filter['filter.timestamp.lte'];
}
if (filter['filter.timestamp.gte'] != null) {
filters['$gte'] = filter['filter.timestamp.gte'];
}
query['messageTimestamp'] = filters;
}
if (filter['filter.fromMe'] != null) {
query['data.key.fromMe'] = filter['filter.fromMe'];
}
if (filter['filter.ack'] != null) {
const status = AckToStatus(filter['filter.ack']);
query['data.status'] = status;
}
let cursor = this.collection.find(query);
cursor = this.pagination(cursor, pagination);
const rows = await cursor.toArray();
return rows.map(MongoRepository.revive);
}
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
if (jid === ALL_JID) {
return this.getBy({ id: id });
}
return this.getBy({ jid: jid, id: id });
}
async updateByJidAndId(
jid: string,
id: string,
update: any,
): Promise<boolean> {
const entity = await this.getByJidById(jid, id);
if (!entity) {
return false;
}
Object.assign(entity, update);
await this.upsertOne(entity);
}
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
await this.collection.deleteMany({ jid: jid, id: { $in: ids } });
}
deleteAllByJid(jid: string): Promise<void> {
return this.deleteBy({ jid: jid });
}
}
@@ -0,0 +1,170 @@
import {
convertProtobufToPlainObject,
replaceLongsWithNumber,
} from '@waha/core/engines/noweb/utils';
import { Field, Schema } from '@waha/core/storage/Schema';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { MongoPaginator } from '@waha/utils/Paginator';
import { Collection, Db } from 'mongodb';
import esm from '@waha/vendor/esm';
/**
* Key value repository with extra metadata
*/
export class MongoRepository<Entity> {
private UPSERT_BATCH_SIZE = 1000;
protected collection: Collection<any>;
private readonly metadata: Map<string, (entity: Entity) => any>;
private readonly columns: Field[];
static replace(data: any): any {
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
}
static revive(row: any): any {
if (!row) {
return null;
}
return JSON.parse(JSON.stringify(row.data), esm.b.BufferJSON.reviver);
}
constructor(
db: Db,
schema: Schema,
metadata: Map<string, (entity: Entity) => any> | null = null,
) {
this.collection = db.collection(schema.name);
this.columns = schema.columns;
this.metadata = metadata || new Map();
}
async getAll(pagination?: PaginationParams) {
let query = this.collection.find();
query = this.pagination(query, pagination);
const rows = await query.toArray();
return rows.map(MongoRepository.revive);
}
async getCount() {
return await this.collection.countDocuments();
}
async getAllBy(filters: any) {
const rows = await this.collection.find(filters).toArray();
return rows.map(MongoRepository.revive);
}
async getAllByIds(ids: string[]) {
const entitiesMap = await this.getEntitiesByIds(ids);
return Array.from(entitiesMap.values()).filter(
(entity) => entity !== null,
) as Entity[];
}
async getEntitiesByIds(ids: string[]): Promise<Map<string, Entity | null>> {
if (ids.length === 0) {
return new Map();
}
const rows = await this.collection.find({ id: { $in: ids } }).toArray();
const entitiesMap = new Map<string, Entity | null>();
// Initialize a map with null values for all requested IDs
for (const id of ids) {
entitiesMap.set(id, null);
}
// Fill in the map with found entities
for (const row of rows) {
const entity = MongoRepository.revive(row);
if (entity && row.id) {
entitiesMap.set(row.id, entity);
}
}
return entitiesMap;
}
protected async getBy(filters: any) {
return await this.collection.findOne(filters).then(MongoRepository.revive);
}
private dump(entity: Entity): any {
const raw = convertProtobufToPlainObject(entity);
replaceLongsWithNumber(raw);
const data = {};
for (const field of this.columns) {
const fn = this.metadata.get(field.fieldName);
if (fn) {
data[field.fieldName] = fn(raw);
} else if (field.fieldName == 'data') {
data['data'] = MongoRepository.replace(raw);
} else {
data[field.fieldName] = raw[field.fieldName];
}
}
return data;
}
save(entity: Entity) {
return this.upsertOne(entity);
}
async getById(id: string): Promise<Entity | null> {
return this.getBy({ id: id });
}
async upsertOne(entity: Entity): Promise<void> {
const row = this.dump(entity);
await this.collection.replaceOne({ id: row.id }, row, { upsert: true });
}
async upsertMany(entities: Entity[]): Promise<void> {
if (entities.length === 0) {
return;
}
const batchSize = this.UPSERT_BATCH_SIZE;
for (let i = 0; i < entities.length; i += batchSize) {
const batch = entities.slice(i, i + batchSize);
await this.upsertBatch(batch);
}
}
private async upsertBatch(entities: Entity[]): Promise<void> {
// Update using bulk write
const bulk = this.collection.initializeUnorderedBulkOp();
for (const entity of entities) {
const row = this.dump(entity);
bulk.find({ id: row.id }).upsert().replaceOne(row);
}
await bulk.execute();
}
protected async deleteBy(filters: any) {
await this.collection.deleteMany(filters);
}
async deleteAll() {
await this.deleteBy({});
}
async deleteById(id: string) {
await this.deleteBy({ id: id });
}
protected pagination(query: any, pagination?: PaginationParams) {
if (pagination?.sortBy) {
// check if it's in 'columns'
const column = this.columns.find(
(column) => column.fieldName === pagination.sortBy,
);
if (!column) {
// add "data"
pagination.sortBy = `data.${pagination.sortBy}`;
}
}
const paginator = new MongoPaginator(pagination);
return paginator.apply(query);
}
}
@@ -0,0 +1,147 @@
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
import {
NowebLabelAssociationsMetadata,
NowebMessagesMetadata,
} from '@waha/core/engines/noweb/store/metadata';
import { NOWEB_STORE_SCHEMA } from '@waha/core/engines/noweb/store/schemas';
import { Schema } from '@waha/core/storage/Schema';
import { MongoGroupRepository } from '@waha/core/engines/noweb/store/mongodb/MongoGroupRepository';
import { MongoLabelAssociationsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelAssociationsRepository';
import { MongoLabelsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelsRepository';
import { MongoLidPNRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLidPNRepository';
import { Db } from 'mongodb';
import { MongoChatRepository } from './MongoChatRepository';
import { MongoContactRepository } from './MongoContactRepository';
import { MongoMessagesRepository } from './MongoMessagesRepository';
enum Order {
ASC = 1,
DESC = -1,
}
export class MongoStorage extends INowebStorage {
private readonly tables: Schema[];
constructor(private db: Db) {
super();
this.tables = NOWEB_STORE_SCHEMA;
}
async init() {
await this.upsertIndexes();
return;
}
async upsertIndexes() {
// Contacts
await this.db
.collection('contacts')
.createIndex({ id: Order.ASC }, { unique: true });
// Chats
await this.db
.collection('chats')
.createIndex({ id: Order.ASC }, { unique: true });
await this.db
.collection('chats')
.createIndex({ conversationTimestamp: Order.ASC });
await this.db
.collection('chats')
.createIndex({ conversationTimestamp: Order.DESC });
// Groups
await this.db
.collection('groups')
.createIndex({ id: Order.ASC }, { unique: true });
// Messages
await this.db
.collection('messages')
.createIndex({ id: Order.ASC }, { unique: true });
await this.db
.collection('messages')
.createIndex({ jid: Order.ASC, id: Order.ASC }, { unique: true });
await this.db
.collection('messages')
.createIndex({ jid: Order.ASC, messageTimestamp: Order.ASC });
await this.db
.collection('messages')
.createIndex({ jid: Order.ASC, messageTimestamp: Order.DESC });
await this.db
.collection('messages')
.createIndex({ messageTimestamp: Order.ASC });
await this.db
.collection('messages')
.createIndex({ messageTimestamp: Order.DESC });
//
// Labels
//
await this.db.collection('labels').createIndex({ id: 1 }, { unique: true });
// Label associations
await this.db
.collection('labelAssociations')
.createIndex({ id: 1 }, { unique: true });
await this.db
.collection('labelAssociations')
.createIndex({ type: 1, labelId: 1 });
await this.db
.collection('labelAssociations')
.createIndex({ type: 1, chatId: 1 });
await this.db
.collection('labelAssociations')
.createIndex({ type: 1, messageId: 1 });
return;
}
async close() {
return;
}
getContactsRepository() {
return new MongoContactRepository(this.db, this.getSchema('contacts'));
}
getChatRepository() {
return new MongoChatRepository(this.db, this.getSchema('chats'));
}
getGroupRepository(): IGroupRepository {
return new MongoGroupRepository(this.db, this.getSchema('groups'));
}
getMessagesRepository() {
return new MongoMessagesRepository(
this.db,
this.getSchema('messages'),
NowebMessagesMetadata,
);
}
getLabelsRepository(): ILabelsRepository {
return new MongoLabelsRepository(this.db, this.getSchema('labels'));
}
getLabelAssociationRepository(): ILabelAssociationRepository {
return new MongoLabelAssociationsRepository(
this.db,
this.getSchema('labelAssociations'),
NowebLabelAssociationsMetadata,
);
}
getSchema(name: string) {
const schema = this.tables.find((table) => table.name === name);
if (!schema) {
throw new Error(`Schema not found: ${name}`);
}
return schema;
}
getLidPNRepository(): INowebLidPNRepository {
return new MongoLidPNRepository(this.db, this.getSchema('lid_map'));
}
}
@@ -0,0 +1,191 @@
import esm from '@waha/vendor/esm';
import { Collection, Db, Document } from 'mongodb';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
function mongoKey(id: string) {
// Replace . to -
return id.replace(/\./g, '-');
}
type AuthDocument = Document & {
key?: string;
data?: any;
type?: string;
[key: string]: any;
};
export class NoWebMongoDbAuth {
private collection: Collection<AuthDocument>;
// Keep legacy aggregated document reference for gradual migration.
private document: AuthDocument | null;
private creds: any;
private db: Db;
private lock: any;
constructor(db: Db) {
this.db = db;
this.collection = this.db.collection('auth');
this.lock = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
maxExecutionTime: 60_000,
});
this.document = null;
}
async init() {
await this.ensureIndexes();
this.document = await this.loadLegacyDocument();
const creds = await this.readData('creds');
// @ts-ignore:next-line
this.creds = creds || (0, esm.b.initAuthCreds)();
}
private async ensureIndexes() {
// Separate docs are keyed by "key", keep collection indexable for new format.
await this.collection.createIndex({ key: 1 }, { unique: true });
}
private serialize(data: any) {
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
}
private deserialize(data: any) {
if (data === null || data === undefined) {
return null;
}
const json = JSON.stringify(data);
if (!json) {
return null;
}
return JSON.parse(json, esm.b.BufferJSON.reviver);
}
private async loadLegacyDocument(): Promise<AuthDocument | null> {
// The old format stored everything in one doc; keep it for reads and migrate lazily.
return this.collection.findOne({ data: { $exists: false } });
}
private async readFromLegacy(field: string, key: string) {
if (!this.document) {
return null;
}
if (!(key in this.document)) {
return null;
}
try {
const value = this.deserialize(this.document[key]);
if (value === null || value === undefined) {
return null;
}
await this.writeData(value, field);
return value;
} catch (error) {
console.error(error);
return null;
}
}
private async writeData(data: any, field: string) {
const key = mongoKey(field);
await this.lock.acquire(key, async () => {
await this.collection.updateOne(
{ key: key },
{
$set: {
key: key,
data: this.serialize(data),
type: 'kv',
},
},
{ upsert: true },
);
});
}
async readData(field: string) {
const key = mongoKey(field);
try {
const document = await this.collection.findOne({ key: key });
if (document && 'data' in document) {
return this.deserialize(document.data);
}
} catch (error) {
console.error(error);
}
return this.readFromLegacy(field, key);
}
async removeData(field: string) {
const key = mongoKey(field);
try {
await this.collection.deleteOne({ key: key });
} catch (error) {
console.error(error);
}
}
isMyMainSession(id: string) {
// Decode the jid
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
return id == `${meId}.0`;
}
methods() {
const creds = this.creds;
return {
state: {
creds: creds,
keys: {
get: async (type, ids) => {
const data = {};
await Promise.all(
ids.map(async (id) => {
// Always reset my session and setup new session
if (type === 'session' && this.isMyMainSession(id)) {
return;
}
let value = await this.readData(`${type}-${id}`);
if (type === 'app-state-sync-key' && value) {
value =
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
}
data[id] = value;
}),
);
return data;
},
set: async (data) => {
const tasks = [];
for (const category of Object.keys(data)) {
for (const id of Object.keys(data[category])) {
const value = data[category][id];
const key = `${category}-${id}`;
const hasValue = !!value || value?.length === 0;
if (hasValue) {
tasks.push(this.writeData(value, key));
} else {
// Do not remove any keys for now
// Avoid removing pre-keys
// tasks.push(this.removeData(key));
}
}
}
await Promise.all(tasks);
},
},
},
saveCreds: () => {
return this.writeData(this.creds, 'creds');
},
close: async () => {
return;
},
};
}
}
@@ -0,0 +1,36 @@
import {
convertProtobufToPlainObject,
replaceLongsWithNumber,
} from '@waha/core/engines/noweb/utils';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import esm from '@waha/vendor/esm';
// PostgreSQL TEXT or JSONB columns do not allow null bytes (c-style strings)
// oxlint-disable-next-line no-control-regex
const invalidCharsRegex = /\u0000/g;
export function sanitizeJsonUnicode(str: string): string {
return str.replace(invalidCharsRegex, '');
}
/**
* Key value repository with extra metadata
* Add support for converting protobuf to plain object
*/
export class NOWEBPsqlKVRepository<Entity> extends PsqlKVRepository<Entity> {
protected stringify(data: any): string {
let value = JSON.stringify(data, esm.b.BufferJSON.replacer);
value = sanitizeJsonUnicode(value);
return value;
}
public parse(row: any): any {
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
}
protected dump(entity: Entity) {
const raw = convertProtobufToPlainObject(entity);
replaceLongsWithNumber(raw);
return super.dump(raw);
}
}
@@ -0,0 +1,89 @@
import esm from '@waha/vendor/esm';
import { PsqlNowebAuthRepository } from '@waha/core/engines/noweb/store/psql/PsqlAuthRepository';
import Knex from 'knex';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
export class NoWebPsqlAuth {
private creds: any;
private repository: PsqlNowebAuthRepository;
private lock: any;
constructor(private knex: Knex.Knex) {
this.repository = new PsqlNowebAuthRepository(knex);
this.lock = new AsyncLock({
timeout: 5_000,
maxPending: Infinity,
maxExecutionTime: 60_000,
});
}
async init() {
await this.repository.init();
this.creds = await this.repository.getCreds();
if (!this.creds) {
this.creds = esm.b.initAuthCreds();
}
}
methods() {
return {
state: {
creds: this.creds,
keys: {
get: async (type, ids) => {
const data = {};
await Promise.all(
ids.map(async (id) => {
// Always reset my session and setup new session
if (type === 'session' && this.isMyMainSession(id)) {
return;
}
let value = await this.repository.getCategory(type, id);
if (type === 'app-state-sync-key' && value) {
value =
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
}
data[id] = value;
}),
);
return data;
},
set: async (data) => {
const tasks = [];
for (const category of Object.keys(data)) {
for (const id of Object.keys(data[category])) {
const value = data[category][id];
const hasValue = !!value || value?.length === 0;
if (hasValue) {
tasks.push(this.repository.saveCategory(category, id, value));
} else {
// Do not remove any keys for now
// Avoid removing pre-keys
// tasks.push(this.removeData(key));
}
}
}
await Promise.all(tasks);
return;
},
},
},
saveCreds: () => {
return this.lock.acquire('creds', async () => {
await this.repository.saveCreds(this.creds);
});
},
close: async () => {
return await this.knex.destroy();
},
};
}
isMyMainSession(id: string) {
// Decode the jid
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
return id == `${meId}.0`;
}
}
@@ -0,0 +1,42 @@
import {
INowebLidPNRepository,
LidToPN,
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import { NowebLidMapSchema } from '@waha/core/engines/noweb/store/schemas';
import { LidPaginator } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LidPNRepository';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
export class PsqlLidPNRepository
extends NOWEBPsqlKVRepository<LidToPN>
implements INowebLidPNRepository
{
protected Paginator = LidPaginator;
get schema() {
return NowebLidMapSchema;
}
saveLids(lids: LidToPN[]): Promise<void> {
return this.upsertMany(lids);
}
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
return this.getAll(pagination);
}
getLidsCount(): Promise<number> {
return this.getCount();
}
async findLidByPN(pn: string): Promise<string | null> {
const value = await this.getBy({ pn: pn });
return value?.id || null;
}
async findPNByLid(lid: string): Promise<string | null> {
const value = await this.getBy({ id: lid });
return value?.pn || null;
}
}
@@ -0,0 +1,60 @@
import {
SqlAuthMigrations,
SqlAuthSchema,
} from '@waha/core/engines/noweb/store/psql/schemas';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import esm from '@waha/vendor/esm';
import Knex from 'knex';
class AuthData {
id: string;
data: any;
}
export class PsqlNowebAuthRepository extends PsqlKVRepository<AuthData> {
constructor(knex: Knex.Knex) {
super(knex);
}
get schema() {
return SqlAuthSchema;
}
get migrations() {
return SqlAuthMigrations;
}
saveCreds(data) {
return this.saveCategory('creds', '', data);
}
getCreds() {
return this.getCategory('creds', '');
}
private getDbId(category: string, id: string) {
return id ? `${category}-${id}` : category;
}
async saveCategory(category: string, id: string, data) {
const dbId = this.getDbId(category, id);
await this.upsertOne({ id: dbId, data: data });
}
async getCategory(category: string, id: string): Promise<any> {
const dbId = this.getDbId(category, id);
const data = await this.getById(dbId);
return data?.data;
}
/**
* JSON helpers
*/
protected stringify(data: any): string {
return JSON.stringify(data, esm.b.BufferJSON.replacer);
}
public parse(row: any) {
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
}
}
@@ -0,0 +1,41 @@
import type { Chat } from '@adiwajshing/baileys';
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
import { NowebChatSchema } from '@waha/core/engines/noweb/store/schemas';
import { SqlChatMethods } from '@waha/core/engines/noweb/store/sql/SqlChatMethods';
import { NOWEBPsqlKVRepository } from '@waha/core/engines/noweb/store/psql/NOWEBPsqlKVRepository';
import { OverviewFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { KnexPaginator } from '@waha/utils/Paginator';
class ChatPaginator extends KnexPaginator {
indexes = ['id', 'conversationTimestamp'];
}
export class PsqlChatRepository
extends NOWEBPsqlKVRepository<Chat>
implements IChatRepository
{
protected Paginator = ChatPaginator;
get schema() {
return NowebChatSchema;
}
get methods() {
return new SqlChatMethods(this);
}
getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
filter?: OverviewFilter,
merge?: boolean,
): Promise<Chat[]> {
return this.methods.getAllWithMessages(
pagination,
broadcast,
filter,
merge,
);
}
}
@@ -0,0 +1,21 @@
import type { Contact } from '@adiwajshing/baileys';
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
import { NowebContactSchema } from '@waha/core/engines/noweb/store/schemas';
import { KnexPaginator } from '@waha/utils/Paginator';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
class ContactPaginator extends KnexPaginator {
indexes = ['id'];
}
export class PsqlContactRepository
extends NOWEBPsqlKVRepository<Contact>
implements IContactRepository
{
protected Paginator = ContactPaginator;
get schema() {
return NowebContactSchema;
}
}
@@ -0,0 +1,21 @@
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { NowebGroupsSchema } from '@waha/core/engines/noweb/store/schemas';
import { KnexPaginator } from '@waha/utils/Paginator';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
class Paginator extends KnexPaginator {
indexes = ['id'];
}
export class PsqlGroupRepository
extends NOWEBPsqlKVRepository<GroupMetadata>
implements IGroupRepository
{
protected Paginator = Paginator;
get schema() {
return NowebGroupsSchema;
}
}
@@ -0,0 +1,46 @@
import type {
LabelAssociation,
LabelAssociationType,
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { NowebLabelAssociationsMetadata } from '@waha/core/engines/noweb/store/metadata';
import { NowebLabelAssociationsSchema } from '@waha/core/engines/noweb/store/schemas';
import { SqlLabelAssociationsMethods } from '@waha/core/engines/noweb/store/sql/SqlLabelAssociationsMethods';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
export class PsqlLabelAssociationsRepository
extends NOWEBPsqlKVRepository<LabelAssociation>
implements ILabelAssociationRepository
{
get schema() {
return NowebLabelAssociationsSchema;
}
get metadata() {
return NowebLabelAssociationsMetadata;
}
get methods() {
return new SqlLabelAssociationsMethods(this);
}
async deleteOne(association: LabelAssociation): Promise<void> {
return this.methods.deleteOne(association);
}
async deleteByLabelId(labelId: string): Promise<void> {
return this.methods.deleteByLabelId(labelId);
}
getAssociationsByLabelId(
labelId: string,
type: LabelAssociationType,
): Promise<LabelAssociation[]> {
return this.methods.getAssociationsByLabelId(labelId, type);
}
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
return this.methods.getAssociationsByChatId(chatId);
}
}
@@ -0,0 +1,14 @@
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { NowebLabelsSchema } from '@waha/core/engines/noweb/store/schemas';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
export class PsqlLabelsRepository
extends NOWEBPsqlKVRepository<Label>
implements ILabelsRepository
{
get schema() {
return NowebLabelsSchema;
}
}
@@ -0,0 +1,67 @@
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
import { NowebMessagesMetadata } from '@waha/core/engines/noweb/store/metadata';
import { NowebMessagesSchema } from '@waha/core/engines/noweb/store/schemas';
import { SqlMessagesMethods } from '@waha/core/engines/noweb/store/sql/SqlMessagesMethods';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import Knex from 'knex';
export class PsqlMessagesRepository
extends NOWEBPsqlKVRepository<any>
implements IMessagesRepository
{
constructor(
knex: Knex.Knex,
private readonly lidRepository: INowebLidPNRepository,
) {
super(knex);
}
get schema() {
return NowebMessagesSchema;
}
get methods() {
return new SqlMessagesMethods(this, this.lidRepository);
}
get metadata() {
return NowebMessagesMetadata;
}
upsert(messages: any[]): Promise<void> {
return this.methods.upsert(messages);
}
async getAllByJid(
jid: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
merge?: boolean,
): Promise<any[]> {
return this.methods.getAllByJid(jid, filter, pagination, merge);
}
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
return this.methods.getByJidById(jid, id, merge);
}
async updateByJidAndId(
jid: string,
id: string,
update: any,
): Promise<boolean> {
return this.methods.updateByJidAndId(jid, id, update);
}
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
return this.methods.deleteByJidByIds(jid, ids);
}
deleteAllByJid(jid: string): Promise<void> {
return this.methods.deleteAllByJid(jid);
}
}
@@ -0,0 +1,78 @@
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
import { Migrations } from '@waha/core/engines/noweb/store/schemas';
import { PsqlChatRepository } from '@waha/core/engines/noweb/store/psql/PsqlChatRepository';
import { PsqlContactRepository } from '@waha/core/engines/noweb/store/psql/PsqlContactRepository';
import { PsqlGroupRepository } from '@waha/core/engines/noweb/store/psql/PsqlGroupRepository';
import { PsqlLidPNRepository } from '@waha/core/engines/noweb/store/psql/PsqLidPNRepository';
import { PsqlLabelAssociationsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelAssociationsRepository';
import { PsqlLabelsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelsRepository';
import { PsqlMessagesRepository } from '@waha/core/engines/noweb/store/psql/PsqlMessagesRepository';
import Knex from 'knex';
export class PsqlStorage extends INowebStorage {
private lidRepository: INowebLidPNRepository | null = null;
constructor(private knex: Knex.Knex) {
super();
}
async init() {
await this.migrate();
}
private migrate() {
return this.migration0001init();
}
private async migration0001init() {
for (const migration of Migrations) {
await this.knex.raw(migration);
}
}
async close() {
return this.knex.destroy();
}
getContactsRepository(): IContactRepository {
return new PsqlContactRepository(this.knex);
}
getChatRepository(): IChatRepository {
return new PsqlChatRepository(this.knex);
}
getGroupRepository(): IGroupRepository {
return new PsqlGroupRepository(this.knex);
}
getMessagesRepository(): IMessagesRepository {
return new PsqlMessagesRepository(this.knex, this.getLidRepository());
}
getLabelsRepository(): ILabelsRepository {
return new PsqlLabelsRepository(this.knex);
}
getLabelAssociationRepository(): ILabelAssociationRepository {
return new PsqlLabelAssociationsRepository(this.knex);
}
getLidPNRepository(): INowebLidPNRepository {
return this.getLidRepository();
}
private getLidRepository(): INowebLidPNRepository {
if (!this.lidRepository) {
this.lidRepository = new PsqlLidPNRepository(this.knex);
}
return this.lidRepository;
}
}
@@ -0,0 +1,13 @@
import { Field, Index, Schema } from '@waha/core/storage/Schema';
import { Migration } from '@waha/core/storage/sql/SqlKVRepository';
export const SqlAuthSchema = new Schema(
'auth',
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
[new Index('auth_id_index', ['id'])],
);
export const SqlAuthMigrations: Migration[] = [
'CREATE TABLE IF NOT EXISTS auth (id TEXT PRIMARY KEY, data TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS auth_id_index ON auth (id)',
];
+44
View File
@@ -0,0 +1,44 @@
import {
formatWaVersion,
isWaVersionHigher,
parseWaVersion,
} from '@waha/core/engines/noweb/waversion';
describe('parseWaVersion', () => {
it('parses a valid version', () => {
expect(parseWaVersion('2.3000.1043857760')).toEqual([2, 3000, 1043857760]);
});
it('trims whitespace', () => {
expect(parseWaVersion(' 2.3000.1 ')).toEqual([2, 3000, 1]);
});
it.each(['', '2.3000', '2.3000.1.2', '2.3000.abc', 'latest'])(
"returns null for invalid value '%s'",
(value) => {
expect(parseWaVersion(value)).toBeNull();
},
);
});
describe('formatWaVersion', () => {
it('formats a version', () => {
expect(formatWaVersion([2, 3000, 1043857760])).toBe('2.3000.1043857760');
});
});
describe('isWaVersionHigher', () => {
it('compares by the last part', () => {
expect(isWaVersionHigher([2, 3000, 2], [2, 3000, 1])).toBe(true);
expect(isWaVersionHigher([2, 3000, 1], [2, 3000, 2])).toBe(false);
});
it('compares by the middle part first', () => {
expect(isWaVersionHigher([2, 3001, 1], [2, 3000, 999])).toBe(true);
expect(isWaVersionHigher([2, 3000, 999], [2, 3001, 1])).toBe(false);
});
it('returns false for equal versions', () => {
expect(isWaVersionHigher([2, 3000, 1], [2, 3000, 1])).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { WAVersion } from '@adiwajshing/baileys';
export function parseWaVersion(value: string): WAVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function formatWaVersion(version: WAVersion): string {
return version.join('.');
}
export function isWaVersionHigher(left: WAVersion, right: WAVersion): boolean {
for (let index = 0; index < 3; index++) {
if (left[index] !== right[index]) {
return left[index] > right[index];
}
}
return false;
}
+329
View File
@@ -0,0 +1,329 @@
import { sleep } from '@waha/utils/promiseTimeout';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import * as path from 'path';
import pino, { Logger } from 'pino';
import { AuthStrategy, Client, Events, Store } from 'whatsapp-web.js';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const prettyBytes = require('pretty-bytes');
/* Require Optional Dependencies */
let fs;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
fs = require('fs-extra');
} catch {
// eslint-disable-next-line @typescript-eslint/no-var-requires
fs = undefined;
}
async function isValidPath(path: string) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
async function getFilesizeInBytes(filename: string) {
const stats = await fs.promises.lstat(filename).catch(() => null);
if (!stats) {
return;
}
return stats.size;
}
export interface Zipper {
compress(path: string, archivePath: string): Promise<void>;
uncompress(archivePath: string, path: string): Promise<void>;
}
/**
* Remote-based authentication
* @param {object} options - options
* @param {object} options.store - Remote database store instance
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
*/
export class RemoteAuth implements AuthStrategy {
// Required Files & Dirs in WWebJS to restore session
private REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
// Initial delay sync required for session to be stable enough to recover
private INITIAL_DELAY_MS = 60000;
private readonly clientId: string;
private readonly dataPath: string;
private readonly tempDir: string;
private readonly store: Store;
private readonly logger: Logger;
private client: any;
private userDataDir: string;
private sessionName: string;
private backupSyncRunner: SinglePeriodicJobRunner;
private zipper: Zipper;
constructor(
{ clientId, dataPath, store, backupSyncIntervalMs, logger, zipper } = {
clientId: 'default',
dataPath: undefined,
store: null,
backupSyncIntervalMs: 60000,
zipper: undefined,
logger: undefined,
},
) {
if (!fs)
throw new Error(
'Optional Dependencies [fs-extra] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
const idRegex = /^[-_\w]+$/i;
if (clientId && !idRegex.test(clientId)) {
throw new Error(
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
);
}
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
throw new Error(
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
);
}
if (!store) throw new Error('Remote database store is required.');
this.store = store;
this.clientId = clientId;
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
this.zipper = zipper;
this.logger = logger || pino({ name: RemoteAuth.name });
this.backupSyncRunner = new SinglePeriodicJobRunner(
'RemoteAuth Backup Sync',
backupSyncIntervalMs,
this.logger,
);
}
get compressedSessionPath() {
return `${this.sessionName}.zip`;
}
setup(client: Client) {
this.client = client;
}
async afterBrowserInitialized() {
return;
}
async onAuthenticationNeeded() {
return {
failed: false,
restart: false,
failureEventPayload: undefined,
};
}
async getAuthEventPayload() {
return;
}
async beforeBrowserInitialized() {
const puppeteerOpts = this.client.options.puppeteer;
const sessionDirName = this.clientId
? `RemoteAuth-${this.clientId}`
: 'RemoteAuth';
const dirPath = path.join(this.dataPath, sessionDirName);
if (puppeteerOpts.userDataDir && puppeteerOpts.userDataDir !== dirPath) {
throw new Error(
'RemoteAuth is not compatible with a user-supplied userDataDir.',
);
}
this.userDataDir = dirPath;
this.logger.debug(`User data dir: ${this.userDataDir}`);
this.sessionName = sessionDirName;
this.logger.debug(`Session name: ${this.sessionName}`);
await this.extractRemoteSession();
await this.removeSingletonFiles(dirPath);
this.client.options.puppeteer = {
...puppeteerOpts,
userDataDir: dirPath,
};
}
/**
* Find in direction Singleton* files and try to remove it
* Fix for SingletonLock and other files
*/
private async removeSingletonFiles(dir: string) {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (file.startsWith('Singleton')) {
const filePath = path.join(dir, file);
try {
await fs.promises.rm(filePath, {
maxRetries: 4,
recursive: true,
force: true,
});
} catch (err) {
this.logger.error(err, `Error deleting: ${filePath}`);
}
}
}
}
async logout() {
await this.disconnect();
await this.destroy();
}
async destroy() {
this.backupSyncRunner.stop();
// @ts-ignore
if (this.store.close) {
// @ts-ignore
await this.store.close();
}
}
async disconnect() {
await this.deleteRemoteSession();
await this.deleteLocalSession();
}
async afterAuthReady() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (!sessionExists) {
/* Initial delay sync required for session to be stable enough to recover */
await sleep(this.INITIAL_DELAY_MS);
await this.storeRemoteSession();
this.client.emit(Events.REMOTE_SESSION_SAVED);
}
this.backupSyncRunner.start(async () => {
await this.storeRemoteSession();
});
}
async storeRemoteSession() {
const pathExists = await isValidPath(this.userDataDir);
if (!pathExists) {
this.logger.warn(
'User data dir does not exist. Skipping session backup.',
);
return;
}
await this.compressSession();
await this.store.save({ session: this.sessionName });
await this.removePathSilently(this.compressedSessionPath);
await this.removePathSilently(this.tempDir);
}
async extractRemoteSession() {
await this.removePathSilently(this.userDataDir);
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (!sessionExists) {
fs.mkdirSync(this.userDataDir, { recursive: true });
return;
}
await this.store.extract({
session: this.sessionName,
path: this.compressedSessionPath,
});
await this.removePathSilently(this.userDataDir);
await this.unCompressSession();
await this.removePathSilently(this.compressedSessionPath);
}
private async deleteRemoteSession() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (sessionExists) await this.store.delete({ session: this.sessionName });
}
private async deleteLocalSession() {
await this.removePathSilently(this.userDataDir);
}
async compressSession() {
// Chrome's Storage Partitioning creates symlinks with numeric names inside the
// profile. fs-extra's copy resolves them and throws "subdirectory of itself"
// when it detects a cycle. We skip symlinks entirely — session-critical data
// (IndexedDB, Local Storage, cookies) lives in regular files only.
const skipSymlinks = async (src: string) => {
try {
const lstat = await fs.promises.lstat(src);
return !lstat.isSymbolicLink();
} catch {
return false;
}
};
await fs.copy(this.userDataDir, this.tempDir, { filter: skipSymlinks });
await this.deleteMetadata();
this.logger.debug('Compressing session...');
await this.zipper.compress(this.tempDir, this.compressedSessionPath);
this.logger.debug('Session compressed.');
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
this.logger.debug(`Session archive size: ${prettyBytes(zipSize)}`);
}
async unCompressSession() {
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
this.logger.debug(`Restored Session archive size: ${prettyBytes(zipSize)}`);
this.logger.debug('Uncompressing session...');
await this.zipper.uncompress(this.compressedSessionPath, this.userDataDir);
this.logger.debug('Session uncompressed.');
}
async deleteMetadata() {
const sessionDirs = [this.tempDir, path.join(this.tempDir, 'Default')];
for (const dir of sessionDirs) {
const sessionFiles = await fs.promises.readdir(dir);
for (const element of sessionFiles) {
if (this.REQUIRED_DIRS.includes(element)) {
continue;
}
const dirElement = path.join(dir, element);
await this.removePathSilently(dirElement);
}
}
}
private async removePathSilently(path: string) {
const exists = await isValidPath(path);
if (!exists) {
return;
}
try {
await fs.promises.rm(path, {
maxRetries: 4,
recursive: true,
force: true,
});
} catch (err) {
this.logger.error(err, `Error deleting: ${path}`);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
let AdmZip;
let archiver;
import * as fs from 'fs';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
AdmZip = require('adm-zip');
// eslint-disable-next-line @typescript-eslint/no-var-requires
archiver = require('archiver');
} catch {
// eslint-disable-next-line @typescript-eslint/no-var-requires
AdmZip = undefined;
// eslint-disable-next-line @typescript-eslint/no-var-requires
archiver = undefined;
}
export class StreamZipper implements Zipper {
constructor() {
if (!archiver && !AdmZip)
throw new Error(
'Optional Dependencies [archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
}
compress(path: string, archivePath: string): Promise<void> {
const archive = archiver('zip');
const stream = fs.createWriteStream(archivePath);
return new Promise((resolve, reject) => {
archive
.directory(path, false)
.on('error', (err) => reject(err))
.pipe(stream);
stream.on('close', () => resolve(null));
archive.finalize();
});
}
async uncompress(archivePath: string, path: string): Promise<void> {
const zip = new AdmZip(archivePath);
await new Promise((resolve, reject) => {
zip.extractAllToAsync(path, false, false, (err) => {
if (err) {
reject(err);
} else {
resolve(null);
}
});
});
}
}
@@ -0,0 +1,91 @@
import { DataStore } from '@waha/core/abc/DataStore';
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { RemoteAuth, Zipper } from '@waha/core/engines/webjs/RemoteAuth';
import { StreamZipper } from '@waha/core/engines/webjs/StreamZipper';
import { WebJSMongoAuth } from '@waha/core/engines/webjs/WebJSMongoAuth';
import { ZipUnzipZipper } from '@waha/core/engines/webjs/ZipUnzipZipper';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { LoggerBuilder } from '@waha/utils/logging';
import { Logger } from 'pino';
import { AuthStrategy } from 'whatsapp-web.js';
import { WebJSPsqlAuth } from './WebJSPsqlAuth';
export class WebJSAuthFactory {
async buildAuth(
store: DataStore,
name: string,
loggerBuilder: LoggerBuilder,
): Promise<AuthStrategy> {
if (store instanceof MongoStore)
return this.buildMongoAuth(store, name, loggerBuilder);
if (store instanceof PsqlStore)
return await this.buildPsql(store, name, loggerBuilder);
if (store instanceof LocalStore)
return this.buildLocalAuth(store, name, loggerBuilder);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
buildLocalAuth(
store: LocalStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: LocalAuth.name });
return new LocalAuth({
clientId: name,
dataPath: store.getSessionDirectory(name),
logger: logger,
rmMaxRetries: undefined,
});
}
async buildPsql(
store: PsqlStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: WebJSPsqlAuth.name });
const knex = store.buildSessionKnex(name, 'Session/Auth');
const authStore = new WebJSPsqlAuth(knex, logger);
await authStore.init();
const zipper = this.getAvailableZipper(logger);
return new RemoteAuth({
backupSyncIntervalMs: 60 * 1000,
clientId: name,
dataPath: null,
logger: loggerBuilder.child({ name: RemoteAuth.name }),
store: authStore,
zipper: zipper,
});
}
private buildMongoAuth(
store: MongoStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: WebJSMongoAuth.name });
const authStore = new WebJSMongoAuth(name, store, logger);
const zipper = this.getAvailableZipper(logger);
return new RemoteAuth({
backupSyncIntervalMs: 60 * 1000,
clientId: name,
dataPath: null,
logger: loggerBuilder.child({ name: RemoteAuth.name }),
store: authStore,
zipper: zipper,
});
}
private getAvailableZipper(logger: Logger): Zipper {
if (process.env.WAHA_ZIPPER == 'ZIPUNZIP') {
logger.debug('Using ZipUnzipZipper');
return new ZipUnzipZipper();
}
logger.debug('Using StreamZipper');
return new StreamZipper();
}
}
+147
View File
@@ -0,0 +1,147 @@
import { sleep } from '@nestjs/terminus/dist/utils';
import * as fs from 'fs';
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
import { Logger } from 'pino';
import { pipeline } from 'stream/promises';
import { Store } from 'whatsapp-web.js';
import { MongoStore } from '../../storage/mongo/MongoStore';
class WebJSMongoAuth implements Store {
private store: MongoStore;
private logger: Logger;
private sessionName: string;
private db: Db;
private bucket: GridFSBucket;
constructor(sessionName: string, store: MongoStore, logger: Logger) {
this.store = store;
this.logger = logger;
this.sessionName = sessionName;
this.db = this.store.getSessionDb(sessionName);
const bucketName = this.getBucketName();
this.bucket = new GridFSBucket(this.db, {
bucketName: bucketName,
});
}
async sessionExists(options) {
this.checkSessionName(options);
this.logger.info('Checking if session exists...');
const filesCollection = this.getFilesCollectionName();
const multiDeviceCollection = this.db.collection(filesCollection);
const hasExistingSession = await multiDeviceCollection.countDocuments();
const result = !!hasExistingSession;
this.logger.info(`Session exists: ${result}`);
return result;
}
async save(options) {
this.checkSessionName(options);
this.logger.debug('Saving session...');
const filename = this.getAuthFileName(options);
const readStream = fs.createReadStream(filename);
const uploadStream = this.bucket.openUploadStream(filename);
await pipeline(readStream, uploadStream);
this.logger.debug('Session saved.');
await this.#deletePrevious(options, this.bucket);
}
async extract(options) {
this.checkSessionName(options);
this.logger.info('Extracting existing session...');
const filename = this.getAuthFileName(options);
const downloadStream = this.bucket.openDownloadStreamByName(filename);
const writeStream = fs.createWriteStream(options.path);
await pipeline(downloadStream, writeStream);
// Wait a second before giving the zip file to next phase
await sleep(1_000);
this.logger.info('Session has been extracted.');
}
async delete(options) {
this.checkSessionName(options);
this.logger.debug('Deleting session...');
const filename = this.getAuthFileName(options);
const documents = await this.bucket
.find({
filename: filename,
})
.toArray();
documents.map(async (doc) => {
return this.bucket.delete(doc._id);
});
this.logger.debug('Session deleted.');
}
async #deletePrevious(options, bucket) {
const filename = this.getAuthFileName(options);
const documents = await bucket
.find({
filename: filename,
})
.toArray();
if (documents.length > 1) {
this.logger.debug('Deleting old auth files...');
// Got all, but not the last one
const oldDocuments = documents.slice();
// Sort by uploadDate, desc
oldDocuments.sort((a, b) => {
return a.uploadDate - b.uploadDate;
});
const keepDocument = oldDocuments.pop();
this.logger.debug(
`Keeping document - '${keepDocument.uploadDate}', '${keepDocument._id}'`,
);
oldDocuments.map((document: GridFSFile) => {
this.logger.debug(
`Deleting document - '${document.uploadDate}', '${document._id}'`,
);
return bucket.delete(document._id);
});
}
}
/**
* Get session name
*/
private getSessionName(options): string {
const prefix = 'RemoteAuth';
if (!options.session || options.session === prefix) {
return 'default';
}
// Remote prefix
return options.session.replace(`${prefix}-`, '');
}
/**
* Even tho we accept any "session" in options,
* but the store hold a single session database link (bucket)
* This is why we need to check if the session name
* is the same as the store session name
*/
private checkSessionName(options) {
const session = this.getSessionName(options);
if (session !== this.sessionName) {
throw new Error(
`Session name '${session}' does not match the store session name '${this.sessionName}'`,
);
}
}
private getBucketName(): string {
return `auth`;
}
private getFilesCollectionName(): string {
const bucket = this.getBucketName();
return `${bucket}.files`;
}
private getAuthFileName(options): string {
return `${options.session}.zip`;
}
}
export { WebJSMongoAuth };
+75
View File
@@ -0,0 +1,75 @@
import { sleep } from '@nestjs/terminus/dist/utils';
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import Knex from 'knex';
import { Logger } from 'pino';
import { Store } from 'whatsapp-web.js';
interface Options {
session: string;
path?: string;
}
class WebjsFileRepository extends PsqlFileRepository {
get tableName() {
return 'files';
}
}
export class WebJSPsqlAuth implements Store {
private repository: PsqlFileRepository;
constructor(
private knex: Knex.Knex,
private logger: Logger,
) {
this.repository = new WebjsFileRepository(knex, logger);
}
async sessionExists(options: Options): Promise<boolean> {
this.logger.info('Checking if session exists...');
const filename = this.getAuthFileName(options);
const exists = await this.repository.exists(filename);
this.logger.info(`Session exists: ${exists}`);
return exists;
}
async delete(options: Options): Promise<any> {
this.logger.debug('Deleting session...');
const filename = this.getAuthFileName(options);
await this.repository.delete(filename);
this.logger.debug('Session deleted.');
}
async save(options: Options): Promise<any> {
this.logger.debug('Saving session...');
const filename = this.getAuthFileName(options);
await this.repository.saveFromFile(filename, filename);
this.logger.debug('Session saved.');
}
async extract(options: Options) {
this.logger.debug('Extracting existing session...');
const filename = this.getAuthFileName(options);
const found = await this.repository.fetchToFile(filename, options.path);
if (!found) {
this.logger.warn('Session does not exist.');
return;
}
// Wait a second before giving the zip file to next phase
await sleep(1_000);
this.logger.info('Session has been extracted.');
}
async init() {
await this.repository.init();
}
private getAuthFileName(options: Options): string {
return `${options.session}.zip`;
}
async close() {
await this.knex.destroy();
}
}
+275 -90
View File
@@ -1,4 +1,5 @@
import { WebJSPresence } from '@waha/core/engines/webjs/types';
import { GetSerialized } from '@waha/core/utils/serialized';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { Label } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
@@ -7,15 +8,14 @@ import { TextStatus } from '@waha/structures/status.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Page } from 'puppeteer';
import { Client, Events } from 'whatsapp-web.js';
import { Client, Events, Message as WebjsMessage } from 'whatsapp-web.js';
import { Message } from 'whatsapp-web.js/src/structures';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { CallErrorEvent, PAGE_CALL_ERROR_EVENT, WPage } from './WPage';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadWAHA } = require('./_WAHA.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadLodash } = require('./_lodash.js');
@@ -30,24 +30,95 @@ const {
exposeFunctionIfAbsent,
} = require('whatsapp-web.js/src/util/Puppeteer');
export interface WebjsChannelMessage {
message: WebjsMessage;
reactions: ChannelMessageReaction[];
viewCount: number;
}
class ChannelMessageReaction {
reaction: string;
count: number;
}
interface _Id {
id: string;
fromMe: boolean;
remote: string;
_serialized: string;
}
/**
* "parentMsgKey": {
* "fromMe": false,
* "remote": "111111111111111111@newsletter",
* "id": "AAAAAAAAAAAAAAAAAAAA",
* "_serialized": "false_111111111111111111@newsletter_AAAAAAAAAAAAAAAAAAAA"
* },
* "serverTimestamp": 1738536731,
* "emojiCountMap": {emoji=>count}
*/
interface _NewsletterReaction {
parentMsgKey: _Id;
serverTimestamp: number;
emojiCountMap: any;
}
interface _GetNewsletterPreviewDataResponse {
ids: any[];
newsletterMetadata: any;
newsletterMessages: any[];
newsletterReactions: _NewsletterReaction[];
timestamp: number;
}
function extractReactionsByMessageKey(
newsletterReactions: _NewsletterReaction[],
): Map<string, ChannelMessageReaction[]> {
const reactions = new Map();
for (const reaction of newsletterReactions) {
const key = GetSerialized(reaction.parentMsgKey);
const emojiCountMap = reaction.emojiCountMap;
const reactionList: ChannelMessageReaction[] = [];
for (const emoji in emojiCountMap) {
reactionList.push({
reaction: emoji,
count: emojiCountMap[emoji],
});
}
reactions.set(key, reactionList);
}
return reactions;
}
export class WebjsClientCore extends Client {
public events = new EventEmitter();
private wpage: WPage = null;
private injecting: Promise<void> = null;
constructor(
options,
protected tags: boolean,
protected logger: Logger,
) {
super(options);
// Wait until it's READY and inject more utils
this.on(Events.AUTHENTICATED, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
this.on(Events.READY, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
// AUTHENTICATED and READY fire back to back - run one injection for both
this.on(Events.AUTHENTICATED, () => this.injectUtils());
this.on(Events.READY, () => this.injectUtils());
}
private injectUtils(): Promise<void> {
if (this.injecting) {
return this.injecting;
}
this.injecting = this.attachCustomEventListeners()
.then(() => this.injectWaha())
.catch((err) => this.logger.error(err, 'Failed to inject utils'))
.finally(() => {
this.injecting = null;
});
return this.injecting;
}
async initialize() {
@@ -65,7 +136,6 @@ export class WebjsClientCore extends Client {
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
}
/**
@@ -88,6 +158,37 @@ export class WebjsClientCore extends Client {
});
}
/**
* @result indicating whether the "What's New" auto-modal was prevented or dismissed.
*/
hideWhatsNewModal(): Promise<boolean> {
return this.pupPage.evaluate(() => {
// Module registry is not available until the app bundle has loaded
if (typeof window.require !== 'function') {
return false;
}
const WAWebWhatsNewNux = window.require('WAWebWhatsNewNux');
if (!WAWebWhatsNewNux) {
return false;
}
// user prefs are not writable before login
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
if (!WAWebUserPrefsMeUser.getMaybeMePnUser()) {
return false;
}
const nux = WAWebWhatsNewNux.createWhatsNewNux();
if (!nux.shouldShow()) {
return false;
}
// Bump the dismiss count and start the cool-off, so the app never auto-opens the modal
nux.dismiss();
// If the modal has already opened - close the topmost modal (no-op when nothing is open)
const WAWebModalManager = window.require('WAWebModalManager');
WAWebModalManager.ModalManager.close();
return true;
});
}
async attachCustomEventListeners() {
await exposeFunctionIfAbsent(
this.pupPage,
@@ -110,10 +211,10 @@ export class WebjsClientCore extends Client {
}
const tags = ['receipt', 'presence', 'chatstate'];
const WAWap = window.require('WAWap');
// @ts-ignore
window.decodeStanzaBack = window.Store.SocketWap.decodeStanza;
// @ts-ignore
window.Store.SocketWap.decodeStanza = async (...args) => {
window.decodeStanzaBack = WAWap.decodeStanza;
WAWap.decodeStanza = async (...args) => {
// @ts-ignore
const result = await window.decodeStanzaBack(...args);
if (tags.includes(result?.tag)) {
@@ -134,9 +235,9 @@ export class WebjsClientCore extends Client {
async setPushName(name: string) {
await this.ensureWahaInjected();
await this.pupPage.evaluate(async (pushName) => {
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
pushName,
);
return await window
.require('WAWebSetPushnameConnAction')
.setPushname(pushName);
}, name);
if (this.info) {
this.info.pushname = name;
@@ -145,16 +246,9 @@ export class WebjsClientCore extends Client {
async unpair() {
await this.pupPage.evaluate(async () => {
if (
// @ts-ignore
window.Store &&
// @ts-ignore
window.Store.AppState &&
// @ts-ignore
typeof window.Store.AppState.logout === 'function'
) {
// @ts-ignore
await window.Store.AppState.logout();
const Socket = window.require('WAWebSocketModel')?.Socket;
if (Socket && typeof Socket.logout === 'function') {
await Socket.logout();
}
});
}
@@ -163,11 +257,9 @@ export class WebjsClientCore extends Client {
await this.ensureWahaInjected();
const labelId: number = (await this.pupPage.evaluate(
async (name, color) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelAddAction(
name,
color,
);
return await window
.require('WAWebBizLabelEditingAction')
.labelAddAction(name, color);
},
name,
color,
@@ -178,20 +270,16 @@ export class WebjsClientCore extends Client {
async deleteLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelDeleteAction(
label.id,
label.name,
label.color,
);
return await window
.require('WAWebBizLabelEditingAction')
.labelDeleteAction(label.id, label.name, label.color);
}, label);
}
async updateLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelEditAction(
return await window.require('WAWebBizLabelEditingAction').labelEditAction(
label.id,
label.name,
undefined, // predefinedId
@@ -213,8 +301,27 @@ export class WebjsClientCore extends Client {
const chats = await this.pupPage.evaluate(
async (pagination, filter) => {
let chats = window
.require('WAWebCollections')
.Chat.getModelsArray()
.slice();
// Filter chats by IDs if filter is provided
if (filter && filter.ids && filter.ids.length > 0) {
chats = chats.filter((chat) =>
// @ts-ignore
filter.ids.includes(window.WWebJS.GetSerialized(chat.id)),
);
}
// @ts-ignore
return await window.WAHA.getChats(pagination, filter);
const paginator = new window.Paginator(pagination);
chats = paginator.apply(chats);
const chatPromises = chats.map((chat) =>
// @ts-ignore
window.WWebJS.getChatModel(chat),
);
return await Promise.all(chatPromises);
},
pagination,
filter,
@@ -226,7 +333,7 @@ export class WebjsClientCore extends Client {
protected async ensureWahaInjected() {
const hasWaha = await this.pupPage.evaluate(() => {
// @ts-ignore
return Boolean(window.WAHA && window.WAHA.getChats);
return Boolean(window.Paginator);
});
if (!hasWaha) {
await this.injectWaha();
@@ -244,14 +351,14 @@ export class WebjsClientCore extends Client {
font: status.font,
};
const sentMsg = await this.pupPage.evaluate(async (status) => {
// @ts-ignore
await window.Store.SendStatus.sendStatusTextMsgAction(status);
// @ts-ignore
const meUser = window.Store.User.getMaybeMePnUser();
// @ts-ignore
const myStatus = window.Store.Status.getModelsArray().findLast(
(x) => x.id == meUser,
);
await window
.require('WAWebSendStatusMsgAction')
.sendStatusTextMsgAction(status);
const meUser = window.require('WAWebUserPrefsMeUser').getMaybeMePnUser();
const myStatus = window
.require('WAWebCollections')
.Status.getModelsArray()
.findLast((x) => x.id == meUser);
if (!myStatus) {
return undefined;
}
@@ -319,17 +426,20 @@ export class WebjsClientCore extends Client {
// Construct the initial anchor the same way wa-js does:
// serialize to string then reconstruct via MsgKey.fromString so the
// object has the exact shape msgFindByDirection expects.
const lastReceivedSerialized = chat.lastReceivedKey?._serialized;
if (!lastReceivedSerialized) return [];
// @ts-ignore
let currentAnchorKey = window.Store.MsgKey.fromString(
lastReceivedSerialized,
const lastReceivedSerialized = window.WWebJS.GetSerialized(
chat.lastReceivedKey,
);
if (!lastReceivedSerialized) return [];
let currentAnchorKey = window
.require('WAWebMsgKey')
.fromString(lastReceivedSerialized);
// msgFindByDirection is exclusive of the anchor; include the anchor
// message itself (the most recent message in the chat) upfront
// @ts-ignore
const anchorMsg = window.Store.Msg.get(lastReceivedSerialized);
const anchorMsg = window
.require('WAWebCollections')
.Msg.get(lastReceivedSerialized);
if (anchorMsg) {
msgs.push(anchorMsg);
}
@@ -343,14 +453,14 @@ export class WebjsClientCore extends Client {
// @ts-ignore
const toModel = (m) => {
if (m && typeof m.serialize === 'function') return m;
const serializedId = m?.id?._serialized;
// @ts-ignore
const serializedId = window.WWebJS.GetSerialized(m?.id);
const Msg = window.require('WAWebCollections').Msg;
if (serializedId) {
// @ts-ignore
const stored = window.Store.Msg.get(serializedId);
const stored = Msg.get(serializedId);
if (stored) return stored;
}
// @ts-ignore
return new window.Store.Msg.modelClass(m);
return new Msg.modelClass(m);
};
while (true) {
@@ -375,7 +485,8 @@ export class WebjsClientCore extends Client {
// appear in multiple batches when anchors overlap
const seenIds = new Set();
msgs = msgs.filter((m) => {
const sid = m?.id?._serialized;
// @ts-ignore
const sid = window.WWebJS.GetSerialized(m?.id);
if (!sid || seenIds.has(sid)) return false;
seenIds.add(sid);
return true;
@@ -401,21 +512,22 @@ export class WebjsClientCore extends Client {
// the oldest message in this batch — use it as the next anchor to
// walk further back in history without overlap
const oldestInBatch = batchModels[batchModels.length - 1];
const oldestSerialized = oldestInBatch?.id?._serialized;
if (!oldestSerialized) break;
// @ts-ignore
currentAnchorKey = window.Store.MsgKey.fromString(oldestSerialized);
const oldestSerialized = window.WWebJS.GetSerialized(
oldestInBatch?.id,
);
if (!oldestSerialized) break;
currentAnchorKey = window
.require('WAWebMsgKey')
.fromString(oldestSerialized);
}
} else {
// Legacy fallback: loadEarlierMsgs loop
msgs = chat.msgs.getModelsArray();
while (msgs.length < pagination.limit + pagination.offset) {
const loadedMessages =
// @ts-ignore
await window.Store.ConversationMsgs.loadEarlierMsgs(
chat,
chat.msgs,
);
const loadedMessages = await window
.require('WAWebChatLoadMessages')
.loadEarlierMsgs(chat, chat.msgs);
if (!loadedMessages || loadedMessages.length == 0) break;
msgs = [...loadedMessages, ...msgs];
@@ -464,8 +576,7 @@ export class WebjsClientCore extends Client {
pagination.offset ||= 0;
pagination.sortBy ||= 'lid';
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
const WAWebApiContact = window.require('WAWebApiContact');
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
@@ -473,9 +584,9 @@ export class WebjsClientCore extends Client {
const result = values.map((map) => {
return {
// @ts-ignore
lid: map.lid._serialized,
lid: window.WWebJS.GetSerialized(map.lid),
// @ts-ignore
pn: map.phoneNumber._serialized,
pn: window.WWebJS.GetSerialized(map.phoneNumber),
};
});
// @ts-ignore
@@ -490,8 +601,7 @@ export class WebjsClientCore extends Client {
public async getLidsCount(): Promise<number> {
const count: number = (await this.pupPage.evaluate(async () => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
const WAWebApiContact = window.require('WAWebApiContact');
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
@@ -502,28 +612,26 @@ export class WebjsClientCore extends Client {
public async findPNByLid(lid: string): Promise<string> {
const pn = await this.pupPage.evaluate(async (lid) => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
// @ts-ignore
const WAWebWidFactory = window.Store.WidFactory;
const WAWebApiContact = window.require('WAWebApiContact');
const WAWebWidFactory = window.require('WAWebWidFactory');
const wid = WAWebWidFactory.createWid(lid);
const result = WAWebApiContact.getPhoneNumber(wid);
return result ? result._serialized : null;
// @ts-ignore
return window.WWebJS.GetSerialized(result);
}, lid);
return pn;
}
public async findLIDByPhoneNumber(phoneNumber: string): Promise<string> {
const lid: string = (await this.pupPage.evaluate(async (pn) => {
// @ts-ignore
const WAWebApiContact = window.Store.LidUtils;
// @ts-ignore
const WAWebWidFactory = window.Store.WidFactory;
const WAWebApiContact = window.require('WAWebApiContact');
const WAWebWidFactory = window.require('WAWebWidFactory');
const wid = WAWebWidFactory.createWid(pn);
const result = WAWebApiContact.getCurrentLid(wid);
return result ? result._serialized : null;
// @ts-ignore
return window.WWebJS.GetSerialized(result);
}, phoneNumber)) as any;
return lid;
}
@@ -563,7 +671,8 @@ export class WebjsClientCore extends Client {
}
return chatstates.map((chatstate) => {
return {
participant: chatstate.id._serialized,
// @ts-ignore
participant: window.WWebJS.GetSerialized(chatstate.id),
lastSeen: chatstate.t,
state: chatstate.type,
};
@@ -578,4 +687,80 @@ export class WebjsClientCore extends Client {
await sleep(3_000);
return await this.getCurrentPresence(chatId);
}
/**
* Channels methods
*/
async channelFetchMessageByInvite(
inviteCode: string,
limit: number,
): Promise<WebjsChannelMessage[]> {
const response: _GetNewsletterPreviewDataResponse =
await this.pupPage.evaluate(
async (code, limit) => {
// Overwrite the server-side message count so the preview fetches
// exactly `limit` messages
window.require(
'WAWebNewsletterGatingUtils',
).getMaxMsgCountFromServer = () => limit;
const result = await window
.require('WAWebNewsletterPreviewJob')
.getNewsletterPreviewData(code, 'guest');
for (const newsletterReaction of result.newsletterReactions) {
// puppeter doesn't support Map,
// so we need to convert it to object
newsletterReaction.emojiCountMap = Object.fromEntries(
newsletterReaction.emojiCountMap,
);
}
// Fetch one more time to save in database so we can fetch media later
await window
.require('WAWebLoadNewsletterPreviewChatAction')
.loadNewsletterPreviewChat(code);
return result;
},
inviteCode,
limit,
);
const messageInstances = response.newsletterMessages
.filter((msg) => msg.type != 'revoked')
.map((msg) => {
return new MessageInstance(this, msg);
});
const reactions = extractReactionsByMessageKey(
response.newsletterReactions,
);
const messages: WebjsChannelMessage[] = messageInstances.map((msg) => {
return {
message: msg,
reactions: reactions.get(GetSerialized(msg.id)) || [],
viewCount: msg.rawData.viewCount,
};
});
return messages;
}
/**
* Channels Search methods
*/
async searchChannelsView(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectoryList(params);
}, params);
return newsletters;
}
async searchChannelsText(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectorySearchResults(params);
}, params);
return newsletters;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
import { execFile as execFileOrigin } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const execFile = promisify(execFileOrigin);
export class ZipUnzipZipper implements Zipper {
async compress(dirPath: string, archivePath: string): Promise<void> {
// resolve archivePath to full path
const archiveFullPath = path.resolve(archivePath);
const dirFullPath = path.resolve(dirPath);
const opts = {
cwd: dirFullPath,
};
await execFile('zip', ['-rq1', archiveFullPath, '.'], opts);
return;
}
async uncompress(archivePath: string, dirPath: string): Promise<void> {
const archiveFullPath = path.resolve(archivePath);
const dirFullPath = path.resolve(dirPath);
// upsert path, create if not exist
fs.mkdirSync(dirFullPath, { recursive: true });
await execFile('unzip', ['-q', archiveFullPath, '-d', dirFullPath]);
return;
}
}
-30
View File
@@ -1,30 +0,0 @@
exports.LoadWAHA = () => {
window.WAHA = {};
window.WAHA.WAWebBizLabelEditingAction = window.require(
'WAWebBizLabelEditingAction',
);
window.WAHA.getChats = async (pagination, filter) => {
let chats = window.Store.Chat.getModelsArray().slice();
// Filter chats by IDs if filter is provided
if (filter && filter.ids && filter.ids.length > 0) {
chats = chats.filter((chat) => filter.ids.includes(chat.id._serialized));
}
const paginator = new window.Paginator(pagination);
chats = paginator.apply(chats);
const chatPromises = chats.map((chat) => window.WWebJS.getChatModel(chat));
return await Promise.all(chatPromises);
};
// Set push name
window.WAHA.WAWebSetPushnameConnAction = window.require(
'WAWebSetPushnameConnAction',
);
// Get my status
window.WAHA.WAWebTextStatusCollection = window.require(
'WAWebTextStatusCollection',
);
};
+7 -6
View File
@@ -19,6 +19,7 @@ import {
GroupParticipant as WEBJSGroupParticipant,
} from 'whatsapp-web.js';
import { isPnUser } from '@waha/core/utils/jids';
import { GetSerialized } from '@waha/core/utils/serialized';
function ToGroupInfo(
group: GroupChat,
@@ -29,11 +30,11 @@ function ToGroupInfo(
const groupMetadata = group.groupMetadata;
const info: GroupInfo = {
// @ts-ignore
id: group.id._serialized,
id: GetSerialized(group.id),
subject: group.name,
description: group.description,
invite: invite,
membersCanAddNewMember: groupMetadata.restrict,
membersCanAddNewMember: groupMetadata.memberAddMode === 'all_member_add',
membersCanSendMessages: groupMetadata.announce,
newMembersApprovalRequired: groupMetadata.membershipApprovalMode,
participants: participants,
@@ -72,11 +73,10 @@ export function getParticipants(
role = GroupParticipantRole.ADMIN;
}
const participantId = GetSerialized(participant.id);
return {
id: participant.id._serialized,
pn: isPnUser(participant.id._serialized)
? participant.id._serialized
: null,
id: participantId,
pn: isPnUser(participantId) ? participantId : null,
role: role,
};
});
@@ -140,6 +140,7 @@ export function ToGroupV2ParticipantsEvent(
(id) => {
return {
id: id,
pn: isPnUser(id) ? id : null,
role: role,
};
},
+532 -60
View File
@@ -1,6 +1,8 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { parseMessageCapping } from '@waha/core/abc/capping';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
@@ -18,20 +20,21 @@ import {
ToGroupV2ParticipantsEvent,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/webjs/groups.webjs';
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
import {
TagChatstateToPresence,
TagPresenceToPresence,
} from '@waha/core/engines/webjs/presence';
import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
import {
WebjsChannelMessage,
WebjsClientCore,
} from '@waha/core/engines/webjs/WebjsClientCore';
import {
CallErrorEvent,
PAGE_CALL_ERROR_EVENT,
} from '@waha/core/engines/webjs/WPage';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { detectMimetype } from '@waha/utils/files';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
@@ -47,6 +50,7 @@ import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelPublicInfo,
ChannelRole,
ChannelSearchByText,
ChannelSearchByView,
@@ -75,11 +79,12 @@ import {
MessageImageRequest,
MessageLocationRequest,
MessagePollRequest,
MessagePollVoteRequest,
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageTextRequest,
MessageVoiceRequest,
MessageVideoRequest,
SendSeenRequest,
WANumberExistResult,
} from '@waha/structures/chatting.dto';
@@ -104,6 +109,7 @@ import {
GroupParticipant,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
@@ -121,8 +127,21 @@ import {
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { DeleteStatusRequest, TextStatus } from '@waha/structures/status.dto';
import {
MeInfo,
MessageCappingData,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
PollVote as WAHAPollVote,
@@ -132,12 +151,13 @@ import {
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import { TmpDir } from '@waha/utils/tmpdir';
import * as lodash from 'lodash';
import * as path from 'path';
import { ProtocolError } from 'puppeteer';
import { Browser, ProtocolError } from 'puppeteer';
import {
filter,
fromEvent,
@@ -171,8 +191,10 @@ import {
Message as MessageInstance,
Call as CallInstance,
} from 'whatsapp-web.js/src/structures';
import { GetSerialized } from '@waha/core/utils/serialized';
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
import { WebJSAuthFactory } from './WebJSAuthFactory';
import {
isJidCus,
isJidGroup,
@@ -200,12 +222,17 @@ export interface WebJSConfig {
export class WhatsappSessionWebJSCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
// how long to wait on stop for the browser to close gracefully before force-killing it
private DESTROY_TIMEOUT_MS = 10_000;
authFactory = new WebJSAuthFactory();
engine = WAHAEngine.WEBJS;
protected engineConfig?: WebJSConfig;
private startDelayedJob: SingleDelayedJobRunner;
private engineStateCheckDelayedJob: SingleDelayedJobRunner;
private whatsNewModalJob: SinglePeriodicJobRunner;
private shouldRestart: boolean;
private lastQRDate: Date = null;
private static readonly REACTION_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
@@ -230,6 +257,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
2 * SECOND,
this.logger,
);
// Hide the "What's New" modal that blocks app init after QR login
this.whatsNewModalJob = new SinglePeriodicJobRunner(
'hide-whats-new-modal',
SECOND,
this.logger,
false,
);
}
/**
@@ -284,16 +318,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
protected async buildClient() {
const authStrategy = await this.authFactory.buildAuth(
this.sessionStore,
this.name,
this.loggerBuilder,
);
const clientOptions = this.getClientOptions();
const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
clientOptions.authStrategy = new LocalAuth({
clientId: this.name,
dataPath: `${base}/webjs/default`,
logger: this.logger,
rmMaxRetries: undefined,
});
clientOptions.authStrategy = authStrategy;
this.addProxyConfig(clientOptions);
return new WebjsClientCore(clientOptions, this.getWebjsTagsFlag());
return new WebjsClientCore(
clientOptions,
this.getWebjsTagsFlag(),
this.logger,
);
}
protected getWebjsTagsFlag() {
@@ -401,6 +438,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
console.log(`url is ${location.href}`),
);
}
this.startWhatsNewModalJob();
})
.catch((error) => {
this.logger.error(error);
@@ -434,11 +473,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
async stop() {
this.cleanupPresenceTimeout();
this.shouldRestart = false;
this.startDelayedJob.cancel();
await this.end();
this.status = WAHASessionStatus.STOPPED;
this.stopEvents();
this.startDelayedJob.cancel();
this.mediaManager.close();
await this.end();
}
protected failed() {
@@ -469,6 +508,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.cleanupPresenceTimeout();
this.presence = null;
this.engineStateCheckDelayedJob.cancel();
this.whatsNewModalJob.stop();
this.whatsapp?.removeAllListeners();
this.whatsapp?.pupBrowser?.removeAllListeners();
this.whatsapp?.pupPage?.removeAllListeners();
@@ -495,10 +535,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
try {
await this.whatsapp?.destroy();
await promiseTimeout(this.DESTROY_TIMEOUT_MS, this.whatsapp?.destroy());
this.logger.debug('Successfully destroyed whatsapp client');
} catch (error) {
this.logger.error(error, 'Failed to destroy whatsapp client');
const browser = this.whatsapp?.pupBrowser;
if (browser) {
await this.kill(browser);
}
}
try {
@@ -511,6 +555,33 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
/**
* Force-kill the Chromium process when the graceful destroy() failed or timed out.
* Throws if the browser can not be proven dead - the session is not fully stopped then.
*/
private async kill(browser: Browser) {
// shadows the Node.js global 'process' on purpose - the global one is not used here
const process = browser.process();
if (!process) {
// remote or attached browser - we have no process handle to kill
throw new Error(
'No browser process to kill, can not guarantee the browser is closed',
);
}
if (process.exitCode !== null || process.signalCode !== null) {
return;
}
this.logger.warn('Force killing the browser process');
const exited = new Promise<void>((resolve) =>
process.once('exit', () => resolve()),
);
process.kill('SIGKILL');
// the session is stopped only when the browser process is really dead - verify it, do not assume
await promiseTimeout(5_000, exited).catch(() => {
throw new Error('The browser process did not exit after SIGKILL');
});
}
getSessionMeInfo(): MeInfo | null {
const clientInfo = this.whatsapp?.info;
if (!clientInfo) {
@@ -518,8 +589,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
const wid = clientInfo.wid;
return {
id: wid?._serialized,
id: GetSerialized(wid),
lid: GetSerialized(clientInfo.lid),
pushName: clientInfo?.pushname,
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
@@ -535,7 +609,54 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
private startWhatsNewModalJob() {
// The "What's New" modal can block app init right after QR login, so the session never leaves STARTING
// https://github.com/devlikeapro/waha/issues/2217
this.whatsNewModalJob.start(async () => {
let hidden = false;
try {
hidden = await this.whatsapp.hideWhatsNewModal();
} catch (err) {
// The page navigates during login, evaluate() can fail - retry on the next tick
this.logger.debug(`Failed to hide "What's New" modal: ${err}`);
return;
}
if (hidden) {
this.logger.info(`"What's New" modal has been dismissed`);
return;
}
// The cool-off is stored user-scoped, so keep re-checking until it holds after login
if (this.status === WAHASessionStatus.WORKING) {
this.whatsNewModalJob.stop();
}
});
}
protected listenConnectionEvents() {
this.whatsapp.on(Events.REACHOUT_TIMELOCK_UPDATE, (record: any) => {
this.updateReachoutTimelockFromRecord(record);
});
this.whatsapp.on(Events.MESSAGE_CAPPING_UPDATE, (record: any) => {
// Unlike the timelock, a null record means "no local data yet", not "capping lifted"
if (!record) {
return;
}
this.messageCapping.update(parseMessageCapping(record));
});
this.whatsapp.on(Events.READY, async () => {
// Ask WhatsApp for the current message capping state, the same fetch the app runs on startup
try {
const data = await this.whatsapp.fetchMessageCapping();
if (data) {
this.messageCapping.update(parseMessageCapping(data));
}
} catch (err) {
this.logger.warn(err, 'Failed to fetch message capping');
}
});
this.whatsapp.on(Events.QR_RECEIVED, async (qr) => {
this.logger.debug('QR received');
// Convert to image and save
@@ -651,14 +772,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
private async loadClientInfo() {
const data = await this.whatsapp.pupPage.evaluate(() => {
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
return {
// @ts-ignore
...window.Store.Conn.serialize(),
...window.require('WAWebConnModel').Conn.serialize(),
wid:
// @ts-ignore
window.Store.User.getMaybeMePnUser() ||
// @ts-ignore
window.Store.User.getMaybeMeLidUser(),
WAWebUserPrefsMeUser.getMaybeMePnUser() ||
WAWebUserPrefsMeUser.getMaybeMeLidUser(),
};
});
this.whatsapp.info = data as any;
@@ -727,7 +846,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
return {
numberExists: true,
chatId: result._serialized,
chatId: GetSerialized(result),
};
}
@@ -746,12 +865,105 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
private async fileToMedia(
file: BinaryFile | RemoteFile,
): Promise<MessageMedia> {
if ('url' in file) {
const mediaOptions = { unsafeMime: true };
const media = await MessageMedia.fromUrl(file.url, mediaOptions);
media.mimetype = file.mimetype || media.mimetype;
media.filename = file.filename || media.filename;
return media;
}
return new MessageMedia(file.mimetype, file.data, file.filename);
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
@Activity()
protected async setProfilePicture(
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
return await this.whatsapp.setProfilePicture(media);
}
protected async deleteProfilePicture(): Promise<boolean> {
return await this.whatsapp.deleteProfilePicture();
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const data = await this.whatsapp.fetchMessageCapping();
if (!data) {
throw new NotImplementedByEngineError(
'Message capping is not available in the current WhatsApp Web version',
);
}
const capping = parseMessageCapping(data);
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const record = await this.whatsapp.fetchReachoutTimelock();
if (record === undefined) {
throw new NotImplementedByEngineError(
'Reachout timelock is not available in the current WhatsApp Web version',
);
}
return this.updateReachoutTimelockFromRecord(record);
}
// Applies the stored 'WAReachoutTimelockState' record (or its removal) to the tracker,
// so MeInfo and 'session.status' reflect it, and returns the resulting state
private updateReachoutTimelockFromRecord(record: any): ReachoutTimelockData {
if (!record) {
// The app removes the stored record when the enforcement is lifted (or there is none)
const current = this.reachoutTimelock.value;
if (current?.isActive) {
this.reachoutTimelock.update({ ...current, isActive: false });
}
return (
this.reachoutTimelock.value ?? {
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
isActive: false,
timeEnforcementEnds: null,
}
);
}
const enforcementType =
record.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
const timelock: ReachoutTimelockData = {
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
// The record only exists while the enforcement is active
isActive: true,
// The app stores 'time_enforcement_ends' as unix milliseconds
timeEnforcementEnds: record.time_enforcement_ends
? EnsureSeconds(record.time_enforcement_ends)
: null,
};
this.reachoutTimelock.update(timelock);
return timelock;
}
/**
* Groups methods
*/
@Activity()
protected async setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
const media = await this.fileToMedia(file);
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
return await groupChat.setPicture(media);
}
@Activity()
protected async deleteGroupPicture(id: string): Promise<boolean> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
return await groupChat.deletePicture();
}
/**
@@ -845,20 +1057,141 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
}
sendImage(request: MessageImageRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
const media = await this.fileToMedia(request.file);
if (!media.mimetype) {
media.mimetype = await detectMimetype(Buffer.from(media.data, 'base64'));
}
let options = this.getMessageOptions(request);
options = {
...options,
sendMediaAsDocument: true,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
);
}
sendFile(request: MessageFileRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
const media = await this.fileToMedia(request.file);
media.mimetype = media.mimetype || WAMimeType.IMAGE;
let options = this.getMessageOptions(request);
options = {
...options,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
);
}
sendVoice(request: MessageVoiceRequest) {
throw new AvailableInPlusVersion();
@Activity()
async sendVoice(request) {
const media = await this.fileToMedia(request.file);
if (request.convert) {
await this.convertVoice(media);
}
media.mimetype = request.file.mimetype || WAMimeType.VOICE;
let options = this.getMessageOptions(request);
options = {
...options,
sendAudioAsVoice: true,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
);
}
sendButtonsReply(request: MessageButtonReply) {
throw new AvailableInPlusVersion();
@Activity()
async sendVideo(request: MessageVideoRequest) {
this.checkBrowserIsChrome();
const media = await this.fileToMedia(request.file);
media.mimetype = media.mimetype || WAMimeType.VIDEO;
if (request.convert) {
await this.convertVideo(media);
}
let options = this.getMessageOptions(request);
options = {
...options,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
);
}
private async convertVideo(media: MessageMedia) {
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
content = await this.mediaConverter.video(content);
media.data = content.toString('base64');
media.mimetype = WAMimeType.VIDEO;
media.filename = null;
media.filesize = null;
}
private async convertVoice(media: MessageMedia) {
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
content = await this.mediaConverter.voice(content);
media.data = content.toString('base64');
media.mimetype = WAMimeType.VOICE;
media.filename = null;
media.filesize = null;
}
@Activity()
async sendPollVote(request: MessagePollVoteRequest) {
const message = await this.whatsapp.getMessageById(request.pollMessageId);
if (!message) {
throw new UnprocessableEntityException(
`Poll message not found: ${request.pollMessageId}`,
);
}
return message.vote(request.votes);
}
@Activity()
async sendButtonsReply(request: MessageButtonReply) {
const options = this.getMessageOptions(request);
const extra: any = {
type: 'buttons_response',
kind: 'buttonsResponse',
// WAWebGenerateButtonsResponseMessageProto reads the top-level field; nested buttonsResponse is legacy-gated
// behind the "wa_web_buttons_response_prop_removal_killswitch" ABProp (default off).
// Keep both like the native WAWebSendButtonsMsgReplyChatAction does
selectedButtonId: request.selectedButtonID,
buttonsResponse: {
selectedButtonId: request.selectedButtonID,
selectedDisplayText: request.selectedDisplayText,
type: 1,
},
viewMode: 'VISIBLE',
};
options.extra = extra;
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
request.selectedDisplayText,
options,
);
}
private checkBrowserIsChrome() {
if (!IsChrome) {
const msg =
'Use "devlikeapro/waha:chrome" docker image to send video in WEBJS';
throw new UnprocessableEntityException(msg);
}
}
@Activity()
@@ -971,14 +1304,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
const picture = await this.getContactProfilePicture(
chat.id._serialized,
GetSerialized(chat.id),
false,
);
const lastMessage = chat.lastMessage
? this.toWAMessage(chat.lastMessage)
: null;
return {
id: chat.id._serialized,
id: GetSerialized(chat.id),
name: chat.name || null,
picture: picture,
lastMessage: lastMessage,
@@ -1084,7 +1417,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
// @ts-ignore
message.rawData.receipts = await message.getInfo().catch((error) => {
this.logger.error(
{ error: error, msg: message.id._serialized },
{ error: error, msg: GetSerialized(message.id) },
'Failed to get receipts',
);
return null;
@@ -1342,6 +1675,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return groupChat.setMessagesAdminsOnly(value);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
return {
membersCanAddNewMember:
// @ts-ignore
groupChat.groupMetadata.memberAddMode === 'all_member_add',
};
}
@Activity()
public async setMemberAddMode(id, value) {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
// The library setter is inverted - it takes "adminsOnly"
return groupChat.setAddMembersAdminsOnly(!value);
}
public async getGroups(pagination: PaginationParams) {
const chats = await this.whatsapp.getChats();
const groups = lodash.filter(chats, (chat) => chat.isGroup);
@@ -1460,23 +1809,85 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
@Activity()
public async searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const params = {
view: query.view || 'TRENDING',
countryCodes: query.countries,
cursorToken: query.startCursor,
categories: query.categories,
limit: query.limit,
};
const data = await this.whatsapp.searchChannelsView(params);
return this.channelsRawDataToResponse(data);
}
public searchChannelsByText(
@Activity()
public async searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
const params = {
searchText: query.text,
cursorToken: query.startCursor,
categories: query.categories,
limit: query.limit,
};
const data = await this.whatsapp.searchChannelsText(params);
return this.channelsRawDataToResponse(data);
}
private channelsRawDataToResponse(data: any): ChannelListResult {
const pageInfo = data.pageInfo;
const newsletters = data.newsletters;
return {
page: {
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
},
channels: newsletters.map(NewsletterMetadataToChannel),
};
}
@Activity()
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
const channelMessages = await this.whatsapp.channelFetchMessageByInvite(
inviteCode,
query.limit,
);
const promises = [];
for (const msg of channelMessages) {
promises.push(
this.WebjsChannelMessageToChannelMessage(msg, query.downloadMedia),
);
}
return await Promise.all(promises);
}
private async WebjsChannelMessageToChannelMessage(
channelMessage: WebjsChannelMessage,
downloadMedia: boolean,
): Promise<ChannelMessage> {
const message = await this.processIncomingMessage(
channelMessage.message,
downloadMedia,
);
const reactions = {};
for (const reaction of channelMessage.reactions.sort((x) => -x.count)) {
reactions[reaction.reaction] = reaction.count;
}
return {
message: message,
reactions: reactions,
viewCount: channelMessage.viewCount,
};
}
protected ChatToChannel(chat: WEBJSChannel): Channel {
@@ -1487,7 +1898,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
role = ChannelRole.GUEST;
}
return {
id: chat.id._serialized,
id: GetSerialized(chat.id),
name: chat.name,
description: chat.description,
invite: getChannelInviteLink(metadata.inviteCode),
@@ -1680,6 +2091,44 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
@Activity()
public async sendImageStatus(status: ImageStatus) {
this.checkStatusRequest(status);
const media = await this.fileToMedia(status.file);
const options = {
caption: status.caption,
};
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
}
@Activity()
public async sendVoiceStatus(status: VoiceStatus) {
this.checkStatusRequest(status);
const media = await this.fileToMedia(status.file);
if (status.convert) {
await this.convertVoice(media);
}
media.mimetype = status.file.mimetype || WAMimeType.VOICE;
const options = {
sendAudioAsVoice: true,
};
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
}
@Activity()
public async sendVideoStatus(status: VideoStatus) {
this.checkBrowserIsChrome();
this.checkStatusRequest(status);
const media = await this.fileToMedia(status.file);
if (status.convert) {
await this.convertVideo(media);
}
const options = {
caption: status.caption,
};
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
}
@Activity()
public sendTextStatus(status: TextStatus) {
this.checkStatusRequest(status);
@@ -1948,10 +2397,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
},
);
const chatsArchived$ = chatArchived$.pipe(
filter((event: any) => this.jids.include(event?.chat?.id?._serialized)),
filter((event: any) => this.jids.include(GetSerialized(event?.chat?.id))),
map((event) => {
return {
id: event.chat.id._serialized,
id: GetSerialized(event.chat.id),
archived: event.archived,
timestamp: event.chat.timestamp,
};
@@ -2038,7 +2487,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const source = this.getMessageSource(reaction.id.id);
return {
id: reaction.id._serialized,
id: GetSerialized(reaction.id),
from: normalizeJid(reaction.senderId),
fromMe: reaction.id.fromMe,
source: source,
@@ -2047,13 +2496,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
timestamp: reaction.timestamp,
reaction: {
text: reaction.reaction,
messageId: reaction.msgId._serialized,
messageId: GetSerialized(reaction.msgId),
},
};
}
private toPollVotePayload(vote: WebjsPollVote): PollVotePayload | null {
const pollMessageId = vote?.parentMessage?.id?._serialized;
const pollMessageId = GetSerialized(vote?.parentMessage?.id);
if (!pollMessageId) {
return null;
}
@@ -2133,10 +2582,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
protected toWAMessage(message: Message): WAMessage {
const replyTo = this.extractReplyTo(message);
const source = this.getMessageSource(message.id.id);
const key = parseMessageIdSerialized(message.id._serialized);
const key = parseMessageIdSerialized(GetSerialized(message.id));
// @ts-ignore
return {
id: message.id._serialized,
id: GetSerialized(message.id),
timestamp: message.timestamp,
from: message.from,
fromMe: message.fromMe,
@@ -2210,7 +2659,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
protected toWAContact(contact: Contact) {
// @ts-ignore
contact.id = contact.id._serialized;
contact.id = GetSerialized(contact.id);
return contact;
}
@@ -2249,6 +2698,29 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
function NewsletterMetadataToChannel(data: any): ChannelPublicInfo {
const pictureDirectPath =
data.newsletterPictureMetadataMixin.picture?.[0]
.queryPictureDirectPathOrEmptyResponseMixinGroup?.value?.directPath;
const pictureUrl = getPublicUrlFromDirectPath(pictureDirectPath);
return {
id: data.idJid,
name: data.newsletterNameMetadataMixin.nameElementValue,
picture: pictureUrl,
description:
data.newsletterDescriptionMetadataMixin
.descriptionQueryDescriptionResponseMixin.elementValue,
invite: getChannelInviteLink(
data.newsletterInviteLinkMetadataMixin.inviteCode,
),
subscribersCount: Number(
data.newsletterSubscribersMetadataMixin.subscribersCount,
),
verified:
data.newsletterVerificationMetadataMixin.verificationState === 'verified',
};
}
export class WEBJSEngineMediaProcessor
implements IMediaEngineProcessor<Message>
{
@@ -2265,7 +2737,7 @@ export class WEBJSEngineMediaProcessor
}
getMessageId(message: Message): string {
return message.id._serialized;
return GetSerialized(message.id);
}
getMimetype(message: Message): string {
+114
View File
@@ -0,0 +1,114 @@
import { DataStore } from '@waha/core/abc/DataStore';
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
import { WAHASessionStatus } from '@waha/structures/enums.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { Logger } from 'pino';
import { Zipper } from '../webjs/RemoteAuth';
import { StreamZipper } from '../webjs/StreamZipper';
import { ZipUnzipZipper } from '../webjs/ZipUnzipZipper';
import { WPPAuthManager } from './WPPAuthManager';
import { WPPMongoStore } from './WPPMongoStore';
import { WPPPsqlStore } from './WPPPsqlStore';
export class WPPAuthFactory {
async build(
store: DataStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): Promise<IWPPAuthManager | null> {
if (store instanceof LocalStore) {
return null;
}
if (store instanceof MongoStore) {
return this.buildMongo(
store,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
if (store instanceof PsqlStore) {
return this.buildPsql(
store,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
throw new Error(
`WPPAuthFactory: unsupported store type '${store.constructor.name}'`,
);
}
private buildMongo(
store: MongoStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): IWPPAuthManager {
const logger = loggerBuilder.child({ name: WPPMongoStore.name });
const wppStore = new WPPMongoStore(store, sessionName, logger);
return this.buildManager(
wppStore,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
private async buildPsql(
store: PsqlStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): Promise<IWPPAuthManager> {
const logger = loggerBuilder.child({ name: WPPPsqlStore.name });
const wppStore = await WPPPsqlStore.build(store, sessionName, logger);
return this.buildManager(
wppStore,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
private buildManager(
wppStore: WPPMongoStore | WPPPsqlStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): WPPAuthManager {
const logger = loggerBuilder.child({ name: WPPAuthManager.name });
const zipper = this.getZipper(logger);
return new WPPAuthManager(
wppStore,
sessionName,
userDataDir,
zipper,
logger,
getStatus,
);
}
private getZipper(logger: Logger): Zipper {
if (process.env.WAHA_ZIPPER === 'ZIPUNZIP') {
logger.debug('Using ZipUnzipZipper');
return new ZipUnzipZipper();
}
logger.debug('Using StreamZipper');
return new StreamZipper();
}
}
+148
View File
@@ -0,0 +1,148 @@
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
import { WAHASessionStatus } from '@waha/structures/enums.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import * as fs from 'fs-extra';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { Logger } from 'pino';
import { Zipper } from '../webjs/RemoteAuth';
import { WPPStore } from './WPPStore';
// Only these dirs need to be backed up from the Chromium profile
const REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
const INITIAL_DELAY_MS = 60_000;
export class WPPAuthManager implements IWPPAuthManager {
private backupRunner: SinglePeriodicJobRunner | null = null;
constructor(
private readonly store: WPPStore,
private readonly sessionName: string,
private readonly userDataDir: string,
private readonly zipper: Zipper,
private readonly logger: Logger,
private readonly getStatus: () => WAHASessionStatus,
private readonly backupIntervalMs: number = INITIAL_DELAY_MS,
) {}
async beforeStart(): Promise<void> {
const sessionExists = await this.store.sessionExists(this.sessionName);
if (!sessionExists) {
this.logger.info('No remote WPP session found, starting fresh');
await this.removePathSilently(this.userDataDir);
return;
}
this.logger.info('Remote WPP session found, restoring...');
const data = await this.store.load(this.sessionName);
if (!data) {
this.logger.warn('Remote WPP session data is empty, starting fresh');
return;
}
const zipPath = this.getZipPath();
await fsp.mkdir(path.dirname(zipPath), { recursive: true });
await fsp.writeFile(zipPath, data);
await this.removePathSilently(this.userDataDir);
await this.zipper.uncompress(zipPath, this.userDataDir);
await this.removePathSilently(zipPath);
await this.removeSingletonFiles(this.userDataDir);
await sleep(2_000);
this.logger.info('Remote WPP session restored successfully');
}
async afterConnected(): Promise<void> {
if (this.backupRunner) {
// Already running from a previous WORKING transition
return;
}
this.backupRunner = new SinglePeriodicJobRunner(
'WPP Auth Backup',
this.backupIntervalMs,
this.logger,
);
this.backupRunner.start(() => this.saveSnapshot());
}
async saveSnapshot(): Promise<void> {
if (this.getStatus() !== WAHASessionStatus.WORKING) {
this.logger.debug('Skipping WPP snapshot - session not WORKING');
return;
}
this.logger.debug('Saving WPP session snapshot...');
const tempDir = this.getTempDir();
const zipPath = this.getZipPath();
try {
await fs.copy(this.userDataDir, tempDir);
// await this.deleteMetadata(tempDir);
await this.zipper.compress(tempDir, zipPath);
const data = await fsp.readFile(zipPath);
await this.store.save(this.sessionName, data);
this.logger.info('WPP session snapshot saved');
} finally {
await this.removePathSilently(tempDir);
await this.removePathSilently(zipPath);
}
}
async stop(): Promise<void> {
this.backupRunner?.stop();
this.backupRunner = null;
}
private async deleteMetadata(tempDir: string): Promise<void> {
const dirsToScan = [tempDir, path.join(tempDir, 'Default')];
for (const dir of dirsToScan) {
const exists = await fs.pathExists(dir);
if (!exists) {
continue;
}
const entries = await fsp.readdir(dir);
for (const entry of entries) {
if (REQUIRED_DIRS.includes(entry)) {
continue;
}
await this.removePathSilently(path.join(dir, entry));
}
}
}
private async removeSingletonFiles(dir: string): Promise<void> {
const exists = await fs.pathExists(dir);
if (!exists) {
return;
}
const files = await fsp.readdir(dir);
for (const file of files) {
if (!file.startsWith('Singleton')) {
continue;
}
await this.removePathSilently(path.join(dir, file));
}
}
private async removePathSilently(targetPath: string): Promise<void> {
try {
await fsp.rm(targetPath, { recursive: true, force: true, maxRetries: 4 });
} catch (err) {
this.logger.error(err, `Failed to remove: ${targetPath}`);
}
}
private getTempDir(): string {
return `/tmp/wpp_temp_${this.sessionName}`;
}
private getZipPath(): string {
return `/tmp/wpp_session_${this.sessionName}.zip`;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
import { Logger } from 'pino';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
import { WPPStore } from './WPPStore';
const BUCKET_NAME = 'auth';
export class WPPMongoStore implements WPPStore {
private readonly db: Db;
private readonly bucket: GridFSBucket;
constructor(
store: MongoStore,
sessionName: string,
private readonly logger: Logger,
) {
this.db = store.getSessionDb(sessionName);
this.bucket = new GridFSBucket(this.db, { bucketName: BUCKET_NAME });
}
async sessionExists(sessionName: string): Promise<boolean> {
const filename = this.getFilename(sessionName);
const count = await this.db
.collection(`${BUCKET_NAME}.files`)
.countDocuments({ filename: filename });
return count > 0;
}
async load(sessionName: string): Promise<Buffer | null> {
const filename = this.getFilename(sessionName);
const files = await this.bucket.find({ filename: filename }).toArray();
if (files.length === 0) {
return null;
}
const chunks: Buffer[] = [];
const downloadStream = this.bucket.openDownloadStreamByName(filename);
for await (const chunk of downloadStream) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
}
async save(sessionName: string, data: Buffer): Promise<void> {
const filename = this.getFilename(sessionName);
const readable = Readable.from(data);
const uploadStream = this.bucket.openUploadStream(filename);
await pipeline(readable, uploadStream);
await this.deletePrevious(filename);
}
async delete(sessionName: string): Promise<void> {
const filename = this.getFilename(sessionName);
const files = await this.bucket.find({ filename: filename }).toArray();
await Promise.all(files.map((f: GridFSFile) => this.bucket.delete(f._id)));
}
private async deletePrevious(filename: string): Promise<void> {
const files = await this.bucket.find({ filename: filename }).toArray();
if (files.length <= 1) {
return;
}
files.sort(
(a: GridFSFile, b: GridFSFile) =>
a.uploadDate.getTime() - b.uploadDate.getTime(),
);
// Keep the last one, delete the rest
const toDelete = files.slice(0, files.length - 1);
await Promise.all(
toDelete.map((f: GridFSFile) => this.bucket.delete(f._id)),
);
}
private getFilename(sessionName: string): string {
return `${sessionName}.zip`;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import Knex from 'knex';
import { Logger } from 'pino';
import { WPPStore } from './WPPStore';
class WPPFileRepository extends PsqlFileRepository {
get tableName() {
return 'files';
}
}
export class WPPPsqlStore implements WPPStore {
private readonly repository: WPPFileRepository;
constructor(
knex: Knex.Knex,
private readonly logger: Logger,
) {
this.repository = new WPPFileRepository(knex, logger);
}
static async build(
store: PsqlStore,
sessionName: string,
logger: Logger,
): Promise<WPPPsqlStore> {
const knex = store.buildSessionKnex(sessionName, 'Session/Auth');
const instance = new WPPPsqlStore(knex, logger);
await instance.init();
return instance;
}
private async init(): Promise<void> {
await this.repository.init();
}
async sessionExists(sessionName: string): Promise<boolean> {
return this.repository.exists(this.getFilename(sessionName));
}
async load(sessionName: string): Promise<Buffer | null> {
const data = await this.repository.fetch(this.getFilename(sessionName));
return data ? data.content : null;
}
async save(sessionName: string, data: Buffer): Promise<void> {
await this.repository.save(this.getFilename(sessionName), data);
}
async delete(sessionName: string): Promise<void> {
await this.repository.delete(this.getFilename(sessionName));
}
private getFilename(sessionName: string): string {
return `${sessionName}.zip`;
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface WPPStore {
sessionExists(sessionName: string): Promise<boolean>;
load(sessionName: string): Promise<Buffer | null>;
save(sessionName: string, data: Buffer): Promise<void>;
delete(sessionName: string): Promise<void>;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { toCusFormat } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat } from '@waha/core/utils/jids';
import { SerializeMsgKey } from '@waha/core/utils/ids';
import { WAMessageReaction } from '@waha/structures/responses.dto';
import { WAHAPresenceStatus } from '@waha/structures/enums.dto';
@@ -109,7 +109,7 @@ export function WppParticipantsToGroupV2Participants(
const group: GroupId = { id: toCusFormat(data.groupId) };
const participants: GroupParticipant[] = data.who.map((id) => ({
id: id,
pn: null,
pn: isPnUser(id) ? id : null,
role: role,
}));
+261 -17
View File
@@ -47,6 +47,7 @@ import {
MessageFileRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -81,7 +82,13 @@ import {
import { WAHAChatPresences } from '@waha/structures/presence.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { TextStatus } from '@waha/structures/status.dto';
import {
BROADCAST_ID,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
import {
PollVotePayload,
@@ -121,10 +128,7 @@ import {
WppSendTextOptions,
WppSendTextStatusOptions,
} from '@waha/core/engines/wpp/WppTypes';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
@@ -161,6 +165,7 @@ import {
GroupParticipantRole,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto';
@@ -168,6 +173,16 @@ import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { evaluateAndReturn } from '@wppconnect-team/wppconnect/dist/api/helpers';
import { getFromToParticipant } from '@waha/core/engines/noweb/session.noweb.core';
import { IsChrome } from '@waha/version';
import { WPPAuthFactory } from '@waha/core/engines/wpp/WPPAuthFactory';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { detectMimetype } from '@waha/utils/files';
import type {
AudioMessageOptions,
DocumentMessageOptions,
FileMessageOptions,
ImageMessageOptions,
VideoMessageOptions,
} from '@wppconnect/wa-js/dist/chat';
declare global {
interface Window {
@@ -194,6 +209,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
protected qr: QR;
protected wpp?: WPPWhatsapp;
protected authManager: IWPPAuthManager | null = null;
private authFactory = new WPPAuthFactory();
private meInfo: MeInfo | null = null;
private pairingCode?: string;
private presencesByChatId = new Map<string, WAHAChatPresences>();
@@ -218,6 +234,13 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
}
async start() {
this.authManager = await this.authFactory.build(
this.sessionStore,
this.name,
this.getUserDataDir(),
this.loggerBuilder,
() => this.status,
);
this.shouldRestart = true;
this.status = WAHASessionStatus.STARTING;
this.pairingCode = null;
@@ -624,19 +647,218 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
return this.toWAMessage(sent);
}
public sendImage(request: MessageImageRequest) {
void request;
throw new AvailableInPlusVersion();
@Activity()
async sendImage(request: MessageImageRequest) {
const quotedMessageId = this.getReplyToMessageId(request as any);
const content = await this.fileToBuffer(request.file);
const mimetype = request.file.mimetype || WAMimeType.IMAGE;
const media = WPPMedia(content, mimetype);
const options: ImageMessageOptions = {
type: 'image',
caption: request.caption,
filename: request.file.filename,
mimetype: mimetype,
quotedMsg: quotedMessageId,
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
waitForAck: false,
};
return await this.sendMedia(
this.ensureSuffix(request.chatId),
media,
options,
);
}
public sendFile(request: MessageFileRequest) {
void request;
throw new AvailableInPlusVersion();
@Activity()
async sendFile(request: MessageFileRequest) {
const quotedMessageId = this.getReplyToMessageId(request as any);
const content = await this.fileToBuffer(request.file);
const mimetype = request.file.mimetype || (await detectMimetype(content));
const media = WPPMedia(content, mimetype);
const options: DocumentMessageOptions = {
type: 'document',
caption: request.caption,
filename: request.file.filename,
mimetype: mimetype,
quotedMsg: quotedMessageId,
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
waitForAck: false,
};
return await this.sendMedia(
this.ensureSuffix(request.chatId),
media,
options,
);
}
public sendVoice(request: MessageVoiceRequest) {
void request;
throw new AvailableInPlusVersion();
@Activity()
async sendVoice(request: MessageVoiceRequest) {
const quotedMessageId = this.getReplyToMessageId(request as any);
let content = await this.fileToBuffer(request.file);
let mimetype = request.file.mimetype || WAMimeType.VOICE;
if (request.convert) {
content = await this.convertVoice(content);
mimetype = WAMimeType.VOICE;
}
const media = WPPMedia(content, mimetype);
const options: AudioMessageOptions = {
type: 'audio',
isPtt: true,
mimetype: mimetype,
quotedMsg: quotedMessageId,
waitForAck: false,
};
return await this.sendMedia(
this.ensureSuffix(request.chatId),
media,
options,
);
}
@Activity()
async sendVideo(request: MessageVideoRequest) {
const quotedMessageId = this.getReplyToMessageId(request as any);
let content = await this.fileToBuffer(request.file);
let mimetype = request.file.mimetype || WAMimeType.VIDEO;
if (request.convert) {
content = await this.convertVideo(content);
mimetype = WAMimeType.VIDEO;
}
const media = WPPMedia(content, mimetype);
const options: VideoMessageOptions = {
type: 'video',
isPtv: request.asNote,
caption: request.caption,
filename: request.file.filename,
mimetype: mimetype,
quotedMsg: quotedMessageId,
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
waitForAck: false,
};
return await this.sendMedia(
this.ensureSuffix(request.chatId),
media,
options,
);
}
@Activity()
async sendImageStatus(status: ImageStatus) {
this.checkStatusRequest(status);
const content = await this.fileToBuffer(status.file);
const mimetype = status.file.mimetype || WAMimeType.IMAGE;
const media = WPPMedia(content, mimetype);
const options: any = {
caption: status.caption,
waitForAck: false,
};
if (status.id) {
options.messageId = status.id;
}
const sent = (await this.wpp.sendImageStatus(media, options)) as any;
return await this.toStatusResponse(sent, status.id);
}
@Activity()
async sendVoiceStatus(status: VoiceStatus) {
this.checkStatusRequest(status);
let content = await this.fileToBuffer(status.file);
let mimetype = status.file.mimetype || WAMimeType.VOICE;
if (status.convert) {
content = await this.convertVoice(content);
mimetype = WAMimeType.VOICE;
}
const media = WPPMedia(content, mimetype);
const options: AudioMessageOptions = {
type: 'audio',
isPtt: true,
mimetype: mimetype,
waitForAck: true,
};
return await this.sendMedia(BROADCAST_ID, media, options);
}
@Activity()
async sendVideoStatus(status: VideoStatus) {
this.checkStatusRequest(status);
let content = await this.fileToBuffer(status.file);
let mimetype = status.file.mimetype || WAMimeType.VIDEO;
if (status.convert) {
content = await this.convertVideo(content);
mimetype = WAMimeType.VIDEO;
}
const media = WPPMedia(content, mimetype);
const options: any = {
caption: status.caption,
waitForAck: false,
};
if (status.id) {
options.messageId = status.id;
}
const sent = (await this.wpp.sendVideoStatus(media, options)) as any;
return await this.toStatusResponse(sent, status.id);
}
private async sendMedia(
chatId: string,
media: string,
options: FileMessageOptions,
) {
const sent = await this.wpp.sendFile(chatId, media, options);
const sentId = sent?.id || null;
if (!sentId) {
return {
id: null,
_data: sent,
};
}
this.saveSentMessageId(this.extractMessageIdPart(sentId));
const sentMessage = await this.wpp.getMessageById(sentId).catch(() => null);
if (!sentMessage) {
return {
id: sentId,
_data: sent,
};
}
return this.toWAMessage(sentMessage);
}
private async convertVideo(content: Buffer): Promise<Buffer> {
return await this.mediaConverter.video(content);
}
private async convertVoice(content: Buffer): Promise<Buffer> {
return await this.mediaConverter.voice(content);
}
private extractMessageIdPart(messageId: string): string {
if (!messageId) {
return null;
}
const parts = messageId.split('_');
if (parts.length >= 3) {
return parts[2];
}
return messageId;
}
private async toStatusResponse(sent: any, fallbackId?: string) {
const sentId = sent?.id || fallbackId || null;
if (!sentId) {
return {
id: null,
_data: sent,
};
}
this.saveSentMessageId(this.extractMessageIdPart(sentId));
const sentMessage = await this.wpp.getMessageById(sentId).catch(() => null);
if (!sentMessage) {
return {
id: sentId,
_data: sent,
};
}
return this.toWAMessage(sentMessage);
}
@Activity()
@@ -1191,6 +1413,26 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.wpp!.getChatById(this.ensureSuffix(id));
// Undocumented property - 'all_member_add' or 'admin_add'
const memberAddMode = group?.groupMetadata?.memberAddMode;
return {
membersCanAddNewMember: memberAddMode === 'all_member_add',
};
}
@Activity()
public setMemberAddMode(id: string, value: boolean): Promise<boolean> {
// Not in the GroupProperty enum, but wa-js passes the property through
// to WhatsApp's setGroupProperty - 1 - all members, 0 - admins only
return this.wpp!.setGroupProperty(
this.ensureSuffix(id),
'member_add_mode' as GroupProperty,
value,
);
}
@Activity()
public deleteGroup(id: string): Promise<boolean> {
return this.wpp!.deleteChat(this.ensureSuffix(id));
@@ -1271,14 +1513,15 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
}
@Activity()
public promoteParticipantsToAdmin(
public async promoteParticipantsToAdmin(
id: string,
request: ParticipantsRequest,
): Promise<boolean> {
const participants = request.participants.map((participant) =>
this.ensureSuffix(participant.id),
);
return this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
await this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
return true;
}
@Activity()
@@ -1653,7 +1896,8 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
subject: group?.name || '',
description: group?.groupMetadata?.desc || '',
invite: null,
membersCanAddNewMember: !group?.groupMetadata?.restrict,
membersCanAddNewMember:
group?.groupMetadata?.memberAddMode === 'all_member_add',
membersCanSendMessages: !group?.groupMetadata?.announce,
newMembersApprovalRequired:
!!group?.groupMetadata?.membershipApprovalMode,
@@ -0,0 +1,35 @@
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import checkDiskSpace from 'check-disk-space';
export interface CheckFreeDiskSpaceOptions {
path: string;
thresholdBytes: number;
}
export class CheckFreeDiskSpaceIndicator extends HealthIndicator {
public async check(
key: string,
options: CheckFreeDiskSpaceOptions,
): Promise<HealthIndicatorResult> {
const diskSpace = await checkDiskSpace(options.path);
if (diskSpace.free < options.thresholdBytes) {
const result = super.getStatus(key, false, {
path: options.path,
diskPath: diskSpace.diskPath,
free: diskSpace.free,
threshold: options.thresholdBytes,
});
throw new HealthCheckError('Disk space is low', result);
}
return super.getStatus(key, true, {
path: options.path,
diskPath: diskSpace.diskPath,
free: diskSpace.free,
threshold: options.thresholdBytes,
});
}
}
@@ -0,0 +1,36 @@
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { promiseTimeout } from '../../utils/promiseTimeout';
import { MongoStore } from '../storage/mongo/MongoStore';
export interface MongoStoreHealthIndicatorOptions {
timeout: number;
}
export class MongoStoreHealthIndicator extends HealthIndicator {
constructor() {
super();
}
private async ping(mongoStore: MongoStore) {
await mongoStore.command({ ismaster: 1 });
}
public async check(
key: string,
mongo: MongoStore,
options: MongoStoreHealthIndicatorOptions,
): Promise<HealthIndicatorResult> {
try {
await promiseTimeout(options.timeout, this.ping(mongo));
return super.getStatus(key, true, { message: 'Up and running' });
} catch (e) {
const result = super.getStatus(key, false, { error: 'Timeout' });
throw new HealthCheckError('Timeout happened', result);
}
}
}
+53 -3
View File
@@ -1,12 +1,62 @@
import { Injectable } from '@nestjs/common';
import { HealthCheckResult } from '@nestjs/terminus';
import { HealthCheckResult, HealthCheckService } from '@nestjs/terminus';
import type { HealthIndicatorFunction } from '@nestjs/terminus/dist/health-indicator';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import * as path from 'path';
import { WhatsappConfigService } from '../../config.service';
import { SessionManager } from '../abc/manager.abc';
import { WAHAHealthCheckService } from '../abc/WAHAHealthCheckService';
import { AvailableInPlusVersion } from '../exceptions';
import { LocalStore } from '../storage/LocalStore';
import { MongoStore } from '../storage/mongo/MongoStore';
import { CheckFreeDiskSpaceIndicator } from './CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from './MongoStoreHealthIndicator';
const MB = 1024 * 1024;
@Injectable()
export class WAHAHealthCheckServiceCore extends WAHAHealthCheckService {
constructor(
protected sessionManager: SessionManager,
protected health: HealthCheckService,
protected config: WhatsappConfigService,
protected mongoStoreHealthIndicator: MongoStoreHealthIndicator,
protected checkFreeDiskSpaceIndicator: CheckFreeDiskSpaceIndicator,
protected mediaLocalStorageConfig: MediaLocalStorageConfig,
) {
super(sessionManager, health, config);
}
check(): Promise<HealthCheckResult> {
throw new AvailableInPlusVersion();
const indicators = this.getIndicators();
return this.health.check(indicators);
}
getIndicators(): HealthIndicatorFunction[] {
const indicators = [
() =>
this.checkFreeDiskSpaceIndicator.check('mediaFiles.space', {
path: path.resolve(this.mediaLocalStorageConfig.filesFolder),
thresholdBytes: this.config.getHealthMediaFilesThreshold() * MB,
}),
];
if (this.sessionManager.store instanceof LocalStore) {
indicators.push(() =>
this.checkFreeDiskSpaceIndicator.check('sessionsFiles.space', {
path: path.resolve(this.sessionManager.store.getBaseDirectory()),
thresholdBytes: this.config.getHealthSessionFilesThreshold() * MB,
}),
);
}
if (this.sessionManager.store instanceof MongoStore) {
indicators.push(() =>
this.mongoStoreHealthIndicator.check(
'mongodb',
this.sessionManager.store,
{ timeout: this.config.getHealthMongoTimeout() },
),
);
}
return indicators;
}
}
+400 -199
View File
@@ -2,8 +2,8 @@ import {
Inject,
Injectable,
NotFoundException,
OnApplicationBootstrap,
OnModuleInit,
UnprocessableEntityException,
} from '@nestjs/common';
import {
AppsService,
@@ -11,21 +11,50 @@ import {
} from '@waha/apps/app_sdk/services/IAppsService';
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { WhatsappSessionGoWSCore } from '@waha/core/engines/gows/session.gows.core';
import { WhatsappSessionNoWebCore } from '@waha/core/engines/noweb/session.noweb.core';
import { WhatsappSessionWPPCore } from '@waha/core/engines/wpp/session.wpp.core';
import { WhatsappSessionWebJSCore } from '@waha/core/engines/webjs/session.webjs.core';
import { getProxyConfig } from '@waha/core/helpers.proxy';
import { WebhookConductor } from '@waha/core/integrations/webhooks/WebhookConductor';
import { MediaManager } from '@waha/core/media/MediaManager';
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
import { LocalSessionAuthRepository } from '@waha/core/storage/LocalSessionAuthRepository';
import { LocalSessionConfigRepository } from '@waha/core/storage/LocalSessionConfigRepository';
import { LocalStoreCore } from '@waha/core/storage/LocalStoreCore';
import { MongoApiKeyRepository } from '@waha/core/storage/mongo/MongoApiKeyRepository';
import { MongoSessionAuthRepository } from '@waha/core/storage/mongo/MongoSessionAuthRepository';
import { MongoSessionConfigRepository } from '@waha/core/storage/mongo/MongoSessionConfigRepository';
import { MongoSessionMeRepository } from '@waha/core/storage/mongo/MongoSessionMeRepository';
import { MongoSessionWorkerRepository } from '@waha/core/storage/mongo/MongoSessionWorkerRepository';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { parsePsql } from '@waha/core/storage/psql/PsqlConnectionConfig';
import { PsqlApiKeyRepository } from '@waha/core/storage/psql/PsqlApiKeyRepository';
import { PsqlSessionAuthRepository } from '@waha/core/storage/psql/PsqlSessionAuthRepository';
import { PsqlSessionConfigRepository } from '@waha/core/storage/psql/PsqlSessionConfigRepository';
import { PsqlSessionMeRepository } from '@waha/core/storage/psql/PsqlSessionMeRepository';
import { PsqlSessionWorkerRepository } from '@waha/core/storage/psql/PsqlSessionWorkerRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { Sqlite3ApiKeyRepository } from '@waha/core/storage/sqlite3/Sqlite3ApiKeyRepository';
import { Sqlite3SessionMeRepository } from '@waha/core/storage/sqlite3/Sqlite3SessionMeRepository';
import { Sqlite3SessionWorkerRepository } from '@waha/core/storage/sqlite3/Sqlite3SessionWorkerRepository';
import { WAHAWebhookSessionStatus } from '@waha/structures/webhooks.dto';
import { DefaultMap } from '@waha/utils/DefaultMap';
import { getPinoLogLevel, LoggerBuilder } from '@waha/utils/logging';
import { promiseTimeout, sleep } from '@waha/utils/promiseTimeout';
import { complete } from '@waha/utils/reactive/complete';
import { SwitchObservable } from '@waha/utils/reactive/SwitchObservable';
import { getNamespace, getSessionNamespace } from '@waha/config';
import { getEngineName, VERSION } from '@waha/version';
import * as lodash from 'lodash';
import { MongoClient } from 'mongodb';
import { PinoLogger } from 'nestjs-pino';
import { Observable, retry, share } from 'rxjs';
import { merge, Observable, retry, share } from 'rxjs';
import { map } from 'rxjs/operators';
import { getNamespace, getSessionNamespace } from '../config';
import { WhatsappConfigService } from '../config.service';
import {
WAHAEngine,
@@ -43,51 +72,32 @@ import { WebhookConfig } from '../structures/webhooks.config.dto';
import { populateSessionInfo, SessionManager } from './abc/manager.abc';
import { SessionParams, WhatsappSession } from './abc/session.abc';
import { EngineConfigService } from './config/EngineConfigService';
import { WhatsappSessionNoWebCore } from './engines/noweb/session.noweb.core';
import { WhatsappSessionWPPCore } from './engines/wpp/session.wpp.core';
import { WhatsappSessionWebJSCore } from './engines/webjs/session.webjs.core';
import { DOCS_URL } from './exceptions';
import { getProxyConfig } from './helpers.proxy';
import { MediaManager } from './media/MediaManager';
import { LocalSessionAuthRepository } from './storage/LocalSessionAuthRepository';
import { LocalStoreCore } from './storage/LocalStoreCore';
import { CoreApiKeyRepository } from './storage/CoreApiKeyRepository';
export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException {
constructor(name: string) {
const encoded = Buffer.from(name, 'utf-8').toString('base64');
super(
`WAHA Core support only 'default' session. You tried to access '${name}' session (base64: ${encoded}). ` +
`If you want to run more then one WhatsApp account - please get WAHA PLUS version. Check this out: ${DOCS_URL}`,
);
}
}
enum DefaultSessionStatus {
REMOVED = undefined,
STOPPED = null,
}
const ALL = '*';
@Injectable()
export class SessionManagerCore extends SessionManager implements OnModuleInit {
SESSION_STOP_TIMEOUT = 3000;
// session - exists and running (or failed or smth)
// null - stopped
// undefined - removed
private session: WhatsappSession | DefaultSessionStatus;
private sessionConfig?: SessionConfig;
DEFAULT = 'default';
export class SessionManagerCore
extends SessionManager
implements OnModuleInit, OnApplicationBootstrap
{
private SESSION_STOP_TIMEOUT = 3000;
SESSION_UNPAIR_TIMEOUT = 1000;
private readonly sessions: Record<string, WhatsappSession>;
protected readonly EngineClass: typeof WhatsappSession;
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
protected readonly engineBootstrap: EngineBootstrap;
protected events2: DefaultMap<
string,
DefaultMap<WAHAEvents, SwitchObservable<any>>
>;
constructor(
config: WhatsappConfigService,
private engineConfigService: EngineConfigService,
private webjsEngineConfigService: WebJSEngineConfigService,
private wppEngineConfigService: WPPEngineConfigService,
private nowebEngineConfigService: NowebEngineConfigService,
gowsConfigService: GowsEngineConfigService,
log: PinoLogger,
private mediaStorageFactory: MediaStorageFactory,
@@ -95,58 +105,188 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
appsService: IAppsService,
) {
super(log, config, gowsConfigService, appsService);
this.session = DefaultSessionStatus.STOPPED;
this.sessionConfig = null;
this.sessions = {};
const engineName = this.engineConfigService.getDefaultEngineName();
this.EngineClass = this.getEngine(engineName);
this.engineBootstrap = this.getEngineBootstrap(engineName);
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
return obs$.pipe(retry(), share());
}),
this.events2 = new DefaultMap(
(session: string) =>
new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
return obs$.pipe(retry(), share());
}),
),
);
}
this.store = new LocalStoreCore(getNamespace(), getSessionNamespace());
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
this.clearStorage().catch((error) => {
this.log.error({ error }, 'Error while clearing storage');
});
async onModuleInit() {
await this.init();
}
async onApplicationBootstrap() {
await this.engineBootstrap.bootstrap();
await this.restartSessions();
}
async init() {
const mongoUrl = this.config.getSessionMongoUrl();
const postgresUrl = this.config.getSessionPostgresUrl();
if (mongoUrl) {
this.log.info('Using mongo storage for session info.');
const mongo = new MongoClient(mongoUrl);
this.log.info(`Connecting to mongo '${mongoUrl}'...`);
await mongo.connect();
this.log.info(`Connected to mongo '${mongoUrl}'!`);
this.store = new MongoStore(mongo, getNamespace(), getSessionNamespace());
await this.store.init();
this.sessionAuthRepository = new MongoSessionAuthRepository(this.store);
this.sessionConfigRepository = new MongoSessionConfigRepository(
this.store,
);
this.sessionMeRepository = new MongoSessionMeRepository(this.store);
this.sessionWorkerRepository = new MongoSessionWorkerRepository(
this.store,
);
this.apiKeyRepository = new MongoApiKeyRepository(this.store);
} else if (postgresUrl) {
this.log.info('Using Postgres storage for session info.');
const config = parsePsql(postgresUrl);
const engine = getEngineName();
config.application_name = `WAHA(${engine}) ${VERSION.version} - Manager`;
this.store = new PsqlStore(config, getNamespace(), getSessionNamespace());
await this.store.init();
this.sessionAuthRepository = new PsqlSessionAuthRepository(this.store);
this.sessionConfigRepository = new PsqlSessionConfigRepository(
this.store,
);
this.sessionMeRepository = new PsqlSessionMeRepository(this.store);
this.sessionWorkerRepository = new PsqlSessionWorkerRepository(
this.store,
);
this.apiKeyRepository = new PsqlApiKeyRepository(this.store);
const knex = this.store.getWAHADatabase();
await this.appsService.migrate(knex);
} else {
this.log.info('Using local storage for session info.');
this.store = new LocalStoreCore(getNamespace(), getSessionNamespace());
await this.store.init();
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
this.sessionConfigRepository = new LocalSessionConfigRepository(
this.store,
);
this.sessionMeRepository = new Sqlite3SessionMeRepository(this.store);
this.sessionWorkerRepository = new Sqlite3SessionWorkerRepository(
this.store,
);
this.apiKeyRepository = new Sqlite3ApiKeyRepository(this.store);
const knex = this.store.getWAHADatabase();
await this.appsService.migrate(knex);
}
await this.sessionConfigRepository.init();
await this.sessionMeRepository.init();
await this.sessionWorkerRepository.init();
await this.apiKeyRepository.init();
this.listenEvents();
await this.clearStorage();
}
async restartSessions() {
let restartSessions: string[];
if (this.config.shouldRestartAllSessions) {
this.log.info(`Restarting ALL STOPPED sessions...`);
restartSessions = await this.sessionConfigRepository.getAllConfigs();
} else if (this.config.shouldRestartWorkerSessions) {
this.log.info(`Starting sessions for the worker "${this.workerId}"...`);
restartSessions = await this.sessionWorkerRepository.getSessionsByWorker(
this.workerId,
);
}
if (restartSessions != null) {
this.restartStoppedSessions(restartSessions).catch((error) => {
this.log.error(`Error while restarting STOPPED sessions: ${error}`);
this.log.error(error.stack);
});
} else {
this.log.info(`No sessions to restart.`);
}
this.startPredefinedSessions();
}
private listenEvents() {
this.events2
.get(ALL)
.get(WAHAEvents.SESSION_STATUS)
.subscribe(async (data: WAHAWebhookSessionStatus) => {
if (data.me) {
await this.sessionMeRepository.upsertMe(data.session, data.me);
}
});
}
protected async restartStoppedSessions(sessions: string[]) {
// Wait until HTTP/WS server is ready
await sleep(1000);
const sleepS = this.config.autoStartDelaySeconds;
this.log.info(`Restarting sessions with delay of ${sleepS} seconds...`);
const sleepMs = this.config.autoStartDelaySeconds * 1000;
for (const sessionName of sessions) {
const log = this.log.logger.child({ session: sessionName });
await this.withLock(sessionName, async () => {
log.info(`Restarting STOPPED session...`);
await this.start(sessionName).catch((error) => {
log.error(`Failed to start STOPPED session: ${error}`);
log.error(error.stack);
});
})
// withLock() itself can reject (e.g. "Maximum execution time is exceeded"),
// catch it so one stuck session doesn't abort restarting the rest
.catch((error) => {
log.error(`Failed to restart STOPPED session: ${error}`);
log.error(error.stack);
});
await sleep(sleepMs);
}
this.log.info(`STOPPED sessions have been restarted.`);
}
protected getEngine(engine: WAHAEngine): typeof WhatsappSession {
if (engine === WAHAEngine.WEBJS) {
this.SESSION_STOP_TIMEOUT = 3_000;
return WhatsappSessionWebJSCore;
} else if (engine === WAHAEngine.WPP) {
this.SESSION_STOP_TIMEOUT = 3_000;
return WhatsappSessionWPPCore;
} else if (engine === WAHAEngine.NOWEB) {
this.SESSION_STOP_TIMEOUT = 1_000;
return WhatsappSessionNoWebCore;
} else if (engine === WAHAEngine.GOWS) {
this.SESSION_STOP_TIMEOUT = 10;
return WhatsappSessionGoWSCore;
} else {
throw new NotFoundException(`Unknown whatsapp engine '${engine}'.`);
}
}
private onlyDefault(name: string) {
if (name !== this.DEFAULT) {
throw new OnlyDefaultSessionIsAllowed(name);
throw new Error(`Unknown whatsapp engine '${engine}'.`);
}
}
async beforeApplicationShutdown(signal?: string) {
if (this.session) {
await this.stop(this.DEFAULT, true);
}
this.stopEvents();
await this.engineBootstrap.shutdown();
}
this.log.info('Stopping all sessions...');
const promises = Object.keys(this.sessions).map(async (sessionName) => {
await this.withLock(sessionName, async () => {
await this.stop(sessionName, true);
});
});
await Promise.all(promises);
this.log.info('All sessions have been stopped.');
async onApplicationBootstrap() {
this.apiKeyRepository = new CoreApiKeyRepository();
await this.engineBootstrap.bootstrap();
this.startPredefinedSessions();
this.stopEvents();
await this.store?.close();
await this.engineBootstrap.shutdown();
}
private async clearStorage() {
@@ -161,30 +301,41 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
// API Methods
//
async exists(name: string): Promise<boolean> {
this.onlyDefault(name);
return this.session !== DefaultSessionStatus.REMOVED;
return await this.sessionConfigRepository.exists(name);
}
isRunning(name: string): boolean {
this.onlyDefault(name);
return !!this.session;
return !!this.sessions[name];
}
async upsert(name: string, config?: SessionConfig): Promise<void> {
this.onlyDefault(name);
this.sessionConfig = config;
this.log.info({ session: name }, `Saving session...`);
await this.sessionAuthRepository.init(name);
await this.sessionConfigRepository.saveConfig(name, config || {});
this.log.info({ session: name }, `Session saved.`);
}
async delete(name: string): Promise<void> {
this.log.info({ session: name }, `Deleting session...`);
await this.appsService.removeBySession(this, name);
await this.sessionConfigRepository.deleteConfig(name);
await this.sessionAuthRepository.clean(name);
await this.sessionMeRepository.removeMe(name);
await this.sessionWorkerRepository.remove(name);
this.log.info({ session: name }, `Session deleted.`);
}
async start(name: string): Promise<SessionDTO> {
this.onlyDefault(name);
if (this.session) {
throw new UnprocessableEntityException(
`Session '${this.DEFAULT}' is already started.`,
);
}
this.log.info({ session: name }, `Starting session...`);
if (this.isRunning(name)) {
this.log.info({ session: name }, `Session is already running.`);
return;
}
const logger = this.log.logger.child({ session: name });
logger.level = getPinoLogLevel(this.sessionConfig?.debug);
const config = await this.sessionConfigRepository.getConfig(name);
await this.sessionAuthRepository.init(name);
logger.level = getPinoLogLevel(config?.debug);
const loggerBuilder: LoggerBuilder = logger;
const storage = await this.mediaStorageFactory.build(
@@ -197,9 +348,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
this.config.mimetypes,
loggerBuilder.child({ name: 'MediaManager' }),
);
const webhook = new WebhookConductor(loggerBuilder);
const proxyConfig = this.getProxyConfig();
const proxyConfig = this.getProxyConfig(name, config);
const sessionConfig: SessionParams = {
name,
mediaManager,
@@ -207,8 +357,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
printQR: this.engineConfigService.shouldPrintQR,
sessionStore: this.store,
proxyConfig: proxyConfig,
sessionConfig: this.sessionConfig,
ignore: this.ignoreChatsConfig(this.sessionConfig),
sessionConfig: config,
ignore: this.ignoreChatsConfig(config),
};
if (this.EngineClass === WhatsappSessionWebJSCore) {
sessionConfig.engineConfig = this.webjsEngineConfigService.getConfig();
@@ -216,15 +366,17 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
sessionConfig.engineConfig = this.wppEngineConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionNoWebCore) {
sessionConfig.engineConfig =
await this.nowebEngineConfigService.getConfig();
}
await this.sessionAuthRepository.init(name);
// @ts-ignore
const session = new this.EngineClass(sessionConfig);
this.session = session;
this.updateSession();
this.sessions[name] = session;
this.updateSessions();
// configure webhooks
const webhooks = this.getWebhooks();
const webhooks = this.getWebhooks(config);
webhook.configure(session, webhooks);
// Apps
@@ -243,9 +395,6 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
await this.appsService.afterSessionStart(session, this.store);
}
// Apps
await this.appsService.afterSessionStart(session, this.store);
return {
name: session.name,
status: session.status,
@@ -253,26 +402,35 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
};
}
private updateSession() {
if (!this.session) {
return;
}
const session: WhatsappSession = this.session as WhatsappSession;
private updateSessions() {
const sessions = Object.values(this.sessions);
for (const eventName in WAHAEvents) {
const event = WAHAEvents[eventName];
const stream$ = session
.getEventObservable(event)
.pipe(map(populateSessionInfo(event, session)));
this.events2.get(event).switch(stream$);
const streams = [];
for (const session of sessions) {
const stream$ = session
.getEventObservable(event)
.pipe(map(populateSessionInfo(event, session)), share());
this.events2.get(session.name).get(event).switch(stream$);
streams.push(stream$);
}
this.events2
.get(ALL)
.get(event)
.switch(merge(...streams));
}
}
getSessionEvent(session: string, event: WAHAEvents): Observable<any> {
return this.events2.get(event);
return this.events2.get(session).get(event);
}
/**
* Stop session
* @param name
* @param silent - if true, throw error if session is not stopped successfully
*/
async stop(name: string, silent: boolean): Promise<void> {
this.onlyDefault(name);
if (!this.isRunning(name)) {
this.log.debug({ session: name }, `Session is not running.`);
return;
@@ -283,50 +441,43 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
const session = this.getSession(name);
await session.stop();
} catch (err) {
this.log.warn(`Error while stopping session '${name}'`);
this.log.warn({ session: name }, `Error while stopping session`);
if (!silent) {
throw err;
}
}
this.log.info({ session: name }, `Session has been stopped.`);
this.session = DefaultSessionStatus.STOPPED;
this.updateSession();
delete this.sessions[name];
this.updateSessions();
await sleep(this.SESSION_STOP_TIMEOUT);
}
async unpair(name: string) {
if (!this.session) {
const session = this.sessions[name];
if (!session) {
return;
}
const session = this.session as WhatsappSession;
this.log.info({ session: name }, 'Unpairing the device from account...');
await session.unpair().catch((err) => {
this.log.warn(`Error while unpairing from device: ${err}`);
});
await sleep(1000);
await sleep(this.SESSION_UNPAIR_TIMEOUT);
}
async logout(name: string): Promise<void> {
this.onlyDefault(name);
this.log.info({ session: name }, `Logging out session...`);
await this.sessionAuthRepository.clean(name);
}
async delete(name: string): Promise<void> {
this.onlyDefault(name);
await this.appsService.removeBySession(this, name);
this.session = DefaultSessionStatus.REMOVED;
this.updateSession();
this.sessionConfig = undefined;
await this.sessionMeRepository.removeMe(name);
this.log.info({ session: name }, `Session has been logged out.`);
}
/**
* Combine per session and global webhooks
*/
private getWebhooks() {
private getWebhooks(config: SessionConfig) {
let webhooks: WebhookConfig[] = [];
if (this.sessionConfig?.webhooks) {
webhooks = webhooks.concat(this.sessionConfig.webhooks);
if (config?.webhooks) {
webhooks = webhooks.concat(config.webhooks);
}
const globalWebhookConfig = this.config.getWebhookConfig();
if (globalWebhookConfig) {
@@ -338,113 +489,163 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
/**
* Get either session's or global proxy if defined
*/
protected getProxyConfig(): ProxyConfig | undefined {
if (this.sessionConfig?.proxy) {
return this.sessionConfig.proxy;
protected getProxyConfig(
name: string,
config?: SessionConfig,
): ProxyConfig | undefined {
if (config?.proxy) {
return config.proxy;
}
if (!this.session) {
return undefined;
}
const sessions = { [this.DEFAULT]: this.session as WhatsappSession };
return getProxyConfig(this.config, sessions, this.DEFAULT);
return getProxyConfig(this.config, this.sessions, name);
}
getSession(name: string): WhatsappSession {
this.onlyDefault(name);
const session = this.session;
const session = this.sessions[name];
if (!session) {
throw new NotFoundException(
`We didn't find a session with name '${name}'.\n` +
`Please start it first by using POST /api/sessions/${name}/start request`,
);
}
return session as WhatsappSession;
return session;
}
/**
* Get all runtime sessions
*/
private getRuntimeSessions(name: string = null): SessionInfo[] {
let names = Object.keys(this.sessions);
if (name) {
names = names.filter((n) => n === name);
}
const sessions = names.map((sessionName) => {
const status = this.sessions[sessionName].status;
const sessionConfig = this.sessions[sessionName].sessionConfig;
const me = this.sessions[sessionName].getSessionMeInfo();
return {
name: sessionName,
status: status,
config: sessionConfig,
me: me,
presence: this.sessions[sessionName].presence,
timestamps: {
activity: this.sessions[sessionName].getLastActivityTimestamp(),
},
};
});
return sessions;
}
/**
* Get all sessions
* Even tho it's "offline", it usually contains both offline and online sessions
**/
private async getOfflineSessions(
name: string = null,
): Promise<SessionInfo[]> {
let names = await this.sessionConfigRepository.getAllConfigs();
if (name) {
names = names.filter((n) => n === name);
}
const configBySession =
await this.sessionConfigRepository.getConfigBySessions(names);
const meBySession = await this.sessionMeRepository.getMeBySessions(names);
const sessions = names.map((sessionName) => {
const status = WAHASessionStatus.STOPPED;
return {
name: sessionName,
status: status,
config: configBySession.get(sessionName) ?? null,
me: meBySession.get(sessionName) ?? null,
presence: null,
timestamps: {
activity: null,
},
};
});
return sessions;
}
async getSessions(all: boolean): Promise<SessionInfo[]> {
if (this.session === DefaultSessionStatus.STOPPED && all) {
return [
{
name: this.DEFAULT,
status: WAHASessionStatus.STOPPED,
config: this.sessionConfig,
me: null,
presence: null,
timestamps: {
activity: null,
},
},
];
}
if (this.session === DefaultSessionStatus.REMOVED && all) {
return [];
}
if (!this.session && !all) {
return [];
const runtimeSessions = this.getRuntimeSessions();
let offlineSessions: SessionInfo[] = [];
if (all) {
offlineSessions = await this.getOfflineSessions();
}
// Merge runtime and offline by name
// Runtime one will overwrite offline one
const sessions = lodash.keyBy(
[...offlineSessions, ...runtimeSessions],
'name',
);
const session = this.session as WhatsappSession;
const me = session?.getSessionMeInfo();
return [
{
name: session.name,
status: session.status,
config: session.sessionConfig,
me: me,
presence: session.presence,
timestamps: {
activity: session?.getLastActivityTimestamp(),
},
},
];
}
// Get assigned worker
const workersInfo = await this.sessionWorkerRepository.getAll();
const workerBySession = lodash.keyBy(workersInfo, 'id');
Object.keys(sessions).forEach((sessionName) => {
sessions[sessionName].assignedWorker =
workerBySession[sessionName]?.worker;
});
private async fetchEngineInfo() {
const session = this.session as WhatsappSession;
// Get engine info
let engineInfo = {};
if (session) {
try {
engineInfo = await promiseTimeout(1000, session.getEngineInfo());
} catch (error) {
this.log.debug(
{ session: session.name, error: `${error}` },
'Can not get engine info',
);
}
}
const engine = {
engine: session?.engine,
...engineInfo,
};
return engine;
return Object.values(sessions);
}
async getSessionInfo(name: string): Promise<SessionDetailedInfo | null> {
this.onlyDefault(name);
const sessions = await this.getSessions(true);
if (sessions.length === 0) {
let session: SessionDetailedInfo = null;
// Try to find session in runtime sessions
const runtimeSessions = this.getRuntimeSessions(name);
if (runtimeSessions.length === 1) {
session = runtimeSessions[0];
}
// If session is not found in runtime sessions,
// try to find it in offline sessions
if (!session) {
const offlineSessions = await this.getOfflineSessions(name);
if (offlineSessions.length === 1) {
session = offlineSessions[0];
}
}
// No session found
if (!session) {
return null;
}
const session = sessions[0];
const engine = await this.fetchEngineInfo();
// If session is found, get engine info
const engine = await this.fetchEngineInfo(name);
return {
...session,
engine: engine,
};
}
private async fetchEngineInfo(sessionName: string) {
// Get engine info
if (!this.sessions[sessionName]) {
return {};
}
const session = this.sessions[sessionName];
let engineInfo = {};
try {
engineInfo = await promiseTimeout(3_000, session.getEngineInfo());
} catch (error) {
this.log.debug(
{ session: session.name, error: `${error}` },
'Can not get engine info',
);
}
return {
engine: this.sessions[sessionName]?.engine,
...engineInfo,
};
}
protected stopEvents() {
complete(this.events2);
}
async onModuleInit() {
await this.init();
}
async init() {
await this.store.init();
const knex = this.store.getWAHADatabase();
await this.appsService.migrate(knex);
for (const events of this.events2.values()) {
complete(events);
}
}
}
+1
View File
@@ -14,6 +14,7 @@ interface Message {
interface File {
extension: string;
filename?: string;
mimetype?: string;
}
export interface MediaData {
+9 -1
View File
@@ -75,6 +75,7 @@ export class MediaManager implements IMediaManager {
file: {
extension: extension,
filename: filename,
mimetype: mimetype,
},
};
@@ -187,7 +188,14 @@ export class MediaManager implements IMediaManager {
const retryOptions = this.RETRY_OPTIONS;
try {
return await promiseRetry((retry: CallableFunction, number: number) => {
return fn().catch(retry);
return fn().catch((err: any) => {
// Some failures are definitive (e.g. media not downloadable): retrying
// won't help and may block, so abort the retry loop immediately.
if (err?.nonRetriable) {
throw err;
}
return retry(err);
});
}, retryOptions);
} catch (error) {
this.log.error(
+1 -1
View File
@@ -108,7 +108,7 @@ export class MediaLocalStorage implements IMediaStorage {
this.log.debug(`File ${filepath} was removed`);
}),
delay,
);
).unref();
}
async close() {
+69
View File
@@ -0,0 +1,69 @@
import {
getMetadata,
IMediaStorage,
MediaData,
MediaStorageData,
} from '@waha/core/media/IMediaStorage';
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import Knex from 'knex';
import { Logger } from 'pino';
class MediaFileRepository extends PsqlFileRepository {
get tableName() {
return 'media';
}
}
export class MediaPsqlStorage implements IMediaStorage {
repository: MediaFileRepository;
constructor(
private knex: Knex.Knex,
private baseUrl: string,
private logger: Logger,
) {
this.repository = new MediaFileRepository(knex, logger);
}
async init(): Promise<void> {
await this.repository.init();
}
private getFilepath(data: MediaData) {
return `${data.message.id}.${data.file.extension}`;
}
async save(buffer: Buffer, data: MediaData): Promise<boolean> {
const filepath = this.getFilepath(data);
const metadata = getMetadata(data);
await this.repository.save(filepath, buffer, metadata);
return true;
}
async exists(data: MediaData): Promise<boolean> {
const filepath = this.getFilepath(data);
return await this.repository.exists(filepath);
}
async fetch(filepath: string) {
return await this.repository.fetch(filepath);
}
async remove(filepath: string) {
return await this.repository.delete(filepath);
}
async getStorageData(data: MediaData): Promise<MediaStorageData> {
const filepath = this.getFilepath(data);
const url = `${this.baseUrl}${data.session}/${filepath}`;
return { url: url };
}
async purge(): Promise<void> {
this.logger.debug('Purging S3 bucket is not supported');
}
async close() {
await this.knex.destroy();
}
}
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more