Compare commits

..
83 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
79 changed files with 4584 additions and 912 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();
}
});
});
+146 -25
View File
@@ -1,3 +1,5 @@
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
@@ -63,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,
@@ -103,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 {
@@ -163,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;
@@ -178,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
@@ -190,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
});
@@ -217,13 +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$) => {
@@ -252,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;
}),
),
);
@@ -311,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:
@@ -438,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
*/
@@ -450,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 {
@@ -948,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();
}
+2
View File
@@ -22,6 +22,7 @@ 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';
@@ -205,6 +206,7 @@ export const PROVIDERS_BASE: Provider[] = [
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
NowebEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
+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,
};
}
+147 -1
View File
@@ -28,6 +28,8 @@ 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,
@@ -48,6 +50,10 @@ import {
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
import {
PasskeyChallenge,
PasskeyConfirmationResponse,
} from '@waha/structures/auth.dto';
import {
Channel,
ChannelListResult,
@@ -114,6 +120,7 @@ import {
GroupSortField,
Participant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
@@ -130,7 +137,9 @@ import {
import { CallData } from '@waha/structures/calls.dto';
import {
MeInfo,
MessageCappingData,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
} from '@waha/structures/sessions.dto';
import {
@@ -216,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,
});
}
@@ -231,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',
@@ -419,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;
@@ -430,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) => {
@@ -439,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"
@@ -849,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,
};
}
/**
@@ -1191,6 +1277,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return {
numberExists: info?.registered || false,
chatId: toCusFormat(info?.jid || null),
pn: toCusFormat(info?.pn || null),
};
}
@@ -1578,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();
}
@@ -2184,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,
@@ -2958,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
@@ -3062,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;
}
+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;
}
+233 -41
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';
@@ -43,6 +44,7 @@ import type {
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,
@@ -73,7 +75,12 @@ 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,
@@ -149,6 +156,7 @@ import {
CreateGroupRequest,
GroupParticipant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import {
@@ -166,7 +174,13 @@ 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,
@@ -185,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';
@@ -235,6 +249,7 @@ 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');
@@ -259,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;
@@ -381,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,
@@ -419,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;
@@ -540,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
@@ -624,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() {
@@ -649,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() {
@@ -830,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 {
@@ -849,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,
};
}
@@ -939,6 +1037,39 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
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;
}
/**
* Other methods
*/
@@ -1205,12 +1336,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
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: this.sock.waUploadToServer,
upload: uploadThumbnail,
mediaTypeOverride: 'thumbnail-link',
options: { signal: AbortSignal.timeout(10_000) },
jid: chatId,
},
);
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
@@ -1936,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);
@@ -2882,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);
@@ -2915,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) || '';
@@ -2960,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;
}
}
+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;
}
+155 -132
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,6 +8,7 @@ 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, Message as WebjsMessage } from 'whatsapp-web.js';
import { Message } from 'whatsapp-web.js/src/structures';
@@ -14,18 +16,12 @@ 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');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadPaginator } = require('./_Paginator.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadWAHACore } = require('./_WAHACore.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
@@ -81,7 +77,7 @@ function extractReactionsByMessageKey(
): Map<string, ChannelMessageReaction[]> {
const reactions = new Map();
for (const reaction of newsletterReactions) {
const key = reaction.parentMsgKey._serialized;
const key = GetSerialized(reaction.parentMsgKey);
const emojiCountMap = reaction.emojiCountMap;
const reactionList: ChannelMessageReaction[] = [];
for (const emoji in emojiCountMap) {
@@ -98,21 +94,31 @@ function extractReactionsByMessageKey(
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() {
@@ -130,8 +136,6 @@ export class WebjsClientCore extends Client {
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
await this.pupPage.evaluate(LoadWAHACore);
}
/**
@@ -154,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,
@@ -176,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)) {
@@ -200,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;
@@ -211,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();
}
});
}
@@ -229,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,
@@ -244,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
@@ -279,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,
@@ -292,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();
@@ -310,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;
}
@@ -385,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);
}
@@ -409,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) {
@@ -441,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;
@@ -467,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];
@@ -530,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'];
@@ -539,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
@@ -556,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'];
@@ -568,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;
}
@@ -629,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,
};
@@ -652,16 +695,18 @@ export class WebjsClientCore extends Client {
inviteCode: string,
limit: number,
): Promise<WebjsChannelMessage[]> {
await this.ensureWahaExtraInjected();
const response: _GetNewsletterPreviewDataResponse =
await this.pupPage.evaluate(
async (code, limit) => {
// Overwrite limit
window['WAHA'].WAWebNewsletterGatingUtils = () => limit;
// Overwrite the server-side message count so the preview fetches
// exactly `limit` messages
window.require(
'WAWebNewsletterGatingUtils',
).getMaxMsgCountFromServer = () => limit;
const result = await window[
'WAHA'
].WAWebNewsletterPreviewJob.getNewsletterPreviewData(code, 'guest');
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
@@ -671,11 +716,9 @@ export class WebjsClientCore extends Client {
}
// Fetch one more time to save in database so we can fetch media later
await window[
'WAHA'
].WAWebLoadNewsletterPreviewChatAction.loadNewsletterPreviewChat(
code,
);
await window
.require('WAWebLoadNewsletterPreviewChatAction')
.loadNewsletterPreviewChat(code);
return result;
},
inviteCode,
@@ -693,7 +736,7 @@ export class WebjsClientCore extends Client {
const messages: WebjsChannelMessage[] = messageInstances.map((msg) => {
return {
message: msg,
reactions: reactions.get(msg.id._serialized) || [],
reactions: reactions.get(GetSerialized(msg.id)) || [],
viewCount: msg.rawData.viewCount,
};
});
@@ -704,40 +747,20 @@ export class WebjsClientCore extends Client {
* Channels Search methods
*/
async searchChannelsView(params: any): Promise<any> {
await this.ensureWahaExtraInjected();
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window[
'WAHA'
].WAWebNewsletterDirectorySearchJob.getNewsletterDirectoryList(params);
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectoryList(params);
}, params);
return newsletters;
}
async searchChannelsText(params: any): Promise<any> {
await this.ensureWahaExtraInjected();
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window[
'WAHA'
].WAWebNewsletterDirectorySearchJob.getNewsletterDirectorySearchResults(
params,
);
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectorySearchResults(params);
}, params);
return newsletters;
}
protected async ensureWahaExtraInjected() {
const hasWahaExtra = await this.pupPage.evaluate(() => {
return Boolean(
// @ts-ignore
window.WAHA &&
// @ts-ignore
window.WAHA.WAWebNewsletterDirectorySearchJob &&
// @ts-ignore
window.WAHA.WAWebNewsletterPreviewJob,
);
});
if (!hasWahaExtra) {
await this.injectWaha();
}
}
}
-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',
);
};
-18
View File
@@ -1,18 +0,0 @@
exports.LoadWAHACore = () => {
// Channels Search
window.WAHA.WAWebNewsletterDirectorySearchJob = window.require(
'WAWebNewsletterDirectorySearchJob',
);
// Channels load "preview" - fetch messages by invite code
window.WAHA.WAWebNewsletterPreviewJob = window.require(
'WAWebNewsletterPreviewJob',
);
window.WAHA.WAWebLoadNewsletterPreviewChatAction = window.require(
'WAWebLoadNewsletterPreviewChatAction',
);
// Rewrite newsletter count to different number
window.WAHA.WAWebNewsletterGatingUtils = window.require(
'WAWebNewsletterGatingUtils',
);
};
+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,
};
},
+211 -28
View File
@@ -1,4 +1,5 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { parseMessageCapping } from '@waha/core/abc/capping';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
@@ -108,6 +109,7 @@ import {
GroupParticipant,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
@@ -125,7 +127,13 @@ import {
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { BrowserTraceQuery } from '@waha/structures/server.debug.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,
@@ -143,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,
@@ -182,6 +191,7 @@ 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';
@@ -212,6 +222,8 @@ 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();
@@ -220,6 +232,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
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;
@@ -244,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,
);
}
/**
@@ -306,7 +326,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const clientOptions = this.getClientOptions();
clientOptions.authStrategy = authStrategy;
this.addProxyConfig(clientOptions);
return new WebjsClientCore(clientOptions, this.getWebjsTagsFlag());
return new WebjsClientCore(
clientOptions,
this.getWebjsTagsFlag(),
this.logger,
);
}
protected getWebjsTagsFlag() {
@@ -414,6 +438,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
console.log(`url is ${location.href}`),
);
}
this.startWhatsNewModalJob();
})
.catch((error) => {
this.logger.error(error);
@@ -447,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() {
@@ -482,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();
@@ -508,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 {
@@ -524,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) {
@@ -531,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,
};
}
@@ -548,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
@@ -664,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;
@@ -740,7 +846,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
return {
numberExists: true,
chatId: result._serialized,
chatId: GetSerialized(result),
};
}
@@ -784,6 +890,63 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
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
*/
@@ -1004,6 +1167,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
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,
@@ -1137,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,
@@ -1250,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;
@@ -1508,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);
@@ -1715,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),
@@ -2214,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,
};
@@ -2304,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,
@@ -2313,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;
}
@@ -2399,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,
@@ -2476,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;
}
@@ -2554,7 +2737,7 @@ export class WEBJSEngineMediaProcessor
}
getMessageId(message: Message): string {
return message.id._serialized;
return GetSerialized(message.id);
}
getMimetype(message: Message): string {
+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,
}));
+26 -3
View File
@@ -165,6 +165,7 @@ import {
GroupParticipantRole,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto';
@@ -1412,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));
@@ -1492,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()
@@ -1874,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,
+13 -2
View File
@@ -11,6 +11,7 @@ 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';
@@ -96,6 +97,7 @@ export class SessionManagerCore
private engineConfigService: EngineConfigService,
private webjsEngineConfigService: WebJSEngineConfigService,
private wppEngineConfigService: WPPEngineConfigService,
private nowebEngineConfigService: NowebEngineConfigService,
gowsConfigService: GowsEngineConfigService,
log: PinoLogger,
private mediaStorageFactory: MediaStorageFactory,
@@ -235,14 +237,20 @@ export class SessionManagerCore
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 () => {
const log = this.log.logger.child({ session: sessionName });
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.`);
@@ -358,6 +366,9 @@ export class SessionManagerCore
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();
}
// @ts-ignore
const session = new this.EngineClass(sessionConfig);
+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() {
+1
View File
@@ -40,6 +40,7 @@ export class MediaS3Storage implements IMediaStorage {
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: data.file.mimetype,
Metadata: metadata,
});
await this.client.send(command);
+59
View File
@@ -3,6 +3,7 @@ import {
UnprocessableEntityException,
} from '@nestjs/common';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SessionActions } from '@waha/core/auth/casl.types';
import {
ApiKey,
IApiKeyRepository,
@@ -10,6 +11,24 @@ import {
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
const MEDIA_KEY_ACTIONS: SessionActions = {
read: false,
send: false,
control: false,
setting: false,
app: false,
delete: false,
};
const CONTROL_KEY_ACTIONS: SessionActions = {
read: false,
send: false,
control: true,
setting: false,
app: false,
delete: false,
};
function CheckInvariant(
apiKey: Pick<ApiKey, 'isAdmin' | 'session' | 'actions'>,
): void {
@@ -78,6 +97,46 @@ export class ApiKeyService {
return this.toDTO(apikey);
}
private async createOrGetScopedKey(
session: string,
variant: string,
actions: SessionActions,
): Promise<ApiKeyDTO> {
const exists = await this.manager.exists(session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${session}" does not exist`,
);
}
// Deterministic id keeps this idempotent: repeated calls reuse the same
// stored secret instead of minting a new key every time.
const id = `key_id_${variant}_${session}`;
const existing = await this.repository.getById(id);
if (existing) {
return this.toDTO(existing);
}
const apikey: ApiKey = {
id: id,
key: `key_${generateSecret(32)}`,
isActive: true,
isAdmin: false,
session: session,
actions: actions,
app_id: null,
};
CheckInvariant(apikey);
await this.repository.upsert(apikey);
return this.toDTO(apikey);
}
async createOrGetMediaKey(session: string): Promise<ApiKeyDTO> {
return this.createOrGetScopedKey(session, 'media', MEDIA_KEY_ACTIONS);
}
async createOrGetControlKey(session: string): Promise<ApiKeyDTO> {
return this.createOrGetScopedKey(session, 'control', CONTROL_KEY_ACTIONS);
}
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.repository.list();
return keys.map((key) => this.toDTO(key));
+131
View File
@@ -0,0 +1,131 @@
import {
GetSerialized,
GetSerializedMsgKey,
GetSerializedWid,
} from './serialized';
describe('serialized helpers', () => {
describe('GetSerializedWid', () => {
it('returns strings and nullish values as-is', () => {
expect(GetSerializedWid('123@c.us')).toBe('123@c.us');
expect(GetSerializedWid(null)).toBeNull();
expect(GetSerializedWid(undefined)).toBeUndefined();
});
it('uses existing _serialized', () => {
expect(GetSerializedWid({ _serialized: '123@c.us' })).toBe('123@c.us');
});
it('ignores the minified $1 property and reconstructs from keys', () => {
const wid: any = { $1: 'ignored', user: '123', server: 'c.us' };
expect(GetSerializedWid(wid)).toBe('123@c.us');
expect(wid._serialized).toBe('123@c.us');
});
it('reconstructs from user/server and caches', () => {
const wid: any = { user: '123', server: 'c.us' };
expect(GetSerializedWid(wid)).toBe('123@c.us');
expect(wid._serialized).toBe('123@c.us');
});
it('reconstructs a group wid', () => {
expect(GetSerializedWid({ user: '123-456', server: 'g.us' })).toBe(
'123-456@g.us',
);
});
it('appends the device when present and truthy', () => {
expect(GetSerializedWid({ user: '123', server: 'c.us', device: 7 })).toBe(
'123:7@c.us',
);
expect(GetSerializedWid({ user: '123', server: 'c.us', device: 0 })).toBe(
'123@c.us',
);
});
it('handles the special "call" wid', () => {
expect(GetSerializedWid({ user: 'call', server: 'call' })).toBe('call');
});
it('returns null when it cannot reconstruct (no keys, only $1)', () => {
expect(GetSerializedWid({ foo: 'bar' })).toBeNull();
expect(GetSerializedWid({ $1: '123@c.us' })).toBeNull();
});
});
describe('GetSerializedMsgKey', () => {
it('reconstructs fromMe_remote_id and caches', () => {
const key: any = {
fromMe: true,
remote: { _serialized: '123@c.us' },
id: 'AAA',
};
expect(GetSerializedMsgKey(key)).toBe('true_123@c.us_AAA');
expect(key._serialized).toBe('true_123@c.us_AAA');
});
it('reconstructs a remote wid that only has component keys', () => {
const key: any = {
fromMe: false,
remote: { user: '123', server: 'c.us' },
id: 'AAA',
};
expect(GetSerializedMsgKey(key)).toBe('false_123@c.us_AAA');
});
it('appends the participant', () => {
const key: any = {
fromMe: false,
remote: { _serialized: '123-456@g.us' },
id: 'AAA',
participant: { _serialized: '789@c.us' },
};
expect(GetSerializedMsgKey(key)).toBe('false_123-456@g.us_AAA_789@c.us');
});
it('appends self before participant', () => {
const key: any = {
fromMe: true,
remote: { _serialized: '123@c.us' },
id: 'AAA',
self: 'out',
participant: { _serialized: '789@c.us' },
};
expect(GetSerializedMsgKey(key)).toBe('true_123@c.us_AAA_out_789@c.us');
});
it('ignores $1 and reconstructs from keys', () => {
const key: any = {
$1: 'ignored',
fromMe: true,
remote: { _serialized: '999@c.us' },
id: 'BBB',
};
expect(GetSerializedMsgKey(key)).toBe('true_999@c.us_BBB');
});
});
describe('GetSerialized (dispatcher)', () => {
it('dispatches wid shapes', () => {
expect(GetSerialized({ user: '123', server: 'c.us' })).toBe('123@c.us');
});
it('dispatches msgkey shapes', () => {
expect(
GetSerialized({
fromMe: true,
remote: { user: '123', server: 'c.us' },
id: 'AAA',
}),
).toBe('true_123@c.us_AAA');
});
it('honours existing _serialized before dispatching', () => {
expect(GetSerialized({ _serialized: 'x@c.us' })).toBe('x@c.us');
});
it('returns null for unknown shapes', () => {
expect(GetSerialized({ foo: 'bar' })).toBeNull();
});
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Serialized-ID helpers for the WEBJS engine.
*
* COPIED from whatsapp-web.js `src/util/Serialized.js` (fork: ../whatsapp-web.js).
* Keep this file in sync with that upstream version whenever it changes.
*
* WhatsApp Web renamed the `_serialized` property on its ID objects (Wid /
* MsgKey) to a minified name (`$1`) in the 2026-07 update. Rather than depend on
* that unstable minified name, each helper reconstructs the serialized string
* deterministically from the object's own component keys - existing
* `_serialized` -> reconstruct from keys - and caches it back onto the object as
* `_serialized` so later reads keep working.
*/
interface CacheHit {
done: boolean;
value: string | null;
}
function cached(id: any): CacheHit {
if (id == null) {
return { done: true, value: id };
}
if (typeof id === 'string') {
return { done: true, value: id };
}
if (typeof id._serialized === 'string' && id._serialized !== '') {
return { done: true, value: id._serialized };
}
return { done: false, value: null };
}
/**
* Serializes a Wid (WhatsApp ID): `user[:device]@server`.
*/
export function GetSerializedWid(id: any): string | null {
const hit = cached(id);
if (hit.done) {
return hit.value;
}
let value: string | null;
if (id.user != null && id.server != null) {
value =
id.user === 'call'
? 'call'
: `${id.user}${id.device ? `:${id.device}` : ''}@${id.server}`;
} else {
value = null;
}
if (value != null) {
id._serialized = value;
}
return value;
}
/**
* Serializes a MsgKey (message ID): `fromMe_remote_id[_self][_participant]`.
*/
export function GetSerializedMsgKey(id: any): string | null {
const hit = cached(id);
if (hit.done) {
return hit.value;
}
let value: string | null;
if (id.remote != null && id.id != null) {
const remote = GetSerializedWid(id.remote);
const participant =
id.participant != null ? GetSerializedWid(id.participant) : null;
value =
`${id.fromMe ? 'true' : 'false'}_${remote}_${id.id}` +
(id.self ? `_${id.self}` : '') +
(participant ? `_${participant}` : '');
} else {
value = null;
}
if (value != null) {
id._serialized = value;
}
return value;
}
/**
* Generic entry point: resolves `_serialized`/`$1` then dispatches to the Wid
* or MsgKey helper based on the object's shape. Use the specific helpers when
* the id type is known.
*/
export function GetSerialized(id: any): string | null {
const hit = cached(id);
if (hit.done) {
return hit.value;
}
if (id.remote != null && id.id != null) {
return GetSerializedMsgKey(id);
}
if (id.user != null && id.server != null) {
return GetSerializedWid(id);
}
return null;
}
+2 -2
View File
@@ -43,11 +43,11 @@ process.on('unhandledRejection', (reason, promise) => {
});
logger.info('NODE - Catching unhandled rejections and exceptions enabled');
process.on('SIGINT', () => {
process.once('SIGINT', () => {
logger.info('SIGINT received');
});
process.on('SIGTERM', () => {
process.once('SIGTERM', () => {
logger.info('SIGTERM received');
});
+17
View File
@@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { IsString, Matches, MaxLength } from 'class-validator';
// Affect almost all Databases - Sqlite, MongoDB, Postgres.
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
+7 -1
View File
@@ -8,7 +8,7 @@ import {
ValidateNested,
} from 'class-validator';
import { SessionActions } from '@waha/core/auth/casl.types';
import { SessionName } from '@waha/structures/sessions.dto';
import { SessionName } from '@waha/nestjs/validation/SessionName';
export class SessionActionsDTO implements SessionActions {
@ApiProperty({
@@ -76,6 +76,12 @@ export class ApiKeyDTO {
actions: SessionActions | null;
}
export class ScopedApiKeyRequest {
@ApiProperty({ example: 'default' })
@SessionName()
session: string;
}
export class ApiKeyRequest {
@ApiProperty({ example: false })
@IsBoolean()
+112
View File
@@ -38,3 +38,115 @@ export class RequestCodeRequest {
export class PairingCodeResponse {
code: string;
}
export class PasskeyAssertionResponseData {
@ApiProperty({
description: 'Base64url-encoded clientDataJSON from the authenticator.',
})
clientDataJSON: string;
@ApiProperty({
description: 'Base64url-encoded authenticatorData from the authenticator.',
})
authenticatorData: string;
@ApiProperty({
description: 'Base64url-encoded signature from the authenticator.',
})
signature: string;
@ApiProperty({
description:
'Base64url-encoded user handle, if returned by the authenticator.',
required: false,
})
userHandle?: string;
}
export class PasskeyAssertionRequest {
@ApiProperty({
description:
'Credential ID, as returned by navigator.credentials.get().toJSON().',
})
id: string;
@ApiProperty({
description: 'Base64url-encoded raw credential ID.',
})
rawId: string;
@ApiProperty({
description: 'Always "public-key".',
example: 'public-key',
})
type: string;
@ApiProperty({ type: PasskeyAssertionResponseData })
response: PasskeyAssertionResponseData;
}
export class PasskeyAllowedCredential {
@ApiProperty({
description: 'Base64url-encoded credential ID.',
})
id: string;
@ApiProperty({
description: 'Always "public-key".',
example: 'public-key',
})
type: string;
@ApiProperty({
description: 'Authenticator transports the credential supports.',
example: ['internal', 'hybrid'],
required: false,
})
transports?: string[];
}
/**
* WebAuthn request options - pass it to navigator.credentials.get({ publicKey })
* on the https://web.whatsapp.com origin.
*/
export class PasskeyChallenge {
@ApiProperty({
description: 'Base64url-encoded challenge to sign.',
})
challenge: string;
@ApiProperty({
description: 'How long the challenge is valid for, in milliseconds.',
example: 60000,
})
timeout: number;
@ApiProperty({
description: 'Relying party ID.',
example: 'web.whatsapp.com',
})
rpId: string;
@ApiProperty({ type: [PasskeyAllowedCredential] })
allowCredentials: PasskeyAllowedCredential[];
@ApiProperty({
example: 'required',
})
userVerification: string;
@ApiProperty({
description: 'WebAuthn extensions requested by WhatsApp.',
required: false,
})
extensions?: Record<string, any>;
}
export class PasskeyConfirmationResponse {
@ApiProperty({
description:
'The code the user must verify against the one shown on their phone.',
example: '1234',
})
code: string;
}
+8
View File
@@ -422,6 +422,14 @@ export class WANumberExistResult {
'Chat id for the phone number. Undefined if the number does not exist',
})
chatId?: string;
@ApiProperty({
description:
'Phone number id (@c.us). Can be null when only a @lid chatId is known',
example: '11111111111@c.us',
required: false,
})
pn?: string;
}
export class MessagePoll {
+2
View File
@@ -45,6 +45,8 @@ export enum WAHASessionStatus {
STOPPED = 'STOPPED',
STARTING = 'STARTING',
SCAN_QR_CODE = 'SCAN_QR_CODE',
PASSKEY_REQUIRED = 'PASSKEY_REQUIRED',
PASSKEY_CONFIRMATION_REQUIRED = 'PASSKEY_CONFIRMATION_REQUIRED',
WORKING = 'WORKING',
FAILED = 'FAILED',
}
+4
View File
@@ -26,6 +26,10 @@ export class SettingsSecurityChangeInfo {
adminsOnly: boolean = true;
}
export class SettingsMemberAddMode {
membersCanAddNewMember: boolean = true;
}
/**
* Queries
*/
+161 -17
View File
@@ -1,8 +1,8 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
import { IsDynamicObject } from '@waha/nestjs/validation/IsDynamicObject';
import { SessionName } from '@waha/nestjs/validation/SessionName';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
@@ -10,8 +10,6 @@ import {
IsEnum,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
@@ -153,6 +151,30 @@ export class GowsStorageConfig {
@IsBoolean()
@IsOptional()
labels?: boolean | null;
@ApiProperty({
description:
'Store contacts locally. Set to false to disable; omit or null to keep enabled. ' +
'When disabled: contacts API returns no data, no contact names in chats, ' +
'no PushName/BusinessName events, and sending status to all contacts does not work.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
contacts?: boolean | null;
@ApiProperty({
description:
'Store message secrets locally. Set to false to disable; omit or null to keep enabled. ' +
'When disabled: incoming poll votes, event responses and bot messages can not be decrypted, ' +
'and sending own poll votes does not work.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
messageSecrets?: boolean | null;
}
export class GowsConfig {
@@ -297,6 +319,8 @@ export class SessionConfig {
groups: true,
chats: true,
labels: true,
contacts: true,
messageSecrets: true,
},
},
})
@@ -327,6 +351,121 @@ export class SessionDTO {
config?: SessionConfig;
}
/**
* Enforcement types as listed in the WhatsApp Web app (WAWebUserPrefsTypes.ReachoutTimelockEnforcementType).
* WhatsApp may introduce new values at any time - treat it as an open set.
*/
export enum ReachoutTimelockEnforcementType {
// No restriction
DEFAULT = 'DEFAULT',
BIZ_QUALITY = 'BIZ_QUALITY',
BIZ_COMMERCE_VIOLATION_ADULT = 'BIZ_COMMERCE_VIOLATION_ADULT',
BIZ_COMMERCE_VIOLATION_ALCOHOL = 'BIZ_COMMERCE_VIOLATION_ALCOHOL',
BIZ_COMMERCE_VIOLATION_ANIMALS = 'BIZ_COMMERCE_VIOLATION_ANIMALS',
BIZ_COMMERCE_VIOLATION_BODY_PARTS_FLUIDS = 'BIZ_COMMERCE_VIOLATION_BODY_PARTS_FLUIDS',
BIZ_COMMERCE_VIOLATION_DATING = 'BIZ_COMMERCE_VIOLATION_DATING',
BIZ_COMMERCE_VIOLATION_DIGITAL_SERVICES_PRODUCTS = 'BIZ_COMMERCE_VIOLATION_DIGITAL_SERVICES_PRODUCTS',
BIZ_COMMERCE_VIOLATION_DRUGS = 'BIZ_COMMERCE_VIOLATION_DRUGS',
BIZ_COMMERCE_VIOLATION_DRUGS_ONLY_OTC = 'BIZ_COMMERCE_VIOLATION_DRUGS_ONLY_OTC',
BIZ_COMMERCE_VIOLATION_GAMBLING = 'BIZ_COMMERCE_VIOLATION_GAMBLING',
BIZ_COMMERCE_VIOLATION_HEALTHCARE = 'BIZ_COMMERCE_VIOLATION_HEALTHCARE',
BIZ_COMMERCE_VIOLATION_REAL_FAKE_CURRENCY = 'BIZ_COMMERCE_VIOLATION_REAL_FAKE_CURRENCY',
BIZ_COMMERCE_VIOLATION_SUPPLEMENTS = 'BIZ_COMMERCE_VIOLATION_SUPPLEMENTS',
BIZ_COMMERCE_VIOLATION_TOBACCO = 'BIZ_COMMERCE_VIOLATION_TOBACCO',
BIZ_COMMERCE_VIOLATION_VIOLENT_CONTENT = 'BIZ_COMMERCE_VIOLATION_VIOLENT_CONTENT',
BIZ_COMMERCE_VIOLATION_WEAPONS = 'BIZ_COMMERCE_VIOLATION_WEAPONS',
WEB_COMPANION_ONLY = 'WEB_COMPANION_ONLY',
RESTRICT_ALL_COMPANIONS = 'RESTRICT_ALL_COMPANIONS',
}
export class ReachoutTimelockData {
@ApiProperty({
example: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
enum: ReachoutTimelockEnforcementType,
description:
'Raw WhatsApp enforcement type. Informational only - it does not change what is blocked. ' +
'WhatsApp may introduce new values, so treat it as an open set.',
})
enforcementType: ReachoutTimelockEnforcementType;
@ApiProperty({
example: true,
})
isActive: boolean;
@ApiProperty({
example: 1784477333,
nullable: true,
description: 'Unix timestamp (seconds) when the enforcement ends.',
})
timeEnforcementEnds: number | null;
}
/**
* Capping status for the per-cycle new-chat message quota.
* WhatsApp may introduce new values at any time - treat it as an open set.
*/
export enum MessageCappingStatus {
NONE = 'NONE',
FIRST_WARNING = 'FIRST_WARNING',
SECOND_WARNING = 'SECOND_WARNING',
CAPPED = 'CAPPED',
}
export class MessageCappingData {
@ApiProperty({
example: MessageCappingStatus.FIRST_WARNING,
enum: MessageCappingStatus,
description:
'How close the account is to its new-chat quota. ' +
'CAPPED means new chats are blocked. WhatsApp may introduce new values, ' +
'so treat it as an open set.',
})
cappingStatus: MessageCappingStatus;
@ApiProperty({
example: 1000,
description:
'New-chat messages allowed in the current cycle. -1 when the account ' +
'has no cap.',
})
totalQuota: number;
@ApiProperty({
example: 640,
description: 'New-chat messages already used in the current cycle.',
})
usedQuota: number;
@ApiProperty({
example: 1782874800,
nullable: true,
description: 'Unix timestamp (seconds) when the current cycle started.',
})
cycleStart: number | null;
@ApiProperty({
example: 1785553199,
nullable: true,
description: 'Unix timestamp (seconds) when the current cycle ends.',
})
cycleEnd: number | null;
@ApiProperty({
example: 'NOT_ELIGIBLE',
nullable: true,
description: 'Meta Verified status. Informational.',
})
mvStatus: string | null;
@ApiProperty({
example: 'NOT_ELIGIBLE',
nullable: true,
description: 'One-time engagement status. Informational.',
})
oteStatus: string | null;
}
export class MeInfo {
@ChatIdProperty()
id: string;
@@ -343,6 +482,24 @@ export class MeInfo {
jid?: string;
pushName: string;
@ApiProperty({
required: false,
nullable: true,
description:
'WhatsApp reachout timelock (account restriction) info. ' +
'Null if no enforcement has been seen for the account.',
})
reachoutTimelock?: ReachoutTimelockData | null;
@ApiProperty({
required: false,
nullable: true,
description:
'WhatsApp new-chat message capping (per-cycle quota) info. ' +
'Null until the capping state has been fetched for the account.',
})
messageCapping?: MessageCappingData | null;
}
export class SessionInfo extends SessionDTO {
@@ -368,20 +525,7 @@ export class SessionDetailedInfo extends SessionInfo {
engine?: any;
}
// Affect almost all Databases - Sqlite, MongoDB, Postgres.
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
export { SessionName };
export class SessionCreateRequest {
@ApiProperty({
+15
View File
@@ -125,6 +125,21 @@ export class WASessionStatusBody {
status: WAHASessionStatus;
statuses: SessionStatusPoint[];
@ApiProperty({
required: false,
nullable: true,
description:
'Extra info that belongs to the current status, null for most of them.\n' +
'PASSKEY_REQUIRED - the WebAuthn challenge, ' +
'pass it to navigator.credentials.get({ publicKey: data }).\n' +
'PASSKEY_CONFIRMATION_REQUIRED - { code } - the code to verify against the phone.\n' +
'WORKING - { reachoutTimelock } - present when WhatsApp restricts the account ' +
'from messaging new contacts (the cause of 463 errors on send); ' +
'the session stays connected and the restriction lifts automatically.',
example: null,
})
data?: any;
}
export class WAHAWebhook<Payload = any> {
+43 -6
View File
@@ -20,6 +20,8 @@ export const promiseTimeout = function (
ms: number,
promise: Promise<any>,
): Promise<any> {
// the promise may reject after the timeout already won the race - do not treat it as unhandled
promise?.catch?.(() => undefined);
let timer: NodeJS.Timeout;
return Promise.race([
promise,
@@ -57,12 +59,47 @@ export async function waitUntil(
/**
* Handle setTimeout 32-bit overflow
*/
export function setLongTimeout(callback, delayMs) {
const MAX_DELAY = 2_147_483_647; // ~24.8 days
export class LongTimeout {
private static readonly MAX_DELAY = 2_147_483_647; // ~24.8 days
if (delayMs > MAX_DELAY) {
setTimeout(() => setLongTimeout(callback, delayMs - MAX_DELAY), MAX_DELAY);
} else {
setTimeout(callback, delayMs);
private timeout?: ReturnType<typeof setTimeout>;
private unrefed: boolean = false;
constructor(
private callback: () => void,
delayMs: number,
) {
this.schedule(delayMs);
}
private schedule(delayMs: number) {
if (delayMs > LongTimeout.MAX_DELAY) {
this.timeout = setTimeout(
() => this.schedule(delayMs - LongTimeout.MAX_DELAY),
LongTimeout.MAX_DELAY,
);
} else {
this.timeout = setTimeout(this.callback, delayMs);
}
if (this.unrefed) {
this.timeout.unref?.();
}
}
/**
* Do not keep the process alive just for this timer
*/
unref(): LongTimeout {
this.unrefed = true;
this.timeout?.unref?.();
return this;
}
clear() {
clearTimeout(this.timeout);
}
}
export function setLongTimeout(callback: () => void, delayMs: number) {
return new LongTimeout(callback, delayMs);
}
+18 -1
View File
@@ -1,5 +1,15 @@
/**
* If value is ms - we convert it to seconds
* Both helpers detect the unit by magnitude, using 1e12 as the threshold:
* as unix SECONDS 1e12 is year 33658, as unix MILLISECONDS it's Sep 2001 - so any realistic "now-ish"
* timestamp is below 1e12 in seconds and above it in milliseconds.
*/
/**
* Normalize a unix timestamp to SECONDS.
* Milliseconds get converted (floored), values already in seconds pass through unchanged.
*
* EnsureSeconds(1784477333) => 1784477333 (already seconds)
* EnsureSeconds(1784477333000) => 1784477333 (ms => seconds)
*/
export function EnsureSeconds(ms: number) {
if (!ms) {
@@ -10,6 +20,13 @@ export function EnsureSeconds(ms: number) {
}
return ms;
}
/**
* Normalize a unix timestamp to MILLISECONDS. Seconds get converted, values already in milliseconds pass through unchanged.
*
* EnsureMilliseconds(1784477333) => 1784477333000 (seconds => ms)
* EnsureMilliseconds(1784477333000) => 1784477333000 (already ms)
*/
export function EnsureMilliseconds(seconds: number) {
if (!seconds) {
return seconds;
+1 -1
View File
@@ -48,7 +48,7 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.6.1',
version: '2026.8.1',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
+5
View File
@@ -16,5 +16,10 @@
"incremental": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/dashboard"],
"watchOptions": {
"excludeDirectories": ["**/node_modules", "src/dashboard"]
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"waha": {
"gows": {
"repo": "devlikeapro/gows",
"ref": "v1.0.41"
"repo": "devlikeapro/gows-plus",
"ref": "v1.0.45"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "47244c7d9202e0f4c1b23d12d45d5c208cd1184f"
"ref": "07f9a7b79c2f0db7513005b074824424e6ca1a67"
}
}
}
+630 -485
View File
File diff suppressed because it is too large. Load diff