Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
382c1a7e94 | ||
|
|
d7fb3091a6 | ||
|
|
1fbe60e646 | ||
|
|
841063739e | ||
|
|
8d4eb02202 | ||
|
|
f6ed147acc | ||
|
|
96a106cff7 | ||
|
|
98854e2da3 | ||
|
|
48c34cbc17 | ||
|
|
926b5c1b66 | ||
|
|
608bad07a9 | ||
|
|
64c6a32786 | ||
|
|
f33ca787a5 | ||
|
|
78aea9aa51 | ||
|
|
5e75d47149 | ||
|
|
c5a53d36bb | ||
|
|
0213692705 | ||
|
|
a112fde248 | ||
|
|
c207f3c08c | ||
|
|
1b8027473e | ||
|
|
976066b216 | ||
|
|
af229a1d40 | ||
|
|
d14126d8a6 | ||
|
|
d4ce0e6541 | ||
|
|
5ddbeb995e | ||
|
|
f26d43b299 | ||
|
|
ff998579f0 | ||
|
|
3cae921fba | ||
|
|
4682dddb4e | ||
|
|
2e6683d26f | ||
|
|
e97107ba1a | ||
|
|
2d96a57f72 | ||
|
|
9adcd3b133 | ||
|
|
79233e09e3 | ||
|
|
a96a4b2161 | ||
|
|
f736105873 | ||
|
|
2c1ea2c7c1 | ||
|
|
216e00cc25 | ||
|
|
ac1a015046 | ||
|
|
8f4af6bb08 | ||
|
|
acee1b7d79 | ||
|
|
99241c3089 | ||
|
|
8e764372d2 | ||
|
|
fa21a60cf3 | ||
|
|
02fa0ea06b | ||
|
|
8d1ad04625 | ||
|
|
a639baad8d | ||
|
|
2e24107018 | ||
|
|
750ce208b3 | ||
|
|
057e0a0e70 | ||
|
|
255f84a12d | ||
|
|
ac6d14394a | ||
|
|
cb77c2fc49 | ||
|
|
5c279c5240 | ||
|
|
7e719d8894 | ||
|
|
8faa3ca5a4 | ||
|
|
5dd8bf140d | ||
|
|
0501c37500 | ||
|
|
1496274a99 | ||
|
|
b6ba3951da | ||
|
|
a4a3dc93ad | ||
|
|
57eb0e14ca | ||
|
|
d8970326be | ||
|
|
06412c2c1e | ||
|
|
0b39645c50 | ||
|
|
76f8cc6f53 | ||
|
|
ea79b1d886 | ||
|
|
e54604eb97 | ||
|
|
d267a29e87 | ||
|
|
d4625359e6 | ||
|
|
3618b92c3e | ||
|
|
982092cf2b | ||
|
|
6a452cd66e | ||
|
|
19625e38e9 | ||
|
|
208f4f3d78 | ||
|
|
a9ff7d763d | ||
|
|
e290fd545f | ||
|
|
84f111e2c1 | ||
|
|
55dddd5991 | ||
|
|
638555297c | ||
|
|
0e4de03da3 | ||
|
|
c6219be356 | ||
|
|
2460a23cab | ||
|
|
c2ed34a171 | ||
|
|
1db8ae3423 | ||
|
|
8a06ee3d44 |
No files matched your search
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(yarn build:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm show:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(yarn test:unit:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(pre-commit run:*)",
|
||||
"Bash(yarn test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# run: yarn test:unit
|
||||
|
||||
docker-build:
|
||||
if: github.repository == 'devlikeapro/waha-plus'
|
||||
if: github.repository == 'devlikeapro/waha'
|
||||
# needs: unit-tests
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name:
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check recent changes
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
approvedGitRepositories:
|
||||
- '**'
|
||||
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 0
|
||||
|
||||
supportedArchitectures:
|
||||
cpu:
|
||||
- arm
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execFileSync } = require('child_process');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const readline = require('readline');
|
||||
|
||||
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
|
||||
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
|
||||
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
|
||||
|
||||
const CORE_PREFIX = '[core]';
|
||||
const PLUS_PREFIX = '[PLUS]';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const assumeYes = args.includes('--yes') || args.includes('-y');
|
||||
|
||||
function git(gitArgs, options) {
|
||||
const opts = options || {};
|
||||
return execFileSync('git', gitArgs, {
|
||||
encoding: 'utf8',
|
||||
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function gitOut(gitArgs) {
|
||||
return git(gitArgs, { inherit: false }).trim();
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n✗ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureCleanTree() {
|
||||
const status = gitOut(['status', '--porcelain']);
|
||||
if (status) {
|
||||
fail(
|
||||
'Working tree is not clean. Commit or stash your changes before releasing.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBranchExists(branch) {
|
||||
try {
|
||||
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
||||
} catch {
|
||||
fail(`Branch "${branch}" does not exist locally.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
|
||||
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
|
||||
// plus every release, so `plus..dev` is exactly the new, unreleased work.
|
||||
// We intentionally do not use `git cherry` (patch-id matching) because core is
|
||||
// a rewritten history whose old commits share no patch-ids with dev.
|
||||
function commitsToCherryPick(boundary, head, prefix) {
|
||||
const format = '%H%x09%s';
|
||||
const output = gitOut([
|
||||
'rev-list',
|
||||
'--reverse',
|
||||
'--no-merges',
|
||||
`--format=${format}`,
|
||||
`${boundary}..${head}`,
|
||||
]);
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
const picks = [];
|
||||
for (const line of output.split('\n')) {
|
||||
// rev-list --format prefixes each entry with a "commit <sha>" header line.
|
||||
if (!line || line.startsWith('commit ')) {
|
||||
continue;
|
||||
}
|
||||
const tab = line.indexOf('\t');
|
||||
const sha = line.slice(0, tab);
|
||||
const subject = line.slice(tab + 1);
|
||||
if (subject.startsWith(prefix)) {
|
||||
picks.push({ sha: sha, subject: subject });
|
||||
}
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
function cherryPickAll(picks) {
|
||||
for (const pick of picks) {
|
||||
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
|
||||
if (dryRun) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
git(['cherry-pick', pick.sha], { inherit: true });
|
||||
} catch {
|
||||
git(['cherry-pick', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
|
||||
`Aborted the cherry-pick — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkout(branch) {
|
||||
log(`\n→ checkout ${branch}`);
|
||||
if (!dryRun) {
|
||||
git(['checkout', branch], { inherit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(question) {
|
||||
if (assumeYes || dryRun) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise(function resolver(resolve) {
|
||||
rl.question(`${question} [y/N] `, function onAnswer(answer) {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureCleanTree();
|
||||
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
|
||||
|
||||
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
|
||||
if (dryRun) {
|
||||
log('Running in --dry-run mode: no branches will be modified.\n');
|
||||
}
|
||||
|
||||
// Capture the last-released plus tip before we touch anything. Step 3 merges
|
||||
// core into plus and moves the branch, so both pick sets must be computed
|
||||
// against this saved boundary, not the live plus ref.
|
||||
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
|
||||
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
|
||||
|
||||
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
|
||||
log(
|
||||
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
|
||||
// Step 1 + 2: bring missing [core] commits onto core.
|
||||
checkout(CORE_BRANCH);
|
||||
cherryPickAll(corePicks);
|
||||
|
||||
// Step 3: merge the freshly updated core into plus.
|
||||
checkout(PLUS_BRANCH);
|
||||
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['merge', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the merge — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
|
||||
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
|
||||
log(
|
||||
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
cherryPickAll(plusPicks);
|
||||
|
||||
// Step 5: rebase dev onto the freshly built plus.
|
||||
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
|
||||
const proceed = await confirm(
|
||||
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
|
||||
);
|
||||
if (!proceed) {
|
||||
fail('Aborted before rebasing dev. core/plus changes are kept.');
|
||||
}
|
||||
checkout(DEV_BRANCH);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['rebase', PLUS_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['rebase', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the rebase — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
checkout(startBranch);
|
||||
log('\nDry run complete. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
log(
|
||||
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
|
||||
);
|
||||
log(
|
||||
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
|
||||
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(function onError(error) {
|
||||
fail(error.message || String(error));
|
||||
});
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { Action } from '@waha/core/auth/casl.types';
|
||||
import { CanServer } from '@waha/core/auth/policies';
|
||||
import { CanServer, CanSession, FromBody } from '@waha/core/auth/policies';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
|
||||
import {
|
||||
ApiKeyDTO,
|
||||
ApiKeyRequest,
|
||||
ScopedApiKeyRequest,
|
||||
} from '@waha/structures/apikeys.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/keys')
|
||||
@@ -44,6 +48,26 @@ export class ApiKeysController {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post('/media')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a media-download-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetMediaKey(body.session);
|
||||
}
|
||||
|
||||
@Post('/control')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a control-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetControlKey(body.session);
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an API key' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
import {
|
||||
QRCodeSessionParam,
|
||||
@@ -19,6 +24,9 @@ import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
import {
|
||||
PasskeyAssertionRequest,
|
||||
PasskeyChallenge,
|
||||
PasskeyConfirmationResponse,
|
||||
QRCodeFormat,
|
||||
QRCodeQuery,
|
||||
QRCodeValue,
|
||||
@@ -68,6 +76,54 @@ class AuthController {
|
||||
) {
|
||||
return session.requestCode(request.phoneNumber, request.method, request);
|
||||
}
|
||||
|
||||
@Get('passkey/challenge')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey (WebAuthn) challenge.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_REQUIRED status. ' +
|
||||
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
|
||||
'on the https://web.whatsapp.com origin.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyChallenge })
|
||||
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyChallenge();
|
||||
}
|
||||
|
||||
@Post('passkey')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
|
||||
})
|
||||
submitPasskey(
|
||||
@SessionParam session: WhatsappSession,
|
||||
@Body() request: PasskeyAssertionRequest,
|
||||
) {
|
||||
return session.sendPasskeyResponse(JSON.stringify(request));
|
||||
}
|
||||
|
||||
@Get('passkey/confirmation')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey confirmation code.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
|
||||
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyConfirmationResponse })
|
||||
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyConfirmation();
|
||||
}
|
||||
|
||||
@Post('passkey/confirm')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Confirm passkey pairing (only needed for the manual code case).',
|
||||
})
|
||||
confirmPasskey(@SessionParam session: WhatsappSession) {
|
||||
return session.confirmPasskey();
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthController };
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function clearContent(attachments) {
|
||||
/**
|
||||
* Remove actual base64 "content" from attachment
|
||||
*/
|
||||
if (!attachments) {
|
||||
return attachments;
|
||||
}
|
||||
return attachments.map((attachment) => {
|
||||
attachment = { ...attachment };
|
||||
attachment.content = '';
|
||||
return attachment;
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
ChatWootAppConfig,
|
||||
ChatWootConfig,
|
||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
|
||||
|
||||
export function ListenEventsForChatWoot(config: ChatWootConfig) {
|
||||
const events = [
|
||||
@@ -319,6 +320,7 @@ export abstract class MessageBaseHandler<
|
||||
this.session,
|
||||
EngineHelper.ChatID(payload as any),
|
||||
this.l,
|
||||
EngineHelper.PhoneNumber(payload as any),
|
||||
);
|
||||
const conversation = await this.repo.ConversationByContact(contactInfo);
|
||||
this.info.onConversationId(conversation.conversationId);
|
||||
@@ -329,6 +331,8 @@ export abstract class MessageBaseHandler<
|
||||
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
|
||||
);
|
||||
await this.saveMapping(response, payload);
|
||||
// Clear attachments content to avoid saving it in the task result
|
||||
message.attachments = clearContent(message.attachments);
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,17 +3,24 @@ import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import {
|
||||
AuthPasskeyChallengeInput,
|
||||
AuthPasskeyConfirmationInput,
|
||||
AuthPasskeyConfirmInput,
|
||||
AuthPasskeySubmitInput,
|
||||
AuthQRInput,
|
||||
AuthRequestCodeInput,
|
||||
ScreenshotInput,
|
||||
} from '@waha/apps/mcp/tools/auth.zod';
|
||||
|
||||
function AuthContent(key: string): any {
|
||||
function AuthContent(key: string | null): any {
|
||||
const open = key
|
||||
? `add "?x-api-key=${key}" to the query params (this is a control-only key scoped to this session)`
|
||||
: `append "?x-api-key=YOUR_API_KEY" to the query params, using the key you already have`;
|
||||
return {
|
||||
type: 'text' as const,
|
||||
text: `
|
||||
You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +.
|
||||
If the user wants to open the QR code or screenshot in a browser, add "?x-api-key=${key}" to the query params.
|
||||
If the user wants to open the QR code or screenshot in a browser, ${open}.
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -39,7 +46,8 @@ export class AuthTools extends McpController {
|
||||
})
|
||||
async authQR({ session }: z.infer<typeof AuthQRInput>) {
|
||||
const result = await this.imageRequest(`/api/${session}/auth/qr`);
|
||||
result.content.push(AuthContent(this.api.key));
|
||||
const key = await this.controlApiKey(session);
|
||||
result.content.push(AuthContent(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -58,7 +66,8 @@ export class AuthTools extends McpController {
|
||||
const result = await this.imageRequest(
|
||||
`/api/screenshot?session=${session}`,
|
||||
);
|
||||
result.content.push(AuthContent(this.api.key));
|
||||
const key = await this.controlApiKey(session);
|
||||
result.content.push(AuthContent(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -94,4 +103,95 @@ export class AuthTools extends McpController {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-challenge', {
|
||||
title: 'Get passkey challenge',
|
||||
description:
|
||||
'Get the pending passkey (WebAuthn) challenge for a session in PASSKEY_REQUIRED status. ' +
|
||||
'Fails with 422 when nothing is pending. ' +
|
||||
'You cannot sign the challenge yourself - the assertion has to be produced by an authenticator ' +
|
||||
'on the https://web.whatsapp.com origin (the WAHA browser extension, or the DevTools fallback). ' +
|
||||
'Hand the challenge to the user, then submit the result with auth-passkey-submit.',
|
||||
inputSchema: AuthPasskeyChallengeInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyChallenge({
|
||||
session,
|
||||
}: z.infer<typeof AuthPasskeyChallengeInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/auth/passkey/challenge`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-submit', {
|
||||
title: 'Submit passkey assertion',
|
||||
description:
|
||||
'Submit the WebAuthn assertion produced by navigator.credentials.get() to finish passkey pairing. ' +
|
||||
'Get the challenge from auth-passkey-challenge first. ' +
|
||||
'After this the session usually goes straight to WORKING; ' +
|
||||
'if it goes to PASSKEY_CONFIRMATION_REQUIRED instead, follow up with auth-passkey-confirmation.',
|
||||
inputSchema: AuthPasskeySubmitInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
})
|
||||
async passkeySubmit({
|
||||
session,
|
||||
...body
|
||||
}: z.infer<typeof AuthPasskeySubmitInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/auth/passkey`,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-confirmation', {
|
||||
title: 'Get passkey confirmation code',
|
||||
description:
|
||||
'Get the pending passkey confirmation code for a session in PASSKEY_CONFIRMATION_REQUIRED status. ' +
|
||||
'Fails with 422 when nothing is pending. ' +
|
||||
'Show the code to the user, ask them to check it matches the one on their phone, ' +
|
||||
'then call auth-passkey-confirm.',
|
||||
inputSchema: AuthPasskeyConfirmationInput,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyConfirmation({
|
||||
session,
|
||||
}: z.infer<typeof AuthPasskeyConfirmationInput>) {
|
||||
return this.textRequest({
|
||||
method: 'GET',
|
||||
url: `/api/${session}/auth/passkey/confirmation`,
|
||||
});
|
||||
}
|
||||
|
||||
@Tool('auth-passkey-confirm', {
|
||||
title: 'Confirm passkey pairing',
|
||||
description:
|
||||
'Finish passkey pairing after the user confirmed the code matches the one shown on their phone. ' +
|
||||
'Only call it once the user has verified the code from auth-passkey-confirmation.',
|
||||
inputSchema: AuthPasskeyConfirmInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async passkeyConfirm({ session }: z.infer<typeof AuthPasskeyConfirmInput>) {
|
||||
return this.textRequest({
|
||||
method: 'POST',
|
||||
url: `/api/${session}/auth/passkey/confirm`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
|
||||
import { RequestCodeRequest } from '@waha/structures/auth.dto';
|
||||
import {
|
||||
PasskeyAssertionRequest,
|
||||
RequestCodeRequest,
|
||||
} from '@waha/structures/auth.dto';
|
||||
|
||||
export const AuthQRInput = z.object({
|
||||
session: z.string(),
|
||||
@@ -13,3 +16,19 @@ export const ScreenshotInput = z.object({
|
||||
export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyChallengeInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyConfirmationInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeySubmitInput = DtoToZod(PasskeyAssertionRequest).extend({
|
||||
session: z.string(),
|
||||
});
|
||||
|
||||
export const AuthPasskeyConfirmInput = z.object({
|
||||
session: z.string(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { McpController } from '@waha/apps/mcp/decorators/controller';
|
||||
import { Tool } from '@waha/apps/mcp/decorators/tool';
|
||||
import { TextMcpResponse } from '@waha/apps/mcp/responses';
|
||||
import { ScopedKeyInput } from '@waha/apps/mcp/tools/keys.zod';
|
||||
|
||||
export class KeysTools extends McpController {
|
||||
constructor(api: WAHASelf) {
|
||||
super(api);
|
||||
}
|
||||
|
||||
@Tool('keys-get-scoped-key', {
|
||||
title: 'Get a scoped API key',
|
||||
description:
|
||||
'Create or get a minimal, scoped API key for a session. ' +
|
||||
'Use "media" scope for a download-only key to fetch media files, ' +
|
||||
'or "control" scope for a control-only key to open the QR code / screenshot in a browser. ' +
|
||||
'The returned key is far weaker than your own key and is safe to hand to the user for that single purpose.',
|
||||
inputSchema: ScopedKeyInput,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
},
|
||||
})
|
||||
async getScopedKey({ session, scope }: z.infer<typeof ScopedKeyInput>) {
|
||||
let key: string | null = null;
|
||||
if (scope === 'media') {
|
||||
key = await this.mediaApiKey(session);
|
||||
} else {
|
||||
key = await this.controlApiKey(session);
|
||||
}
|
||||
if (!key) {
|
||||
return TextMcpResponse(
|
||||
JSON.stringify({
|
||||
error:
|
||||
'Could not mint a scoped API key. Check that the session exists and that your key has access to it.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return TextMcpResponse(JSON.stringify({ scope: scope, key: key }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const SessionField = z.string().describe('Session name');
|
||||
|
||||
export const ScopedKeyInput = z.object({
|
||||
session: SessionField,
|
||||
scope: z
|
||||
.enum(['media', 'control'])
|
||||
.describe(
|
||||
'Scope of the key. ' +
|
||||
'"media" — download-only key for fetching media files of the session. ' +
|
||||
'"control" — control-only key to open QR code / screenshot in a browser.',
|
||||
),
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MessageCappingData } from '@waha/structures/sessions.dto';
|
||||
import * as lodash from 'lodash';
|
||||
import { Logger } from 'pino';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* WhatsApp new-chat message capping (per-cycle quota).
|
||||
* The volume counterpart of the reachout timelock: it caps how many new chats
|
||||
* the account may start per cycle. Keeps the latest known state and emits
|
||||
* changes. Unlike the timelock there is no client-side expiry - the cycle
|
||||
* resets on the server and the state is refreshed by re-fetching.
|
||||
*/
|
||||
export class MessageCappingTracker {
|
||||
private capping: MessageCappingData | null = null;
|
||||
private changes: Subject<MessageCappingData | null> = new Subject();
|
||||
|
||||
constructor(private logger: Logger) {}
|
||||
|
||||
get value(): MessageCappingData | null {
|
||||
return this.capping;
|
||||
}
|
||||
|
||||
get changes$(): Observable<MessageCappingData | null> {
|
||||
return this.changes;
|
||||
}
|
||||
|
||||
update(capping: MessageCappingData | null) {
|
||||
if (lodash.isEqual(this.capping, capping)) {
|
||||
return;
|
||||
}
|
||||
this.apply(capping);
|
||||
}
|
||||
|
||||
private apply(capping: MessageCappingData | null) {
|
||||
this.capping = capping;
|
||||
this.logger.info({ messageCapping: capping }, 'Message capping updated');
|
||||
this.changes.next(capping);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ReachoutTimelockData } from '@waha/structures/sessions.dto';
|
||||
import { LongTimeout, setLongTimeout } from '@waha/utils/promiseTimeout';
|
||||
import { EnsureMilliseconds } from '@waha/utils/timehelper';
|
||||
import * as lodash from 'lodash';
|
||||
import { Logger } from 'pino';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* WhatsApp "reachout timelock"
|
||||
* The account is restricted from messaging new contacts for a period of time (the cause of 463 errors on send).
|
||||
* Keeps the latest known state, expires it when the enforcement ends and emits changes.
|
||||
*/
|
||||
export class ReachoutTimelockTracker {
|
||||
private timelock: ReachoutTimelockData | null = null;
|
||||
private timeout?: LongTimeout;
|
||||
private changes: Subject<ReachoutTimelockData | null> = new Subject();
|
||||
|
||||
constructor(private logger: Logger) {}
|
||||
|
||||
get value(): ReachoutTimelockData | null {
|
||||
return this.timelock;
|
||||
}
|
||||
|
||||
get changes$(): Observable<ReachoutTimelockData | null> {
|
||||
return this.changes;
|
||||
}
|
||||
|
||||
update(timelock: ReachoutTimelockData | null) {
|
||||
if (timelock && !timelock.isActive && !this.timelock) {
|
||||
// No enforcement has been seen for the account - keep it null instead of storing an inactive record
|
||||
// (startup fetch on a clean account lands here)
|
||||
return;
|
||||
}
|
||||
if (lodash.isEqual(this.timelock, timelock)) {
|
||||
return;
|
||||
}
|
||||
this.timeout?.clear();
|
||||
if (timelock?.isActive && timelock.timeEnforcementEnds) {
|
||||
const endsAtMs = EnsureMilliseconds(timelock.timeEnforcementEnds);
|
||||
const delay = Math.max(endsAtMs - Date.now(), 0);
|
||||
this.timeout = setLongTimeout(() => this.expire(), delay).unref();
|
||||
}
|
||||
this.apply(timelock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the expiry timer, keeping the last known state.
|
||||
*/
|
||||
stop() {
|
||||
this.timeout?.clear();
|
||||
}
|
||||
|
||||
private expire() {
|
||||
if (!this.timelock?.isActive) {
|
||||
return;
|
||||
}
|
||||
this.apply({ ...this.timelock, isActive: false });
|
||||
}
|
||||
|
||||
private apply(timelock: ReachoutTimelockData | null) {
|
||||
this.timelock = timelock;
|
||||
this.logger.info(
|
||||
{ reachoutTimelock: timelock },
|
||||
'Reachout timelock updated',
|
||||
);
|
||||
this.changes.next(timelock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
MessageCappingData,
|
||||
MessageCappingStatus,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||
|
||||
function parseCappingTimestamp(value: any): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? null : EnsureSeconds(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the message capping payload (xwa2_message_capping_info) - the shape is the same
|
||||
* across engines: GOWS gRPC event, NOWEB Baileys fetch/push, WEBJS injected fetch/local read.
|
||||
*/
|
||||
export function parseMessageCapping(data: any): MessageCappingData {
|
||||
const cappingStatus = (data?.capping_status ??
|
||||
MessageCappingStatus.NONE) as MessageCappingStatus;
|
||||
return {
|
||||
cappingStatus: cappingStatus,
|
||||
// -1 total means "no cap"
|
||||
totalQuota: typeof data?.total_quota === 'number' ? data.total_quota : -1,
|
||||
usedQuota: typeof data?.used_quota === 'number' ? data.used_quota : 0,
|
||||
// cycle timestamps are unix seconds strings (or ms numbers from WEBJS local reads)
|
||||
cycleStart: parseCappingTimestamp(data?.cycle_start_timestamp),
|
||||
cycleEnd: parseCappingTimestamp(data?.cycle_end_timestamp),
|
||||
mvStatus: data?.mv_status ?? null,
|
||||
oteStatus: data?.ote_status ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
ReachoutTimelockData,
|
||||
ReachoutTimelockEnforcementType,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import { WASessionStatusBody } from '@waha/structures/webhooks.dto';
|
||||
|
||||
const logger: any = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
logger.child = () => logger;
|
||||
|
||||
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
|
||||
|
||||
class TestSession extends BaseSession {
|
||||
public getSessionMeInfo(): MeInfo | null {
|
||||
// pushName and id must be set so the SESSION_STATUS pipeline does not delay WORKING statuses
|
||||
return { id: '123@c.us', pushName: 'Test' };
|
||||
}
|
||||
|
||||
public setStatusPublic(status: WAHASessionStatus) {
|
||||
this.setStatus(status);
|
||||
}
|
||||
|
||||
public updateReachoutTimelockPublic(timelock: ReachoutTimelockData | null) {
|
||||
this.reachoutTimelock.update(timelock);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSession(): TestSession {
|
||||
return new TestSession({
|
||||
name: 'test',
|
||||
printQR: false,
|
||||
loggerBuilder: { child: () => logger },
|
||||
sessionStore: null,
|
||||
mediaManager: null,
|
||||
sessionConfig: null,
|
||||
engineConfig: null,
|
||||
ignore: {},
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_TIMELOCK: ReachoutTimelockData = {
|
||||
enforcementType: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
|
||||
isActive: true,
|
||||
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
|
||||
describe('WhatsappSession reachout timelock', () => {
|
||||
let session: TestSession;
|
||||
let statuses: WASessionStatusBody[];
|
||||
|
||||
beforeEach(() => {
|
||||
session = buildSession();
|
||||
statuses = [];
|
||||
session
|
||||
.getEventObservable(WAHAEvents.SESSION_STATUS)
|
||||
.subscribe((body: WASessionStatusBody) => statuses.push(body));
|
||||
});
|
||||
|
||||
it('re-issues WORKING with data when a timelock arrives', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
|
||||
|
||||
expect(statuses).toHaveLength(2);
|
||||
expect(statuses[1].status).toEqual(WAHASessionStatus.WORKING);
|
||||
expect(statuses[1].data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
|
||||
});
|
||||
|
||||
it('keeps attaching the timelock on plain WORKING assignments', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
|
||||
// Reconnect flows assign 'status = WORKING' with no data
|
||||
session.setStatusPublic(WAHASessionStatus.STARTING);
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
|
||||
const last = statuses.at(-1);
|
||||
expect(last.status).toEqual(WAHASessionStatus.WORKING);
|
||||
expect(last.data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
|
||||
});
|
||||
|
||||
it('collapses consecutive WORKING statuses with the same data', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
|
||||
expect(statuses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores duplicate timelock updates', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
|
||||
session.updateReachoutTimelockPublic({ ...ACTIVE_TIMELOCK });
|
||||
|
||||
expect(statuses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores an inactive timelock when none has been seen', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic({
|
||||
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
|
||||
isActive: false,
|
||||
timeEnforcementEnds: null,
|
||||
});
|
||||
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0].data).toBeNull();
|
||||
});
|
||||
|
||||
it('re-issues WORKING when the timelock is lifted by an event', () => {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
|
||||
const lifted = { ...ACTIVE_TIMELOCK, isActive: false };
|
||||
session.updateReachoutTimelockPublic(lifted);
|
||||
|
||||
expect(statuses).toHaveLength(3);
|
||||
expect(statuses[2].data).toEqual({ reachoutTimelock: lifted });
|
||||
});
|
||||
|
||||
it('marks the timelock inactive when the enforcement expires', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const endsAt = Math.floor(Date.now() / 1000) + 600;
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic({
|
||||
...ACTIVE_TIMELOCK,
|
||||
timeEnforcementEnds: endsAt,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(601 * 1000);
|
||||
|
||||
const last = statuses.at(-1);
|
||||
expect(last.data.reachoutTimelock.isActive).toBe(false);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fire the expiry timer after the session stops', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
session.setStatusPublic(WAHASessionStatus.WORKING);
|
||||
session.updateReachoutTimelockPublic({
|
||||
...ACTIVE_TIMELOCK,
|
||||
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 600,
|
||||
});
|
||||
session.setStatusPublic(WAHASessionStatus.STOPPED);
|
||||
const count = statuses.length;
|
||||
|
||||
jest.advanceTimersByTime(601 * 1000);
|
||||
|
||||
expect(statuses).toHaveLength(count);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+150
-30
@@ -1,8 +1,8 @@
|
||||
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
|
||||
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
|
||||
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
|
||||
import {
|
||||
CoreMediaConverter,
|
||||
IMediaConverter,
|
||||
} from '@waha/core/media/IConverter';
|
||||
import { IMediaConverter } from '@waha/core/media/IConverter';
|
||||
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
|
||||
import { MessagesForRead } from '@waha/core/utils/convertors';
|
||||
import {
|
||||
IgnoreJidConfig,
|
||||
@@ -65,6 +65,10 @@ import {
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { MessageId } from 'whatsapp-web.js';
|
||||
|
||||
import {
|
||||
PasskeyChallenge,
|
||||
PasskeyConfirmationResponse,
|
||||
} from '../../structures/auth.dto';
|
||||
import {
|
||||
ChatRequest,
|
||||
CheckNumberStatusQuery,
|
||||
@@ -105,12 +109,16 @@ import {
|
||||
GroupParticipant,
|
||||
GroupsListFields,
|
||||
ParticipantsRequest,
|
||||
SettingsMemberAddMode,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '../../structures/groups.dto';
|
||||
import { WAHAChatPresences } from '../../structures/presence.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
MessageCappingData,
|
||||
MessageCappingStatus,
|
||||
ProxyConfig,
|
||||
ReachoutTimelockData,
|
||||
SessionConfig,
|
||||
} from '../../structures/sessions.dto';
|
||||
import {
|
||||
@@ -165,6 +173,19 @@ export interface SessionParams {
|
||||
ignore: IgnoreJidConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A status change, along with the extra info that belongs to that status
|
||||
* (if any) - like the passkey challenge for PASSKEY_REQUIRED.
|
||||
*/
|
||||
interface SessionStatusUpdate {
|
||||
status: WAHASessionStatus;
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface SessionStatusUpdatePoint extends SessionStatusUpdate {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export abstract class WhatsappSession {
|
||||
public engine: WAHAEngine;
|
||||
|
||||
@@ -180,6 +201,9 @@ export abstract class WhatsappSession {
|
||||
protected jids: JidFilter;
|
||||
|
||||
private _status: WAHASessionStatus;
|
||||
private _statusData: any = null;
|
||||
protected reachoutTimelock: ReachoutTimelockTracker;
|
||||
protected messageCapping: MessageCappingTracker;
|
||||
private _presence:
|
||||
| WAHAPresenceStatus.ONLINE
|
||||
| WAHAPresenceStatus.OFFLINE
|
||||
@@ -192,7 +216,7 @@ export abstract class WhatsappSession {
|
||||
|
||||
private shouldPrintQR: boolean;
|
||||
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
|
||||
private status$: Subject<WAHASessionStatus>;
|
||||
private status$: Subject<SessionStatusUpdate>;
|
||||
protected profilePictures: NodeCache = new NodeCache({
|
||||
stdTTL: 24 * 60 * 60, // 1 day
|
||||
});
|
||||
@@ -205,7 +229,7 @@ export abstract class WhatsappSession {
|
||||
|
||||
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
public mediaConverter: IMediaConverter = new CoreMediaConverter();
|
||||
public mediaConverter: IMediaConverter;
|
||||
|
||||
public constructor({
|
||||
name,
|
||||
@@ -219,12 +243,31 @@ export abstract class WhatsappSession {
|
||||
ignore,
|
||||
}: SessionParams) {
|
||||
this._status = WAHASessionStatus.STOPPED;
|
||||
this.status$ = new Subject<WAHASessionStatus>();
|
||||
this.status$ = new Subject<SessionStatusUpdate>();
|
||||
|
||||
this.name = name;
|
||||
this.proxyConfig = proxyConfig;
|
||||
this.loggerBuilder = loggerBuilder;
|
||||
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
|
||||
this.mediaConverter = new Ffmpeg(this.name, this.logger);
|
||||
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
|
||||
this.reachoutTimelock.changes$.subscribe((timelock) => {
|
||||
if (this.status === WAHASessionStatus.WORKING) {
|
||||
// Re-issue WORKING so 'session.status' consumers get the update
|
||||
this.setStatus(WAHASessionStatus.WORKING, {
|
||||
reachoutTimelock: timelock,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.messageCapping = new MessageCappingTracker(this.logger);
|
||||
this.messageCapping.changes$.subscribe((capping) => {
|
||||
if (this.status === WAHASessionStatus.WORKING) {
|
||||
// Re-issue WORKING so 'session.status' consumers get the update
|
||||
this.setStatus(WAHASessionStatus.WORKING, {
|
||||
messageCapping: capping,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
|
||||
(key) =>
|
||||
new SwitchObservable((obs$) => {
|
||||
@@ -253,46 +296,56 @@ export abstract class WhatsappSession {
|
||||
// Wait for WORKING status to get all the info
|
||||
// https://github.com/devlikeapro/waha/issues/409
|
||||
.pipe(
|
||||
switchMap((status: WAHASessionStatus) => {
|
||||
switchMap((update: SessionStatusUpdate) => {
|
||||
const me = this.getSessionMeInfo();
|
||||
const hasMe = !!me?.pushName && !!me?.id;
|
||||
// Delay WORKING by 1 second if condition is met
|
||||
// Usually we get WORKING with all the info after
|
||||
if (status === WAHASessionStatus.WORKING && !hasMe) {
|
||||
return of(status).pipe(delay(2000));
|
||||
if (update.status === WAHASessionStatus.WORKING && !hasMe) {
|
||||
return of(update).pipe(delay(2000));
|
||||
}
|
||||
return of(status);
|
||||
return of(update);
|
||||
}),
|
||||
// Remove consecutive duplicate WORKING statuses
|
||||
// Remove consecutive duplicate WORKING statuses, but let through WORKING re-issued with new data
|
||||
distinctUntilChanged(
|
||||
(prev, curr) => prev === curr && curr === WAHASessionStatus.WORKING,
|
||||
(prev, curr) =>
|
||||
prev.status === curr.status &&
|
||||
curr.status === WAHASessionStatus.WORKING &&
|
||||
lodash.isEqual(prev.data, curr.data),
|
||||
),
|
||||
// attach current time (ms)
|
||||
timestamp(),
|
||||
map(
|
||||
({ value, timestamp }) =>
|
||||
({
|
||||
status: value,
|
||||
status: value.status,
|
||||
timestamp: timestamp,
|
||||
}) as SessionStatusPoint,
|
||||
data: value.data,
|
||||
}) as SessionStatusUpdatePoint,
|
||||
),
|
||||
// keep the last 3 entries
|
||||
scan<SessionStatusPoint, SessionStatusPoint[]>(
|
||||
(statuses, status: SessionStatusPoint) => {
|
||||
const next = [...statuses, status];
|
||||
scan<SessionStatusUpdatePoint, SessionStatusUpdatePoint[]>(
|
||||
(points, point: SessionStatusUpdatePoint) => {
|
||||
const next = [...points, point];
|
||||
return next.length > 3 ? next.slice(-3) : next;
|
||||
},
|
||||
[],
|
||||
),
|
||||
// shape final payload
|
||||
map(
|
||||
(statuses) =>
|
||||
({
|
||||
name: this.name,
|
||||
status: statuses.at(-1)?.status, // current
|
||||
statuses: statuses,
|
||||
}) as WASessionStatusBody,
|
||||
),
|
||||
map((points) => {
|
||||
const current = points.at(-1); // current
|
||||
return {
|
||||
name: this.name,
|
||||
status: current?.status,
|
||||
statuses: points.map(
|
||||
(point): SessionStatusPoint => ({
|
||||
status: point.status,
|
||||
timestamp: point.timestamp,
|
||||
}),
|
||||
),
|
||||
data: current?.data ?? null,
|
||||
} as WASessionStatusBody;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -312,20 +365,55 @@ export abstract class WhatsappSession {
|
||||
return this.events2.get(event);
|
||||
}
|
||||
|
||||
public set status(value: WAHASessionStatus) {
|
||||
if (this.unpairing && value !== WAHASessionStatus.STOPPED) {
|
||||
/**
|
||||
* Set the status along with the extra info that belongs to it.
|
||||
* Plain 'status = value' assignments go through here without data,
|
||||
* so the data is dropped as soon as the session moves on.
|
||||
*/
|
||||
protected setStatus(status: WAHASessionStatus, data: any = null) {
|
||||
if (this.unpairing && status !== WAHASessionStatus.STOPPED) {
|
||||
// In case of unpairing
|
||||
// wait for STOPPED event, ignore the rest
|
||||
return;
|
||||
}
|
||||
this._status = value;
|
||||
this.status$.next(value);
|
||||
if (status === WAHASessionStatus.WORKING && data == null) {
|
||||
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the
|
||||
// active account-restriction info so 'session.status' consumers do not lose it
|
||||
const carry: any = {};
|
||||
if (this.reachoutTimelock.value?.isActive) {
|
||||
carry.reachoutTimelock = this.reachoutTimelock.value;
|
||||
}
|
||||
const capping = this.messageCapping.value;
|
||||
if (capping && capping.cappingStatus !== MessageCappingStatus.NONE) {
|
||||
carry.messageCapping = capping;
|
||||
}
|
||||
if (Object.keys(carry).length > 0) {
|
||||
data = carry;
|
||||
}
|
||||
}
|
||||
if (
|
||||
status === WAHASessionStatus.STOPPED ||
|
||||
status === WAHASessionStatus.FAILED
|
||||
) {
|
||||
this.reachoutTimelock?.stop();
|
||||
}
|
||||
this._status = status;
|
||||
this._statusData = data;
|
||||
this.status$.next({ status: status, data: data });
|
||||
}
|
||||
|
||||
public set status(value: WAHASessionStatus) {
|
||||
this.setStatus(value);
|
||||
}
|
||||
|
||||
public get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
public get statusData() {
|
||||
return this._statusData;
|
||||
}
|
||||
|
||||
protected set presence(value: WAHAPresenceStatus) {
|
||||
switch (value) {
|
||||
case null:
|
||||
@@ -439,6 +527,14 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public fetchMessageCapping(): Promise<MessageCappingData> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth methods
|
||||
*/
|
||||
@@ -451,6 +547,22 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getPasskeyChallenge(): PasskeyChallenge {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public async sendPasskeyResponse(responseJson: string): Promise<void> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public async confirmPasskey(): Promise<void> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getPasskeyConfirmation(): PasskeyConfirmationResponse {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
abstract getScreenshot(): Promise<Buffer>;
|
||||
|
||||
public getSessionMeInfo(): MeInfo | null {
|
||||
@@ -949,6 +1061,14 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getMemberAddMode(id): Promise<SettingsMemberAddMode> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public setMemberAddMode(id, value) {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public deleteGroup(id) {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as process from 'node:process';
|
||||
|
||||
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
|
||||
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConditionalModule, ConfigModule } from '@nestjs/config';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
@@ -22,10 +22,15 @@ import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
|
||||
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
|
||||
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
|
||||
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
|
||||
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
|
||||
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
|
||||
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
|
||||
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
|
||||
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
|
||||
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
|
||||
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
|
||||
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
getPinoHttpUseLevel,
|
||||
getPinoLogLevel,
|
||||
getPinoTransport,
|
||||
isDebugEnabled,
|
||||
redactUrlParams,
|
||||
} from '@waha/utils/logging';
|
||||
import * as Joi from 'joi';
|
||||
@@ -144,10 +150,25 @@ const IMPORTS_MEDIA = [
|
||||
.default('LOCAL'),
|
||||
}),
|
||||
}),
|
||||
MediaLocalStorageModule,
|
||||
ConditionalModule.registerWhen(
|
||||
MediaLocalStorageModule,
|
||||
(env: NodeJS.ProcessEnv) =>
|
||||
!env['WAHA_MEDIA_STORAGE'] || env['WAHA_MEDIA_STORAGE'] == 'LOCAL',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
ConditionalModule.registerWhen(
|
||||
MediaS3StorageModule,
|
||||
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'S3',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
ConditionalModule.registerWhen(
|
||||
MediaPsqlStorageModule,
|
||||
(env: NodeJS.ProcessEnv) => env['WAHA_MEDIA_STORAGE'] == 'POSTGRESQL',
|
||||
{ debug: isDebugEnabled() },
|
||||
),
|
||||
];
|
||||
|
||||
const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
|
||||
export const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
|
||||
|
||||
export const CONTROLLERS = [
|
||||
AuthController,
|
||||
@@ -185,10 +206,13 @@ export const PROVIDERS_BASE: Provider[] = [
|
||||
WebJSEngineConfigService,
|
||||
WPPEngineConfigService,
|
||||
GowsEngineConfigService,
|
||||
NowebEngineConfigService,
|
||||
WhatsappConfigService,
|
||||
EngineConfigService,
|
||||
WebsocketGatewayCore,
|
||||
MediaLocalStorageConfig,
|
||||
MongoStoreHealthIndicator,
|
||||
CheckFreeDiskSpaceIndicator,
|
||||
WebSocketAuth,
|
||||
ApiKeyStrategy,
|
||||
ApiKeyAuthService,
|
||||
@@ -203,7 +227,7 @@ export const PROVIDERS_BASE: Provider[] = [
|
||||
...AppsModuleExports.providers,
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
export const PROVIDERS = [
|
||||
{
|
||||
provide: SessionManager,
|
||||
useClass: SessionManagerCore,
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -28,16 +28,15 @@ import {
|
||||
parseJsonList,
|
||||
statusToAck,
|
||||
} from '@waha/core/engines/gows/helpers';
|
||||
import { parseMessageCapping } from '@waha/core/abc/capping';
|
||||
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
|
||||
import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore';
|
||||
import {
|
||||
extractBody,
|
||||
getDestination,
|
||||
} from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { QR } from '@waha/core/QR';
|
||||
@@ -46,10 +45,15 @@ import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import {
|
||||
isJidBroadcast,
|
||||
isJidGroup,
|
||||
isJidNewsletter,
|
||||
normalizeJid,
|
||||
toCusFormat,
|
||||
toJID,
|
||||
} from '@waha/core/utils/jids';
|
||||
import {
|
||||
PasskeyChallenge,
|
||||
PasskeyConfirmationResponse,
|
||||
} from '@waha/structures/auth.dto';
|
||||
import {
|
||||
Channel,
|
||||
ChannelListResult,
|
||||
@@ -78,6 +82,7 @@ import {
|
||||
ChatRequest,
|
||||
CheckNumberStatusQuery,
|
||||
EditMessageRequest,
|
||||
MessageButtonReply,
|
||||
MessageContactVcardRequest,
|
||||
MessageFileRequest,
|
||||
MessageForwardRequest,
|
||||
@@ -89,6 +94,7 @@ import {
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -114,6 +120,7 @@ import {
|
||||
GroupSortField,
|
||||
Participant,
|
||||
ParticipantsRequest,
|
||||
SettingsMemberAddMode,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '@waha/structures/groups.dto';
|
||||
import { ReplyToMessage } from '@waha/structures/message.dto';
|
||||
@@ -130,13 +137,18 @@ import {
|
||||
import { CallData } from '@waha/structures/calls.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
MessageCappingData,
|
||||
ProxyConfig,
|
||||
ReachoutTimelockData,
|
||||
SessionConfig,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
@@ -192,10 +204,17 @@ import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
|
||||
import { extractVCards } from '@waha/core/engines/waproto/vcards';
|
||||
import { Activity } from '@waha/core/abc/activity';
|
||||
import { TmpDir } from '@waha/utils/tmpdir';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import { sortObjectByValues } from '@waha/helpers';
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
import * as path from 'path';
|
||||
import MessageServiceClient = messages.MessageServiceClient;
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
axiosRetry(axios, { retries: 3 });
|
||||
|
||||
function getGowsStorageConfig(
|
||||
sessionConfig?: SessionConfig,
|
||||
): messages.SessionStorageConfig {
|
||||
@@ -206,6 +225,8 @@ function getGowsStorageConfig(
|
||||
groups: storeConfig?.groups !== false,
|
||||
chats: storeConfig?.chats !== false,
|
||||
labels: storeConfig?.labels !== false,
|
||||
contacts: storeConfig?.contacts !== false,
|
||||
message_secrets: storeConfig?.messageSecrets !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,6 +242,8 @@ enum WhatsMeowEvent {
|
||||
CHAT_PRESENCE = 'events.ChatPresence',
|
||||
PUSH_NAME_SETTING = 'events.PushNameSetting',
|
||||
LOGGED_OUT = 'events.LoggedOut',
|
||||
NOTIFY_ACCOUNT_REACHOUT_TIMELOCK = 'events.NotifyAccountReachoutTimelock',
|
||||
MESSAGE_CAPPING = 'gows.MessageCapping',
|
||||
// Groups
|
||||
GROUP_INFO = 'events.GroupInfo',
|
||||
JOINED_GROUP = 'events.JoinedGroup',
|
||||
@@ -409,6 +432,25 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
if (data.Event == 'success') {
|
||||
return;
|
||||
}
|
||||
if (data.Event == 'passkey-request') {
|
||||
// WhatsApp requires a passkey (WebAuthn) to finish pairing this account.
|
||||
const challenge = data.PasskeyRequest?.PublicKey ?? null;
|
||||
this.logger.info('Passkey required to finish pairing');
|
||||
this.setStatus(WAHASessionStatus.PASSKEY_REQUIRED, challenge);
|
||||
return;
|
||||
}
|
||||
if (data.Event == 'passkey-confirmation') {
|
||||
// Only the manual case reaches us - when WhatsApp allows skipping the
|
||||
// handoff UX, whatsmeow confirms on its own and emits nothing.
|
||||
// The operator must see the code, verify it matches the one shown on
|
||||
// their phone, then confirm via POST .../auth/passkey/confirm.
|
||||
const code = data.PasskeyConfirmation?.Code ?? null;
|
||||
this.logger.info({ code: code }, 'Passkey confirmation code');
|
||||
this.setStatus(WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED, {
|
||||
code: code,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.Event != 'code') {
|
||||
this.logger.warn(data, 'Failed QR item event');
|
||||
this.status = WAHASessionStatus.FAILED;
|
||||
@@ -420,6 +462,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
this.qr.save(qr);
|
||||
this.printQR(this.qr);
|
||||
if (
|
||||
this.status === WAHASessionStatus.PASSKEY_REQUIRED ||
|
||||
this.status === WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED
|
||||
) {
|
||||
// The underlying whatsmeow QR rotation keeps emitting fresh codes in
|
||||
// parallel while the passkey challenge is pending (it doesn't know
|
||||
// about the passkey step). Don't let that bounce the session back to
|
||||
// SCAN_QR_CODE mid-flow — the operator is busy signing the passkey.
|
||||
// It'd also wipe the passkey data off the status.
|
||||
return;
|
||||
}
|
||||
this.status = WAHASessionStatus.SCAN_QR_CODE;
|
||||
});
|
||||
events.on(WhatsMeowEvent.PUSH_NAME_SETTING, (data) => {
|
||||
@@ -429,6 +482,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
this.logger.error('Logged out');
|
||||
this.status = WAHASessionStatus.FAILED;
|
||||
});
|
||||
events.on(WhatsMeowEvent.NOTIFY_ACCOUNT_REACHOUT_TIMELOCK, (data) => {
|
||||
this.reachoutTimelock.update(parseGowsReachoutTimelock(data));
|
||||
});
|
||||
events.on(WhatsMeowEvent.MESSAGE_CAPPING, (data) => {
|
||||
this.messageCapping.update(parseMessageCapping(data));
|
||||
});
|
||||
events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => {
|
||||
if (isJidGroup(event.From)) {
|
||||
// So group is not "online"
|
||||
@@ -839,12 +898,49 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return { code: code };
|
||||
}
|
||||
|
||||
public async sendPasskeyResponse(responseJson: string): Promise<void> {
|
||||
const request = new messages.PasskeyResponseRequest({
|
||||
session: this.session,
|
||||
response_json: responseJson,
|
||||
});
|
||||
await promisify(this.client.SubmitPasskeyResponse)(request);
|
||||
}
|
||||
|
||||
public async confirmPasskey(): Promise<void> {
|
||||
await promisify(this.client.ConfirmPasskey)(this.session);
|
||||
}
|
||||
|
||||
public getPasskeyChallenge(): PasskeyChallenge {
|
||||
if (this.status !== WAHASessionStatus.PASSKEY_REQUIRED) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No passkey challenge is pending for the session',
|
||||
);
|
||||
}
|
||||
return this.statusData;
|
||||
}
|
||||
|
||||
public getPasskeyConfirmation(): PasskeyConfirmationResponse {
|
||||
if (this.status !== WAHASessionStatus.PASSKEY_CONFIRMATION_REQUIRED) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No passkey confirmation is pending for the session',
|
||||
);
|
||||
}
|
||||
return { code: this.statusData?.code };
|
||||
}
|
||||
|
||||
async unpair() {
|
||||
await promisify(this.client.Logout)(this.session);
|
||||
}
|
||||
|
||||
public getSessionMeInfo(): MeInfo | null {
|
||||
return this.me;
|
||||
if (!this.me) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...this.me,
|
||||
reachoutTimelock: this.reachoutTimelock.value,
|
||||
messageCapping: this.messageCapping.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -899,12 +995,46 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async fileToMedia(
|
||||
file: RemoteFile | BinaryFile,
|
||||
): Promise<messages.Media> {
|
||||
let content: Buffer;
|
||||
if ('url' in file) {
|
||||
// fetch file
|
||||
content = await this.fetch(file.url);
|
||||
} else {
|
||||
// base64 to bytes
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
}
|
||||
|
||||
return new messages.Media({
|
||||
content: content,
|
||||
mimetype: file.mimetype,
|
||||
filename: file.filename,
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const request = new messages.SetProfilePictureRequest({
|
||||
session: this.session,
|
||||
picture: media.content,
|
||||
});
|
||||
const response = await promisify(this.client.SetProfilePicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
const request = new messages.SetProfilePictureRequest({
|
||||
session: this.session,
|
||||
});
|
||||
const response = await promisify(this.client.SetProfilePicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1008,12 +1138,53 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
sendPollVote(request: MessagePollVoteRequest) {
|
||||
throw new AvailableInPlusVersion('Poll voting');
|
||||
@Activity()
|
||||
async sendPollVote(request: MessagePollVoteRequest) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const key = parseMessageIdSerialized(request.pollMessageId, true);
|
||||
const pollVote = new messages.PollVoteMessage({
|
||||
pollMessageId: key.id,
|
||||
options: request.votes,
|
||||
});
|
||||
if (request.pollServerId != null) {
|
||||
// protobuf expects int64 number
|
||||
pollVote.pollServerId = request.pollServerId;
|
||||
}
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
pollVote: pollVote,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
sendList(request: SendListRequest): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendList(request: SendListRequest): Promise<any> {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`List message can only be sent to a direct message chat.`,
|
||||
);
|
||||
}
|
||||
const m = request.message;
|
||||
const list = messages.ListMessage.fromObject({
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
footer: m.footer,
|
||||
button: m.button,
|
||||
sections: m.sections,
|
||||
});
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
list: list,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1106,6 +1277,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return {
|
||||
numberExists: info?.registered || false,
|
||||
chatId: toCusFormat(info?.jid || null),
|
||||
pn: toCusFormat(info?.pn || null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1132,22 +1304,209 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async sendMedia(type: messages.MediaType, request: any) {
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.type = type;
|
||||
if (type === messages.MediaType.IMAGE) {
|
||||
media.mimetype = media.mimetype || WAMimeType.IMAGE;
|
||||
} else if (type === messages.MediaType.AUDIO) {
|
||||
media.mimetype = media.mimetype || WAMimeType.VOICE;
|
||||
} else if (
|
||||
type === messages.MediaType.VIDEO ||
|
||||
type === messages.MediaType.PTV
|
||||
) {
|
||||
media.mimetype = media.mimetype || WAMimeType.VIDEO;
|
||||
} else if (type === messages.MediaType.DOCUMENT) {
|
||||
if (!media.mimetype) {
|
||||
media.mimetype = await detectMimetype(media.content as Buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.convert) {
|
||||
switch (type) {
|
||||
case messages.MediaType.AUDIO:
|
||||
media.content = await this.mediaConverter.voice(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VOICE;
|
||||
break;
|
||||
case messages.MediaType.VIDEO:
|
||||
media.content = await this.mediaConverter.video(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
break;
|
||||
case messages.MediaType.PTV:
|
||||
media.content = await this.mediaConverter.video(
|
||||
media.content as Buffer,
|
||||
);
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`No conversion for ${type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only for Voice Status
|
||||
let backgroundColor: messages.OptionalString | null = null;
|
||||
if (request.backgroundColor) {
|
||||
backgroundColor = new messages.OptionalString({
|
||||
value: request.backgroundColor,
|
||||
});
|
||||
}
|
||||
const participants = await this.prepareJidsForStatus(request.contacts);
|
||||
const message = new messages.MessageRequest({
|
||||
id: request.id,
|
||||
jid: jid,
|
||||
text: request.caption,
|
||||
session: this.session,
|
||||
media: media,
|
||||
backgroundColor: backgroundColor,
|
||||
mentions: request.mentions?.map((mention) =>
|
||||
normalizeJid(toJID(mention)),
|
||||
),
|
||||
participants: participants,
|
||||
});
|
||||
|
||||
if (media.type == messages.MediaType.AUDIO) {
|
||||
const logger: any = this.loggerBuilder.child({});
|
||||
const buffer = Buffer.from(media.content);
|
||||
const waveform = await esm.b.getAudioWaveform(buffer, logger);
|
||||
const duration = await esm.b.getAudioDuration(buffer);
|
||||
media.audio = new messages.AudioInfo({
|
||||
waveform: waveform,
|
||||
duration: duration,
|
||||
});
|
||||
}
|
||||
if (
|
||||
media.type == messages.MediaType.VIDEO ||
|
||||
media.type == messages.MediaType.PTV
|
||||
) {
|
||||
const buffer = Buffer.from(media.content);
|
||||
const duration = await esm.b.getAudioDuration(buffer).catch((err) => {
|
||||
this.logger.warn({ error: err }, 'Failed to get video duration');
|
||||
return undefined;
|
||||
});
|
||||
const isGif = request.file?.mimetype === 'image/gif';
|
||||
media.video = new messages.VideoInfo({
|
||||
duration: duration,
|
||||
gifPlayback: isGif,
|
||||
externalShareFullVideoDurationInSeconds: isGif ? 0 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
message.replyTo = getMessageIdFromSerialized(request.reply_to);
|
||||
const tmpdir = new TmpDir(this.logger, `waha-smedia-${this.name}-`);
|
||||
return await tmpdir.use(async (dir) => {
|
||||
const file = path.join(dir, 'send-media.tmp');
|
||||
// Try to write to the file
|
||||
try {
|
||||
await fsp.writeFile(file, Buffer.from(media.content));
|
||||
media.contentPath = file;
|
||||
media.content = null;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to write media to temp file: ${e.message}`);
|
||||
}
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
});
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
return await this.sendMedia(messages.MediaType.IMAGE, request);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
return await this.sendMedia(messages.MediaType.DOCUMENT, request);
|
||||
}
|
||||
|
||||
sendLinkCustomPreview(
|
||||
@Activity()
|
||||
async sendVoice(request: MessageVoiceRequest) {
|
||||
return await this.sendMedia(messages.MediaType.AUDIO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
const type = request.asNote
|
||||
? messages.MediaType.PTV
|
||||
: messages.MediaType.VIDEO;
|
||||
return await this.sendMedia(type, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendLinkCustomPreview(
|
||||
request: MessageLinkCustomPreviewRequest,
|
||||
): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const media = await this.fileToMedia(request.preview.image as RemoteFile);
|
||||
const preview = new messages.LinkPreview({
|
||||
url: request.preview.url,
|
||||
title: request.preview.title,
|
||||
description: request.preview.description,
|
||||
image: media.content,
|
||||
});
|
||||
const message = new messages.MessageRequest({
|
||||
jid: jid,
|
||||
text: request.text,
|
||||
session: this.session,
|
||||
linkPreview: true,
|
||||
linkPreviewHighQuality: request.linkPreviewHighQuality,
|
||||
replyTo: getMessageIdFromSerialized(request.reply_to),
|
||||
preview: preview,
|
||||
});
|
||||
const response = await promisify(this.client.SendMessage)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendButtonsReply(request: MessageButtonReply) {
|
||||
throw new NotImplementedByEngineError();
|
||||
|
||||
// Doesn't work yet
|
||||
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
|
||||
const message = new messages.ButtonReplyRequest({
|
||||
jid: jid,
|
||||
session: this.session,
|
||||
replyTo: getMessageIdFromSerialized(request.replyTo),
|
||||
selectedDisplayText: request.selectedDisplayText,
|
||||
selectedButtonID: request.selectedButtonID,
|
||||
});
|
||||
const response = await promisify(this.client.SendButtonReply)(message);
|
||||
const data = response.toObject();
|
||||
return this.messageResponse(jid, data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.IMAGE, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.AUDIO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
const request = {
|
||||
...status,
|
||||
chatId: Jid.BROADCAST,
|
||||
};
|
||||
return await this.sendMedia(messages.MediaType.VIDEO, request);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1306,6 +1665,24 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return;
|
||||
}
|
||||
|
||||
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
|
||||
const group = await this.getGroup(id);
|
||||
return {
|
||||
membersCanAddNewMember: group.MemberAddMode === 'all_member_add',
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async setMemberAddMode(id, value) {
|
||||
const req = new messages.JidBoolRequest({
|
||||
session: this.session,
|
||||
jid: id,
|
||||
value: value,
|
||||
});
|
||||
await promisify(this.client.SetGroupMemberAddMode)(req);
|
||||
return;
|
||||
}
|
||||
|
||||
public deleteGroup(id) {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
@@ -1339,6 +1716,33 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
await promisify(this.client.SetGroupName)(req);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const request = new messages.SetPictureRequest({
|
||||
session: this.session,
|
||||
jid: id,
|
||||
picture: media.content,
|
||||
});
|
||||
const response = await promisify(this.client.SetGroupPicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
const request = new messages.SetPictureRequest({
|
||||
session: this.session,
|
||||
jid: id,
|
||||
});
|
||||
const response = await promisify(this.client.SetGroupPicture)(request);
|
||||
response.toObject();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async getInviteCode(id): Promise<string> {
|
||||
const req = new messages.JidRequest({
|
||||
@@ -1612,23 +2016,122 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const request = new messages.SearchNewslettersByViewRequest({
|
||||
session: this.session,
|
||||
view: query.view,
|
||||
categories: query.categories,
|
||||
countries: query.countries,
|
||||
page: new messages.SearchPage({
|
||||
limit: query.limit,
|
||||
startCursor: query.startCursor,
|
||||
}),
|
||||
});
|
||||
const response = await promisify(this.client.SearchNewslettersByView)(
|
||||
request,
|
||||
);
|
||||
return this.channelsRawDataToResponse(response);
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const request = new messages.SearchNewslettersByTextRequest({
|
||||
session: this.session,
|
||||
text: query.text,
|
||||
categories: query.categories,
|
||||
page: new messages.SearchPage({
|
||||
limit: query.limit,
|
||||
startCursor: query.startCursor,
|
||||
}),
|
||||
});
|
||||
const response = await promisify(this.client.SearchNewslettersByText)(
|
||||
request,
|
||||
);
|
||||
return this.channelsRawDataToResponse(response);
|
||||
}
|
||||
|
||||
private channelsRawDataToResponse(
|
||||
data: messages.NewsletterSearchPageResult,
|
||||
): ChannelListResult {
|
||||
const channels: Channel[] = data.newsletters.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
channels.forEach((channel) => {
|
||||
delete channel.role;
|
||||
});
|
||||
return {
|
||||
page: {
|
||||
startCursor: data.page.startCursor,
|
||||
endCursor: data.page.endCursor,
|
||||
hasNextPage: data.page.hasNextPage,
|
||||
hasPreviousPage: data.page.hasPreviousPage,
|
||||
},
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const downloadMedia = query.downloadMedia;
|
||||
const request = new messages.GetNewsletterMessagesByInviteRequest({
|
||||
session: this.session,
|
||||
invite: inviteCode,
|
||||
limit: query.limit,
|
||||
});
|
||||
const response = await promisify(this.client.GetNewsletterMessagesByInvite)(
|
||||
request,
|
||||
);
|
||||
const resp = parseJson(response);
|
||||
const promises = [];
|
||||
if (!resp.Messages) {
|
||||
return [];
|
||||
}
|
||||
for (const msg of resp.Messages) {
|
||||
promises.push(
|
||||
this.GowsChannelMessageToChannelMessage(
|
||||
resp.NewsletterJID,
|
||||
msg,
|
||||
downloadMedia,
|
||||
),
|
||||
);
|
||||
}
|
||||
let result = await Promise.all(promises);
|
||||
result = result.filter(Boolean);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async GowsChannelMessageToChannelMessage(
|
||||
jid: string,
|
||||
channelMessage: any,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
const msg = {
|
||||
Info: {
|
||||
ID: channelMessage.MessageID,
|
||||
ServerID: channelMessage.MessageServerID,
|
||||
Chat: jid,
|
||||
Sender: jid,
|
||||
IsFromMe: false,
|
||||
Timestamp: channelMessage.Timestamp,
|
||||
},
|
||||
Message: channelMessage.Message,
|
||||
};
|
||||
const message = await this.processIncomingMessage(msg, downloadMedia);
|
||||
const reactions: any =
|
||||
sortObjectByValues(channelMessage.ReactionCounts) || {};
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: channelMessage.ViewsCount,
|
||||
};
|
||||
}
|
||||
|
||||
protected toChannel(newsletter: messages.Newsletter): Channel {
|
||||
@@ -1676,10 +2179,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
public async channelsCreateChannel(
|
||||
request: CreateChannelRequest,
|
||||
): Promise<Channel> {
|
||||
let media: messages.Media;
|
||||
if (request.picture) {
|
||||
media = await this.fileToMedia(request.picture);
|
||||
}
|
||||
const req = new messages.CreateNewsletterRequest({
|
||||
session: this.session,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
picture: media?.content,
|
||||
});
|
||||
const response = await promisify(this.client.CreateNewsletter)(req);
|
||||
const newsletter = response.toObject() as messages.Newsletter;
|
||||
@@ -1781,6 +2289,28 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
return this.toWAContact(data);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchMessageCapping(): Promise<MessageCappingData> {
|
||||
const response = await promisify(this.client.FetchMessageCapping)(
|
||||
this.session,
|
||||
);
|
||||
const capping = parseMessageCapping(parseJson(response));
|
||||
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
|
||||
this.messageCapping.update(capping);
|
||||
return capping;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
|
||||
const response = await promisify(this.client.FetchReachoutTimelock)(
|
||||
this.session,
|
||||
);
|
||||
const timelock = parseGowsReachoutTimelock(parseJson(response));
|
||||
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
|
||||
this.reachoutTimelock.update(timelock);
|
||||
return timelock;
|
||||
}
|
||||
|
||||
public async getContacts(pagination: PaginationParams) {
|
||||
const request = new messages.GetContactsRequest({
|
||||
session: this.session,
|
||||
@@ -2555,6 +3085,19 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* gRPC status codes returned by GOWS DownloadMedia that represent a definitive
|
||||
* failure (the media cannot be fetched right now and retrying won't help). Used
|
||||
* to tag the error as non-retriable so MediaManager doesn't re-issue the call.
|
||||
*/
|
||||
const NON_RETRIABLE_DOWNLOAD_MEDIA_CODES: Set<number> = new Set([
|
||||
grpc.status.FAILED_PRECONDITION,
|
||||
grpc.status.NOT_FOUND,
|
||||
grpc.status.INVALID_ARGUMENT,
|
||||
grpc.status.PERMISSION_DENIED,
|
||||
grpc.status.UNIMPLEMENTED,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Many encrypted stickers carry URL "https://a.whatsapp.net" with no path. If
|
||||
* that string is passed to DownloadMedia, the Go client may attempt HTTP GET to
|
||||
@@ -2659,6 +3202,12 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
} catch (err) {
|
||||
if (err?.code === grpc.status.DEADLINE_EXCEEDED) {
|
||||
err.message = `DownloadMedia timed out after ${mediaDownloadTimeoutMs}ms for message '${message?.Info?.ID}'`;
|
||||
} else if (NON_RETRIABLE_DOWNLOAD_MEDIA_CODES.has(err?.code)) {
|
||||
// The media is not currently downloadable (e.g. CDN 403 after the
|
||||
// anonymous + media-retry fallbacks, or the object is gone). Retrying
|
||||
// the gRPC call won't help and each attempt can block on a media-retry
|
||||
// wait, so mark it so MediaManager stops retrying.
|
||||
err.nonRetriable = true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@ import * as path from 'node:path';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { GowsAuth } from './GowsAuth';
|
||||
|
||||
export class GowsAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
|
||||
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
|
||||
await store.init(name);
|
||||
const connection = store.getSessionDbURL(name);
|
||||
return new GowsAuthSimple(connection, 'postgres');
|
||||
}
|
||||
|
||||
protected async buildSqlite3(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { DataStore } from '../../abc/DataStore';
|
||||
import { LocalStore } from '../../storage/LocalStore';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
|
||||
import { join } from 'path';
|
||||
|
||||
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
|
||||
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
|
||||
import { useMultiFileAuthState } from './useMultiFileAuthState';
|
||||
|
||||
export class NowebAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string) {
|
||||
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private async buildMongoAuth(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
const authStore = new NoWebMongoDbAuth(db);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
async buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Auth');
|
||||
const authStore = new NoWebPsqlAuth(knex);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
protected async buildLocalAuth(store: LocalStore, name: string) {
|
||||
// Quick fix for
|
||||
// https://github.com/devlikeapro/waha/issues/347
|
||||
// We can remove it after some time and the fix
|
||||
// https://github.com/WhiskeySockets/Baileys/pull/824
|
||||
await this.makeCredsJsonValid(store, name);
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
|
||||
return { state, saveCreds, close };
|
||||
}
|
||||
|
||||
private async makeCredsJsonValid(store: LocalStore, name: string) {
|
||||
try {
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const credsPath = join(authFolder, 'creds.json');
|
||||
await makeSureJsonFile(credsPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to fix "creds.json" file, continue...', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type makeWASocket from '@adiwajshing/baileys';
|
||||
import type { QueryIds } from '@adiwajshing/baileys';
|
||||
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
|
||||
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
|
||||
import {
|
||||
ChannelPagination,
|
||||
ChannelSearchByText,
|
||||
ChannelSearchByView,
|
||||
} from '@waha/structures/channels.dto';
|
||||
|
||||
enum NewsletterMexQueryIds {
|
||||
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
|
||||
}
|
||||
|
||||
enum NewsletterXWAPaths {
|
||||
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
|
||||
}
|
||||
|
||||
/**
|
||||
* NowebClient - wrapper around baileys to have more methods
|
||||
*/
|
||||
|
||||
export interface NewsletterSearchResponse {
|
||||
page: ChannelPagination;
|
||||
newsletters: NewsletterMetadata[];
|
||||
}
|
||||
|
||||
export class NowebClient {
|
||||
constructor(private sock: ReturnType<typeof makeWASocket>) {}
|
||||
|
||||
async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
view: query.view,
|
||||
filters: {
|
||||
country_codes: query.countries,
|
||||
categories: query.categories,
|
||||
},
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
|
||||
async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
search_text: query.text,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
|
||||
const pageInfo = response.page_info;
|
||||
const page: ChannelPagination = {
|
||||
startCursor: pageInfo.startCursor,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
hasPreviousPage: pageInfo.hasPreviousPage,
|
||||
};
|
||||
const newsletterResult: any[] = response.result;
|
||||
const newsletters = newsletterResult.map(toNewsletterMetadata);
|
||||
|
||||
return {
|
||||
page: page,
|
||||
newsletters: newsletters as any,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import makeWASocket, {
|
||||
WAMessageContent,
|
||||
WAMessageKey,
|
||||
WAMessageUpdate,
|
||||
WAVersion,
|
||||
} from '@adiwajshing/baileys';
|
||||
import { WACallEvent } from '@adiwajshing/baileys/lib/Types/Call';
|
||||
import { BaileysEventMap } from '@adiwajshing/baileys/lib/Types/Events';
|
||||
@@ -36,9 +37,14 @@ import {
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message';
|
||||
import type {
|
||||
MediaGenerationOptions,
|
||||
NewsletterFetchedUpdate,
|
||||
} from '@adiwajshing/baileys/lib/Types';
|
||||
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import { parseMessageCapping } from '@waha/core/abc/capping';
|
||||
import {
|
||||
getChannelInviteLink,
|
||||
getPublicUrlFromDirectPath,
|
||||
@@ -58,10 +64,7 @@ import {
|
||||
} from '@waha/core/engines/noweb/noweb.newsletter';
|
||||
import { NowebAuthFactoryCore } from '@waha/core/engines/noweb/NowebAuthFactoryCore';
|
||||
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { toVcardV3 } from '@waha/core/vcard';
|
||||
import { createAgentProxy } from '@waha/core/helpers.proxy';
|
||||
import type { Agent } from 'https';
|
||||
@@ -72,9 +75,19 @@ import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
|
||||
import { pairs } from '@waha/utils/pairs';
|
||||
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { isJidNewsletter, toCusFormat, toJID } from '@waha/core/utils/jids';
|
||||
import {
|
||||
isJidNewsletter,
|
||||
jidsFromKey,
|
||||
toCusFormat,
|
||||
toJID,
|
||||
} from '@waha/core/utils/jids';
|
||||
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
|
||||
import { flipObject, splitAt } from '@waha/helpers';
|
||||
import {
|
||||
flipObject,
|
||||
parseBool,
|
||||
sortObjectByValues,
|
||||
splitAt,
|
||||
} from '@waha/helpers';
|
||||
import { PairingCodeResponse } from '@waha/structures/auth.dto';
|
||||
import { CallData } from '@waha/structures/calls.dto';
|
||||
import {
|
||||
@@ -118,6 +131,7 @@ import {
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -137,11 +151,12 @@ import {
|
||||
WAHASessionStatus,
|
||||
WAMessageAck,
|
||||
} from '@waha/structures/enums.dto';
|
||||
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
|
||||
import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto';
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
GroupParticipant,
|
||||
ParticipantsRequest,
|
||||
SettingsMemberAddMode,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '@waha/structures/groups.dto';
|
||||
import {
|
||||
@@ -159,12 +174,21 @@ import {
|
||||
WAHAPresenceData,
|
||||
} from '@waha/structures/presence.dto';
|
||||
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
|
||||
import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
MessageCappingData,
|
||||
ReachoutTimelockData,
|
||||
ReachoutTimelockEnforcementType,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
StatusRequest,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
@@ -175,7 +199,7 @@ import {
|
||||
WAMessageRevokedBody,
|
||||
} from '@waha/structures/webhooks.dto';
|
||||
import { LoggerBuilder } from '@waha/utils/logging';
|
||||
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { exclude } from '@waha/utils/reactive/ops/exclude';
|
||||
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
@@ -197,6 +221,7 @@ import {
|
||||
} from 'rxjs';
|
||||
import { debounceTime, map } from 'rxjs/operators';
|
||||
|
||||
import { NowebClient } from './NowebClient';
|
||||
import { INowebStore } from './store/INowebStore';
|
||||
import { NowebPersistentStore } from './store/NowebPersistentStore';
|
||||
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
|
||||
@@ -215,13 +240,21 @@ import {
|
||||
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
|
||||
import { extractVCards } from '@waha/core/engines/waproto/vcards';
|
||||
import { Activity } from '@waha/core/abc/activity';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import {
|
||||
WAHA_CLIENT_BROWSER_NAME,
|
||||
WAHA_CLIENT_DEVICE_NAME,
|
||||
} from '@waha/core/env';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import esm from '@waha/vendor/esm';
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
import { formatWaVersion } from '@waha/core/engines/noweb/waversion';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const promiseRetry = require('promise-retry');
|
||||
|
||||
axiosRetry(axios, { retries: 3 });
|
||||
|
||||
export const BaileysEvents = {
|
||||
CONNECTION_UPDATE: 'connection.update',
|
||||
CREDS_UPDATE: 'creds.update',
|
||||
@@ -241,11 +274,18 @@ const PresenceStatuses = {
|
||||
};
|
||||
const ToEnginePresenceStatus = flipObject(PresenceStatuses);
|
||||
|
||||
export interface NowebConfig {
|
||||
waVersion: WAVersion;
|
||||
}
|
||||
|
||||
export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
private START_ATTEMPT_DELAY_SECONDS = 2;
|
||||
private AUTO_RESTART_AFTER_SECONDS = 28 * 60;
|
||||
// how long to wait on stop for the WebSocket close handshake and store close before forcing it
|
||||
private CLOSE_TIMEOUT_MS = 3_000;
|
||||
|
||||
engine = WAHAEngine.NOWEB;
|
||||
protected engineConfig: NowebConfig;
|
||||
authFactory = new NowebAuthFactoryCore();
|
||||
storageFactory = new NowebStorageFactoryCore();
|
||||
private startDelayedJob: SingleDelayedJobRunner;
|
||||
@@ -363,7 +403,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
if (markOnlineOnConnect == undefined) {
|
||||
markOnlineOnConnect = true;
|
||||
}
|
||||
const version = this.engineConfig?.waVersion;
|
||||
return {
|
||||
version: version,
|
||||
agent: agents?.socket,
|
||||
// Baileys media upload uses Node https.request in Node runtime.
|
||||
fetchAgent: agents?.fetch as Agent,
|
||||
@@ -401,6 +443,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
agents,
|
||||
state,
|
||||
) as SocketConfig;
|
||||
this.logger.info(
|
||||
`Connecting using wa.version = ${formatWaVersion(socketConfig.version)}`,
|
||||
);
|
||||
const sock = makeWASocket(socketConfig);
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
return sock;
|
||||
@@ -522,13 +567,33 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
|
||||
protected listenConnectionEvents() {
|
||||
this.logger.debug(`Start listening ${BaileysEvents.CONNECTION_UPDATE}...`);
|
||||
this.sock.ev.on('message-capping.update', (data) => {
|
||||
this.messageCapping.update(parseMessageCapping(data));
|
||||
});
|
||||
this.sock.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr, isNewLogin } = update;
|
||||
if (update.reachoutTimeLock) {
|
||||
this.updateReachoutTimelockFromState(update.reachoutTimeLock);
|
||||
}
|
||||
if (isNewLogin) {
|
||||
this.restartClient();
|
||||
} else if (connection === 'open') {
|
||||
this.qr.save('');
|
||||
this.status = WAHASessionStatus.WORKING;
|
||||
// Ask WhatsApp for the current reachout timelock state, so a restarted session learns about
|
||||
// an ongoing timelock without waiting for a push. The result arrives via 'connection.update'
|
||||
this.sock?.fetchAccountReachoutTimelock?.().catch((error) => {
|
||||
this.logger.warn(`Failed to fetch reachout timelock: ${error}`);
|
||||
});
|
||||
// Same for the new-chat message capping - there is no push on (re)connect, only on changes
|
||||
this.sock
|
||||
?.fetchNewChatMessageCap?.()
|
||||
.then((data) => {
|
||||
this.messageCapping.update(parseMessageCapping(data));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(`Failed to fetch message capping: ${error}`);
|
||||
});
|
||||
// Do we need to resubscribe?
|
||||
// Ideally not, we need to explicitly call interesting
|
||||
// jids every 1 minute
|
||||
@@ -606,16 +671,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
});
|
||||
this.logger.info('Creds saved');
|
||||
}
|
||||
await this.end();
|
||||
await this.closeStores();
|
||||
this.status = WAHASessionStatus.STOPPED;
|
||||
this.stopEvents();
|
||||
|
||||
this.mediaManager.close();
|
||||
await this.end();
|
||||
await this.store?.close();
|
||||
this.authNOWEBStore?.close().catch((err) => {
|
||||
this.logger.error('Failed to close NOWEB auth store');
|
||||
this.logger.error(err, err.stack);
|
||||
});
|
||||
}
|
||||
|
||||
protected async failed() {
|
||||
@@ -631,8 +691,34 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
await sleep(1_000);
|
||||
}
|
||||
|
||||
this.stopEvents();
|
||||
this.mediaManager.close();
|
||||
await this.end();
|
||||
await this.store?.close();
|
||||
await this.closeStores();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the data and auth stores with a time bound - close() may flush pending writes,
|
||||
* and broken storage must not block session stop or process shutdown
|
||||
*/
|
||||
private async closeStores() {
|
||||
if (this.store) {
|
||||
await promiseTimeout(this.CLOSE_TIMEOUT_MS, this.store.close()).catch(
|
||||
(err) => {
|
||||
this.logger.error('Failed to close NOWEB store');
|
||||
this.logger.error(err, err.stack);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (this.authNOWEBStore) {
|
||||
await promiseTimeout(
|
||||
this.CLOSE_TIMEOUT_MS,
|
||||
this.authNOWEBStore.close(),
|
||||
).catch((err) => {
|
||||
this.logger.error('Failed to close NOWEB auth store');
|
||||
this.logger.error(err, err.stack);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private fixMessages() {
|
||||
@@ -812,13 +898,41 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
this.cleanupPresenceTimeout();
|
||||
this.presence = null;
|
||||
this.autoRestartJob.stop();
|
||||
const sock = this.sock;
|
||||
// @ts-ignore
|
||||
this.sock?.ev?.removeAllListeners();
|
||||
this.sock?.ws?.removeAllListeners();
|
||||
sock?.ev?.removeAllListeners();
|
||||
sock?.ws?.removeAllListeners();
|
||||
// wait until connection is not connecting to avoid error:
|
||||
// "WebSocket was closed before the connection was established"
|
||||
await waitUntil(async () => !this.sock?.ws?.isConnecting, 1_000, 10_000);
|
||||
this.sock?.end(undefined);
|
||||
await waitUntil(async () => !sock?.ws?.isConnecting, 1_000, 10_000);
|
||||
if (!sock) {
|
||||
return;
|
||||
}
|
||||
// sock.end() waits for the WebSocket close handshake - on a dead or already closed connection
|
||||
// it can hang forever, so bound it and destroy the raw TCP socket to let the process exit
|
||||
const closing = sock.end(undefined);
|
||||
try {
|
||||
await promiseTimeout(this.CLOSE_TIMEOUT_MS, closing);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`WebSocket did not close in ${this.CLOSE_TIMEOUT_MS}ms, terminating it: ${err}`,
|
||||
);
|
||||
this.terminate(sock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the raw TCP socket behind Baileys WebSocket wrapper.
|
||||
* ws.terminate() skips the close handshake, so it works even on half-open connections.
|
||||
*/
|
||||
private terminate(sock: ReturnType<typeof makeWASocket>) {
|
||||
// 'socket' is protected on Baileys WebSocketClient, reach it at runtime
|
||||
const raw = (sock.ws as any)?.socket;
|
||||
try {
|
||||
raw?.terminate?.();
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to terminate WebSocket: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
getSessionMeInfo(): MeInfo | null {
|
||||
@@ -831,6 +945,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
id: toCusFormat(meId),
|
||||
pushName: me.name,
|
||||
lid: jidNormalizedUser(me.lid),
|
||||
reachoutTimelock: this.reachoutTimelock.value,
|
||||
messageCapping: this.messageCapping.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -905,12 +1021,53 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const content: Buffer = await this.fileToBuffer(file);
|
||||
const me = this.getSessionMeInfo();
|
||||
await this.sock.updateProfilePicture(me.id, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
const me = this.getSessionMeInfo();
|
||||
await this.sock.removeProfilePicture(me.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchMessageCapping(): Promise<MessageCappingData> {
|
||||
const data = await this.sock.fetchNewChatMessageCap();
|
||||
const capping = parseMessageCapping(data);
|
||||
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
|
||||
this.messageCapping.update(capping);
|
||||
return capping;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
|
||||
const state = await this.sock.fetchAccountReachoutTimelock();
|
||||
return this.updateReachoutTimelockFromState(state);
|
||||
}
|
||||
|
||||
private updateReachoutTimelockFromState(timelock: any): ReachoutTimelockData {
|
||||
const enforcementType =
|
||||
timelock.enforcementType ?? ReachoutTimelockEnforcementType.DEFAULT;
|
||||
let timeEnforcementEnds: number | null = null;
|
||||
if (timelock.timeEnforcementEnds) {
|
||||
timeEnforcementEnds = EnsureSeconds(
|
||||
timelock.timeEnforcementEnds.getTime(),
|
||||
);
|
||||
}
|
||||
const data: ReachoutTimelockData = {
|
||||
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
|
||||
isActive: timelock.isActive === true,
|
||||
timeEnforcementEnds: timeEnforcementEnds,
|
||||
};
|
||||
this.reachoutTimelock.update(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1061,32 +1218,217 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return await this.sock.sendMessage(request.chatId, message, options);
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'image',
|
||||
request.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.IMAGE;
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
// Baileys' newsletter media path skips thumbnail and dimension computation.
|
||||
// Pre-compute them so iOS renders the image with the correct aspect ratio.
|
||||
if (isJidNewsletter(chatId)) {
|
||||
try {
|
||||
const thumb = await esm.b.extractImageThumb(message.image, 72);
|
||||
message.jpegThumbnail = thumb.buffer;
|
||||
message.width = thumb.original.width;
|
||||
message.height = thumb.original.height;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
{ error: err },
|
||||
'Failed to generate thumbnail for newsletter image',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'document',
|
||||
request.caption,
|
||||
);
|
||||
if (!message.mimetype) {
|
||||
message.mimetype = await detectMimetype(message['document']);
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVoice(request: MessageVoiceRequest) {
|
||||
const message: any = await this.fileToMessage(request.file, 'audio');
|
||||
message.mimetype = message.mimetype || WAMimeType.VOICE;
|
||||
if (request.convert) {
|
||||
message['audio'] = await this.mediaConverter.voice(message['audio']);
|
||||
message.mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
sendLinkCustomPreview(
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
const message: any = await this.fileToMessage(
|
||||
request.file,
|
||||
'video',
|
||||
request.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.VIDEO;
|
||||
if (request.convert) {
|
||||
message['video'] = await this.mediaConverter.video(message['video']);
|
||||
message.mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
if (request.mentions?.length) {
|
||||
message.mentions = request.mentions.map((mention) => toJID(mention));
|
||||
}
|
||||
const duration = await esm.b
|
||||
.getAudioDuration(message['video'])
|
||||
.catch((err) => {
|
||||
this.logger.warn({ error: err }, 'Failed to get video duration');
|
||||
return undefined;
|
||||
});
|
||||
message.seconds = duration;
|
||||
const isGif = request.file?.mimetype === 'image/gif';
|
||||
if (isGif) {
|
||||
message.gifPlayback = true;
|
||||
message.externalShareFullVideoDurationInSeconds = 0;
|
||||
}
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
message.ptv = parseBool(request.asNote);
|
||||
return this.sock.sendMessage(chatId, message, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendLinkCustomPreview(
|
||||
request: MessageLinkCustomPreviewRequest,
|
||||
): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const chatId = toJID(this.ensureSuffix(request.chatId));
|
||||
const options = await this.getMessageOptions(request);
|
||||
const preview = request.preview;
|
||||
const urlInfo = {
|
||||
'matched-text': preview.url,
|
||||
title: preview.title,
|
||||
description: preview.description,
|
||||
jpegThumbnail: null,
|
||||
highQualityThumbnail: null,
|
||||
};
|
||||
|
||||
if (request.preview.image) {
|
||||
const content: Buffer = await this.fileToBuffer(request.preview.image);
|
||||
if (!request.linkPreviewHighQuality) {
|
||||
// generate built-in thumbnail
|
||||
const thumbnail = await esm.b.extractImageThumb(content, 192);
|
||||
urlInfo.jpegThumbnail = thumbnail.buffer;
|
||||
} else {
|
||||
// upload HQ thumbnail
|
||||
// newsletters need the thumbnail uploaded unencrypted (plaintext path),
|
||||
// otherwise clients can not render it (white/blurred preview image)
|
||||
const uploadToServer = this.sock.waUploadToServer;
|
||||
const uploadThumbnail = async (encFilePath, opts) => {
|
||||
opts.newsletter = isJidNewsletter(chatId);
|
||||
return await uploadToServer(encFilePath, opts);
|
||||
};
|
||||
const { imageMessage } = await esm.b.prepareWAMessageMedia(
|
||||
{ image: content },
|
||||
{
|
||||
upload: uploadThumbnail,
|
||||
mediaTypeOverride: 'thumbnail-link',
|
||||
options: { signal: AbortSignal.timeout(10_000) },
|
||||
jid: chatId,
|
||||
},
|
||||
);
|
||||
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
|
||||
? Buffer.from(imageMessage.jpegThumbnail)
|
||||
: undefined;
|
||||
urlInfo.highQualityThumbnail = imageMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
text: request.text,
|
||||
linkPreview: urlInfo,
|
||||
};
|
||||
return this.sock.sendMessage(chatId, message as any, options);
|
||||
}
|
||||
|
||||
protected async uploadMedia(
|
||||
file: RemoteFile | BinaryFile,
|
||||
type,
|
||||
type: any,
|
||||
): Promise<any> {
|
||||
if (file && ('url' in file || 'data' in file)) {
|
||||
throw new AvailableInPlusVersion('Sending media (image, video, pdf)');
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
if (!('url' in file || 'data' in file)) {
|
||||
return;
|
||||
}
|
||||
const message: any = await this.fileToMessage(file, type);
|
||||
const options: MediaGenerationOptions = {
|
||||
logger: this.engineLogger,
|
||||
upload: this.sock.waUploadToServer,
|
||||
};
|
||||
const { imageMessage } = await esm.b.prepareWAMessageMedia(
|
||||
message,
|
||||
options,
|
||||
);
|
||||
return imageMessage;
|
||||
}
|
||||
|
||||
get client(): NowebClient {
|
||||
return new NowebClient(this.sock);
|
||||
}
|
||||
|
||||
protected async fileToMessage(
|
||||
file: RemoteFile | BinaryFile,
|
||||
type: any,
|
||||
caption = '',
|
||||
) {
|
||||
let content: Buffer;
|
||||
if ('url' in file) {
|
||||
content = await this.fetch(file.url);
|
||||
} else if ('data' in file) {
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
} else {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either "file.url" or "file.data" must be specified.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[type]: content,
|
||||
mimetype: file.mimetype,
|
||||
caption: caption,
|
||||
fileName: file.filename,
|
||||
ptt: type === 'audio',
|
||||
};
|
||||
}
|
||||
|
||||
private async fileToBuffer(file: FileType): Promise<Buffer> {
|
||||
let content: Buffer;
|
||||
if ('data' in file) {
|
||||
content = Buffer.from(file.data, 'base64');
|
||||
} else if ('url' in file) {
|
||||
content = await this.fetch(file.url);
|
||||
} else {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either file.url or file.data must be specified.',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1104,8 +1446,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
sendList(request: SendListRequest): Promise<any> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendList(request: SendListRequest): Promise<any> {
|
||||
const jid = toJID(this.ensureSuffix(request.chatId));
|
||||
if (!isLidUser(jid) && !isPnUser(jid)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`List message can only be sent to a direct message chat.`,
|
||||
);
|
||||
}
|
||||
const message = request.message;
|
||||
const msg = {
|
||||
text: message.description || '',
|
||||
title: message.title,
|
||||
buttonText: message.button,
|
||||
footer: message.footer,
|
||||
sections: message.sections,
|
||||
} as any;
|
||||
const options = await this.getMessageOptions(request);
|
||||
return await this.sock.sendMessage(jid, msg, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1625,6 +1983,22 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
/**
|
||||
* Group methods
|
||||
*/
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const content: Buffer = await this.fileToBuffer(file);
|
||||
await this.sock.updateProfilePicture(id, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
await this.sock.removeProfilePicture(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public createGroup(request: CreateGroupRequest) {
|
||||
const participants = request.participants.map(getId);
|
||||
@@ -1701,6 +2075,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return await this.sock.groupSettingUpdate(id, setting);
|
||||
}
|
||||
|
||||
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
|
||||
const group = await this.getGroup(id);
|
||||
return { membersCanAddNewMember: group.memberAddMode };
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async setMemberAddMode(id, value) {
|
||||
const mode = value ? 'all_member_add' : 'admin_add';
|
||||
return await this.sock.groupMemberAddMode(id, mode);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async leaveGroup(id) {
|
||||
return this.sock.groupLeave(id);
|
||||
@@ -1896,6 +2281,84 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
const message: any = await this.fileToMessage(
|
||||
status.file,
|
||||
'image',
|
||||
status.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.IMAGE;
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
const message: any = await this.fileToMessage(status.file, 'audio');
|
||||
message.mimetype = message.mimetype || WAMimeType.VOICE;
|
||||
if (status.convert) {
|
||||
message['audio'] = await this.mediaConverter.voice(message['audio']);
|
||||
message.mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
backgroundColor: status.backgroundColor,
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
const message: any = await this.fileToMessage(
|
||||
status.file,
|
||||
'video',
|
||||
status.caption,
|
||||
);
|
||||
message.mimetype = message.mimetype || WAMimeType.VIDEO;
|
||||
if (status.convert) {
|
||||
message['video'] = await this.mediaConverter.video(message['video']);
|
||||
message.mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
const jids = await this.prepareJidsForStatus(status.contacts);
|
||||
if (!status.id) {
|
||||
this.upsertMeInJIDs(jids);
|
||||
}
|
||||
const messageId = this.prepareMessageIdForStatus(status);
|
||||
const options = {
|
||||
statusJidList: jids,
|
||||
messageId: messageId,
|
||||
};
|
||||
return await this.sendStatusMessage(
|
||||
message,
|
||||
options,
|
||||
jids,
|
||||
status.contacts?.length,
|
||||
);
|
||||
}
|
||||
|
||||
protected prepareMessageIdForStatus(status: StatusRequest) {
|
||||
if (status.id) {
|
||||
this.saveSentMessageId(status.id);
|
||||
@@ -1955,23 +2418,74 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const response = await this.client.searchChannelsByView(query);
|
||||
const channels: Channel[] = response.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
return {
|
||||
page: response.page,
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const response = await this.client.searchChannelsByText(query);
|
||||
const channels: Channel[] = response.newsletters.map(
|
||||
this.toChannel.bind(this),
|
||||
);
|
||||
return {
|
||||
page: response.page,
|
||||
channels: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const downloadMedia = query.downloadMedia;
|
||||
const updates = await this.sock.newsletterFetchPreviewMessages(
|
||||
'invite',
|
||||
inviteCode,
|
||||
query.limit,
|
||||
null,
|
||||
);
|
||||
const promises = [];
|
||||
for (const update of updates) {
|
||||
promises.push(
|
||||
this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia),
|
||||
);
|
||||
}
|
||||
let result = await Promise.all(promises);
|
||||
result = result.filter(Boolean);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async NewsletterFetchedUpdateToChannelMessage(
|
||||
update: NewsletterFetchedUpdate,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
let reactions: any = Object.fromEntries(
|
||||
update.reactions.map(({ code, count }) => [code, count]),
|
||||
);
|
||||
reactions = sortObjectByValues(reactions) || {};
|
||||
const message = await this.processIncomingMessage(
|
||||
update.message,
|
||||
downloadMedia,
|
||||
);
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: update.views,
|
||||
};
|
||||
}
|
||||
|
||||
protected toChannel(newsletter: NOWEBNewsletterMetadata): Channel {
|
||||
@@ -2017,7 +2531,23 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
request.name,
|
||||
request.description,
|
||||
);
|
||||
return this.toChannel(toNewsletterMetadata(newsletter));
|
||||
const channel = this.toChannel(toNewsletterMetadata(newsletter));
|
||||
|
||||
if (request.picture) {
|
||||
let file = request.picture;
|
||||
let picture: any;
|
||||
// @ts-ignore
|
||||
if (file.url) {
|
||||
file = file as RemoteFile;
|
||||
picture = await esm.b.getStream({ url: file.url });
|
||||
// @ts-ignore
|
||||
} else if (file.data) {
|
||||
file = file as BinaryFile;
|
||||
picture = Buffer.from(file.data, 'base64');
|
||||
}
|
||||
await this.sock.newsletterUpdatePicture(channel.id, picture);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async channelsGetChannel(id: string) {
|
||||
@@ -2502,9 +3032,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
if (!origMsgId) {
|
||||
return '';
|
||||
}
|
||||
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
|
||||
Boolean,
|
||||
);
|
||||
const editKey = editMessage.key as WAMessageKey | undefined;
|
||||
const jidsToTry = [
|
||||
targetKey.remoteJid,
|
||||
editKey?.remoteJid,
|
||||
editKey?.remoteJidAlt,
|
||||
].filter(Boolean);
|
||||
let stored: proto.IWebMessageInfo | undefined;
|
||||
for (const jid of jidsToTry) {
|
||||
stored = await this.store?.loadMessage(jid, origMsgId);
|
||||
@@ -2535,40 +3068,75 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
if (!encPayload || !encIv) {
|
||||
return '';
|
||||
}
|
||||
const editInfo = {
|
||||
Chat: editMessage.key?.remoteJid,
|
||||
Sender:
|
||||
editMessage.key?.participant ||
|
||||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
|
||||
};
|
||||
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
|
||||
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
|
||||
fromMe: targetKey.fromMe,
|
||||
remoteJID: targetKey.remoteJid,
|
||||
participant: targetKey.participant,
|
||||
});
|
||||
const candidates: string[] = [primaryOrig];
|
||||
|
||||
// The editor ("modification sender").
|
||||
let modificationSenderJids: Array<string | null | undefined>;
|
||||
if (editKey?.fromMe) {
|
||||
// Try both user lid and c.us
|
||||
modificationSenderJids = [this.sock?.user?.lid, this.sock?.user?.id];
|
||||
} else {
|
||||
const editJids = editKey ? jidsFromKey(editKey) : null;
|
||||
modificationSenderJids = [
|
||||
editKey?.participant || editKey?.remoteJid,
|
||||
editJids?.lid,
|
||||
editJids?.pn,
|
||||
];
|
||||
}
|
||||
modificationSenderJids = lodash
|
||||
.chain(modificationSenderJids)
|
||||
.filter(Boolean)
|
||||
.map(jidToNonAD)
|
||||
.uniq()
|
||||
.value();
|
||||
if (modificationSenderJids.length === 0) {
|
||||
modificationSenderJids.push('');
|
||||
}
|
||||
|
||||
const remoteNonAD = targetKey.remoteJid
|
||||
? jidToNonAD(targetKey.remoteJid)
|
||||
: '';
|
||||
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
|
||||
candidates.push(remoteNonAD);
|
||||
}
|
||||
const participantNonAD = targetKey.participant
|
||||
? jidToNonAD(targetKey.participant)
|
||||
: '';
|
||||
if (participantNonAD && !candidates.includes(participantNonAD)) {
|
||||
candidates.push(participantNonAD);
|
||||
|
||||
// Map dedupes by key and keeps the first-insertion order
|
||||
const attempts = new Map<
|
||||
string,
|
||||
{ origSenderJid: string; modificationSenderJid: string }
|
||||
>();
|
||||
for (const modificationSenderJid of modificationSenderJids) {
|
||||
const primaryOrigSenderJid = getOrigSenderJidForMsgSecret(
|
||||
{ Chat: editKey?.remoteJid, Sender: modificationSenderJid },
|
||||
{
|
||||
fromMe: targetKey.fromMe,
|
||||
remoteJID: targetKey.remoteJid,
|
||||
participant: targetKey.participant,
|
||||
},
|
||||
);
|
||||
const origSenderJids = [
|
||||
primaryOrigSenderJid,
|
||||
remoteNonAD,
|
||||
participantNonAD,
|
||||
].filter(Boolean);
|
||||
for (const origSenderJid of origSenderJids) {
|
||||
// Avoid duplicates in attemps
|
||||
const attemptKey = `${origSenderJid}|${modificationSenderJid}`;
|
||||
attempts.set(attemptKey, {
|
||||
origSenderJid: origSenderJid,
|
||||
modificationSenderJid: modificationSenderJid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let lastErr: unknown;
|
||||
for (const origSenderJid of candidates) {
|
||||
for (const attempt of attempts.values()) {
|
||||
try {
|
||||
const decoded = decryptSecretEncryptedMessageEditProto({
|
||||
encPayload: encPayload,
|
||||
encIv: encIv,
|
||||
origMsgId: origMsgId,
|
||||
origSenderJid: origSenderJid,
|
||||
modificationSenderJid: modificationSenderJid,
|
||||
origSenderJid: attempt.origSenderJid,
|
||||
modificationSenderJid: attempt.modificationSenderJid,
|
||||
origMsgSecret: origSecret,
|
||||
});
|
||||
const text = extractBody(decoded) || '';
|
||||
@@ -2580,7 +3148,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
this.logger.debug(
|
||||
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
|
||||
{
|
||||
err: lastErr,
|
||||
origMsgId: origMsgId,
|
||||
attempts: Array.from(attempts.keys()),
|
||||
},
|
||||
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
|
||||
);
|
||||
return '';
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import {
|
||||
getParticipantId,
|
||||
participantsIncludeMe,
|
||||
} from '@waha/core/engines/noweb/groups.noweb';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
@@ -380,16 +384,13 @@ export class NowebPersistentStore implements INowebStore {
|
||||
if (!this.jids.include(id)) {
|
||||
return;
|
||||
}
|
||||
const participants: string[] = data.participants;
|
||||
const participants: Array<string | GroupParticipant> = data.participants;
|
||||
const action: ParticipantAction = data.action;
|
||||
|
||||
if (action == 'remove') {
|
||||
// Remove the group if the current user is removed
|
||||
const myJid = this.socket?.authState?.creds?.me?.id;
|
||||
const participantsIncludesMe = lodash.find(participants, (p) =>
|
||||
esm.b.areJidsSameUser(p, myJid),
|
||||
);
|
||||
if (participantsIncludesMe) {
|
||||
const me = this.socket?.authState?.creds?.me;
|
||||
if (me && participantsIncludeMe(me, participants)) {
|
||||
await this.groupRepo.deleteById(id);
|
||||
return;
|
||||
}
|
||||
@@ -419,24 +420,33 @@ export class NowebPersistentStore implements INowebStore {
|
||||
|
||||
private participantUpdate(
|
||||
participantsById: DefaultMap<string, GroupParticipant>,
|
||||
participant: string,
|
||||
participant: string | GroupParticipant,
|
||||
action: ParticipantAction,
|
||||
) {
|
||||
const participantId = getParticipantId(participant);
|
||||
if (!participantId) {
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// if there's no participant - add it (by id)
|
||||
participantsById.get(participant);
|
||||
if (typeof participant === 'string') {
|
||||
// if there's no participant - add it (by id)
|
||||
participantsById.get(participantId);
|
||||
} else {
|
||||
// keep the full object - it carries 'phoneNumber' and 'admin' fields
|
||||
participantsById.set(participantId, participant);
|
||||
}
|
||||
break;
|
||||
case 'remove':
|
||||
// remove the participant (by id)
|
||||
participantsById.delete(participant);
|
||||
participantsById.delete(participantId);
|
||||
break;
|
||||
case 'promote':
|
||||
// set admin: admin
|
||||
participantsById.get(participant).admin = 'admin';
|
||||
participantsById.get(participantId).admin = 'admin';
|
||||
break;
|
||||
case 'demote':
|
||||
participantsById.get(participant).admin = null;
|
||||
participantsById.get(participantId).admin = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { DataStore } from '../../../abc/DataStore';
|
||||
import { MongoStorage } from './mongodb/MongoStorage';
|
||||
import { INowebStorage } from './INowebStorage';
|
||||
import { PsqlStorage } from './psql/PsqlStorage';
|
||||
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
|
||||
|
||||
export class NowebStorageFactoryCore {
|
||||
createStorage(store: DataStore, name: string): INowebStorage {
|
||||
if (store instanceof MongoStore) {
|
||||
return this.buildStorageMongo(store, name);
|
||||
}
|
||||
if (store instanceof PsqlStore) {
|
||||
return this.buildPsql(store, name);
|
||||
}
|
||||
if (store instanceof LocalStore) {
|
||||
return this.buildStorageSqlite3(store, name);
|
||||
}
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private buildStorageMongo(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
return new MongoStorage(db);
|
||||
}
|
||||
|
||||
private buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Storage');
|
||||
return new PsqlStorage(knex);
|
||||
}
|
||||
|
||||
private buildStorageSqlite3(store: LocalStore, name: string) {
|
||||
const filePath = store.getFilePath(name, 'store.sqlite3');
|
||||
return new Sqlite3Storage(filePath);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Chat } from '@adiwajshing/baileys';
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { OverviewFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoChatRepository
|
||||
extends MongoRepository<Chat>
|
||||
implements IChatRepository
|
||||
{
|
||||
async getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
filter?: OverviewFilter,
|
||||
merge?: boolean,
|
||||
): Promise<Chat[]> {
|
||||
// Get chats with conversationTimestamp is not Null
|
||||
const mongoFilter: any = { conversationTimestamp: { $ne: NaN } };
|
||||
|
||||
// Build the ID filter conditions
|
||||
const conditions = [];
|
||||
if (!broadcast) {
|
||||
// Filter out chat by id if it ends at @newsletter or @broadcast
|
||||
conditions.push({ id: { $not: { $regex: /@broadcast|@newsletter/ } } });
|
||||
}
|
||||
if (filter?.ids && filter.ids.length > 0) {
|
||||
// Filter by IDs if provided
|
||||
conditions.push({ id: { $in: filter.ids } });
|
||||
}
|
||||
|
||||
if (conditions.length != 0) {
|
||||
// Combine ID conditions with $and if there are multiple conditions
|
||||
mongoFilter.$and = conditions;
|
||||
}
|
||||
|
||||
let query = this.collection.find(mongoFilter);
|
||||
// Sort by conversationTimestamp in descending order
|
||||
query = this.pagination(query, pagination);
|
||||
const rows = await query.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Contact } from '@adiwajshing/baileys';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoContactRepository
|
||||
extends MongoRepository<Contact>
|
||||
implements IContactRepository {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoGroupRepository
|
||||
extends MongoRepository<GroupMetadata>
|
||||
implements IGroupRepository {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { MongoRepository } from '@waha/core/engines/noweb/store/mongodb/MongoRepository';
|
||||
import { LabelAssociationType } from '@waha/core/engines/noweb/labels/LabelAssociationType';
|
||||
|
||||
export class MongoLabelAssociationsRepository
|
||||
extends MongoRepository<LabelAssociation>
|
||||
implements ILabelAssociationRepository
|
||||
{
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
await this.deleteBy({
|
||||
type: association.type,
|
||||
chatId: association.chatId,
|
||||
labelId: association.labelId,
|
||||
// @ts-ignore: messageId doesn't existing in ChatLabelAssociation
|
||||
messageId: association.messageId || null,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
await this.deleteBy({ labelId: labelId });
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({
|
||||
type: type,
|
||||
labelId: labelId,
|
||||
});
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({ chatId: chatId, type: LabelAssociationType.Chat });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoLabelsRepository
|
||||
extends MongoRepository<Label>
|
||||
implements ILabelsRepository {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
INowebLidPNRepository,
|
||||
LidToPN,
|
||||
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoLidPNRepository
|
||||
extends MongoRepository<LidToPN>
|
||||
implements INowebLidPNRepository
|
||||
{
|
||||
saveLids(lids: LidToPN[]): Promise<void> {
|
||||
return this.upsertMany(lids);
|
||||
}
|
||||
|
||||
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
|
||||
return this.getAll(pagination);
|
||||
}
|
||||
|
||||
getLidsCount(): Promise<number> {
|
||||
return this.getCount();
|
||||
}
|
||||
|
||||
async findLidByPN(pn: string): Promise<string | null> {
|
||||
const value = await this.getBy({ pn: pn });
|
||||
return value?.id || null;
|
||||
}
|
||||
|
||||
async findPNByLid(lid: string): Promise<string | null> {
|
||||
const value = await this.getBy({ id: lid });
|
||||
return value?.pn || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { AckToStatus } from '@waha/core/utils/acks';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
import { FindCursor } from 'mongodb';
|
||||
|
||||
import { IMessagesRepository } from '../../../../../core/engines/noweb/store/IMessagesRepository';
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoMessagesRepository
|
||||
extends MongoRepository<any>
|
||||
implements IMessagesRepository
|
||||
{
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.upsertMany(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
jid: string,
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
merge?: boolean,
|
||||
): Promise<any[]> {
|
||||
const query: any = {};
|
||||
if (jid !== ALL_JID) {
|
||||
query['jid'] = jid;
|
||||
}
|
||||
if (
|
||||
filter['filter.timestamp.lte'] != null ||
|
||||
filter['filter.timestamp.gte'] != null
|
||||
) {
|
||||
const filters: any = {};
|
||||
if (filter['filter.timestamp.lte'] != null) {
|
||||
filters['$lte'] = filter['filter.timestamp.lte'];
|
||||
}
|
||||
if (filter['filter.timestamp.gte'] != null) {
|
||||
filters['$gte'] = filter['filter.timestamp.gte'];
|
||||
}
|
||||
query['messageTimestamp'] = filters;
|
||||
}
|
||||
if (filter['filter.fromMe'] != null) {
|
||||
query['data.key.fromMe'] = filter['filter.fromMe'];
|
||||
}
|
||||
if (filter['filter.ack'] != null) {
|
||||
const status = AckToStatus(filter['filter.ack']);
|
||||
query['data.status'] = status;
|
||||
}
|
||||
|
||||
let cursor = this.collection.find(query);
|
||||
cursor = this.pagination(cursor, pagination);
|
||||
const rows = await cursor.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
|
||||
if (jid === ALL_JID) {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
return this.getBy({ jid: jid, id: id });
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
jid: string,
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
const entity = await this.getByJidById(jid, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(entity, update);
|
||||
await this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
await this.collection.deleteMany({ jid: jid, id: { $in: ids } });
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.deleteBy({ jid: jid });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
convertProtobufToPlainObject,
|
||||
replaceLongsWithNumber,
|
||||
} from '@waha/core/engines/noweb/utils';
|
||||
import { Field, Schema } from '@waha/core/storage/Schema';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { MongoPaginator } from '@waha/utils/Paginator';
|
||||
import { Collection, Db } from 'mongodb';
|
||||
import esm from '@waha/vendor/esm';
|
||||
|
||||
/**
|
||||
* Key value repository with extra metadata
|
||||
*/
|
||||
export class MongoRepository<Entity> {
|
||||
private UPSERT_BATCH_SIZE = 1000;
|
||||
protected collection: Collection<any>;
|
||||
|
||||
private readonly metadata: Map<string, (entity: Entity) => any>;
|
||||
private readonly columns: Field[];
|
||||
|
||||
static replace(data: any): any {
|
||||
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
|
||||
}
|
||||
|
||||
static revive(row: any): any {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(row.data), esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
constructor(
|
||||
db: Db,
|
||||
schema: Schema,
|
||||
metadata: Map<string, (entity: Entity) => any> | null = null,
|
||||
) {
|
||||
this.collection = db.collection(schema.name);
|
||||
this.columns = schema.columns;
|
||||
this.metadata = metadata || new Map();
|
||||
}
|
||||
|
||||
async getAll(pagination?: PaginationParams) {
|
||||
let query = this.collection.find();
|
||||
query = this.pagination(query, pagination);
|
||||
const rows = await query.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getCount() {
|
||||
return await this.collection.countDocuments();
|
||||
}
|
||||
|
||||
async getAllBy(filters: any) {
|
||||
const rows = await this.collection.find(filters).toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getAllByIds(ids: string[]) {
|
||||
const entitiesMap = await this.getEntitiesByIds(ids);
|
||||
return Array.from(entitiesMap.values()).filter(
|
||||
(entity) => entity !== null,
|
||||
) as Entity[];
|
||||
}
|
||||
|
||||
async getEntitiesByIds(ids: string[]): Promise<Map<string, Entity | null>> {
|
||||
if (ids.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await this.collection.find({ id: { $in: ids } }).toArray();
|
||||
const entitiesMap = new Map<string, Entity | null>();
|
||||
|
||||
// Initialize a map with null values for all requested IDs
|
||||
for (const id of ids) {
|
||||
entitiesMap.set(id, null);
|
||||
}
|
||||
|
||||
// Fill in the map with found entities
|
||||
for (const row of rows) {
|
||||
const entity = MongoRepository.revive(row);
|
||||
if (entity && row.id) {
|
||||
entitiesMap.set(row.id, entity);
|
||||
}
|
||||
}
|
||||
|
||||
return entitiesMap;
|
||||
}
|
||||
|
||||
protected async getBy(filters: any) {
|
||||
return await this.collection.findOne(filters).then(MongoRepository.revive);
|
||||
}
|
||||
|
||||
private dump(entity: Entity): any {
|
||||
const raw = convertProtobufToPlainObject(entity);
|
||||
replaceLongsWithNumber(raw);
|
||||
const data = {};
|
||||
for (const field of this.columns) {
|
||||
const fn = this.metadata.get(field.fieldName);
|
||||
if (fn) {
|
||||
data[field.fieldName] = fn(raw);
|
||||
} else if (field.fieldName == 'data') {
|
||||
data['data'] = MongoRepository.replace(raw);
|
||||
} else {
|
||||
data[field.fieldName] = raw[field.fieldName];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
save(entity: Entity) {
|
||||
return this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Entity | null> {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
|
||||
async upsertOne(entity: Entity): Promise<void> {
|
||||
const row = this.dump(entity);
|
||||
await this.collection.replaceOne({ id: row.id }, row, { upsert: true });
|
||||
}
|
||||
|
||||
async upsertMany(entities: Entity[]): Promise<void> {
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batchSize = this.UPSERT_BATCH_SIZE;
|
||||
for (let i = 0; i < entities.length; i += batchSize) {
|
||||
const batch = entities.slice(i, i + batchSize);
|
||||
await this.upsertBatch(batch);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBatch(entities: Entity[]): Promise<void> {
|
||||
// Update using bulk write
|
||||
const bulk = this.collection.initializeUnorderedBulkOp();
|
||||
for (const entity of entities) {
|
||||
const row = this.dump(entity);
|
||||
bulk.find({ id: row.id }).upsert().replaceOne(row);
|
||||
}
|
||||
await bulk.execute();
|
||||
}
|
||||
|
||||
protected async deleteBy(filters: any) {
|
||||
await this.collection.deleteMany(filters);
|
||||
}
|
||||
|
||||
async deleteAll() {
|
||||
await this.deleteBy({});
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
await this.deleteBy({ id: id });
|
||||
}
|
||||
|
||||
protected pagination(query: any, pagination?: PaginationParams) {
|
||||
if (pagination?.sortBy) {
|
||||
// check if it's in 'columns'
|
||||
const column = this.columns.find(
|
||||
(column) => column.fieldName === pagination.sortBy,
|
||||
);
|
||||
if (!column) {
|
||||
// add "data"
|
||||
pagination.sortBy = `data.${pagination.sortBy}`;
|
||||
}
|
||||
}
|
||||
const paginator = new MongoPaginator(pagination);
|
||||
return paginator.apply(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
|
||||
import {
|
||||
NowebLabelAssociationsMetadata,
|
||||
NowebMessagesMetadata,
|
||||
} from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NOWEB_STORE_SCHEMA } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { Schema } from '@waha/core/storage/Schema';
|
||||
import { MongoGroupRepository } from '@waha/core/engines/noweb/store/mongodb/MongoGroupRepository';
|
||||
import { MongoLabelAssociationsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelAssociationsRepository';
|
||||
import { MongoLabelsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelsRepository';
|
||||
import { MongoLidPNRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLidPNRepository';
|
||||
import { Db } from 'mongodb';
|
||||
|
||||
import { MongoChatRepository } from './MongoChatRepository';
|
||||
import { MongoContactRepository } from './MongoContactRepository';
|
||||
import { MongoMessagesRepository } from './MongoMessagesRepository';
|
||||
|
||||
enum Order {
|
||||
ASC = 1,
|
||||
DESC = -1,
|
||||
}
|
||||
|
||||
export class MongoStorage extends INowebStorage {
|
||||
private readonly tables: Schema[];
|
||||
|
||||
constructor(private db: Db) {
|
||||
super();
|
||||
this.tables = NOWEB_STORE_SCHEMA;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.upsertIndexes();
|
||||
return;
|
||||
}
|
||||
|
||||
async upsertIndexes() {
|
||||
// Contacts
|
||||
await this.db
|
||||
.collection('contacts')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
// Chats
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ conversationTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ conversationTimestamp: Order.DESC });
|
||||
// Groups
|
||||
await this.db
|
||||
.collection('groups')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
// Messages
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, messageTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, messageTimestamp: Order.DESC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ messageTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ messageTimestamp: Order.DESC });
|
||||
|
||||
//
|
||||
// Labels
|
||||
//
|
||||
await this.db.collection('labels').createIndex({ id: 1 }, { unique: true });
|
||||
// Label associations
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ id: 1 }, { unique: true });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, labelId: 1 });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, chatId: 1 });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, messageId: 1 });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async close() {
|
||||
return;
|
||||
}
|
||||
|
||||
getContactsRepository() {
|
||||
return new MongoContactRepository(this.db, this.getSchema('contacts'));
|
||||
}
|
||||
|
||||
getChatRepository() {
|
||||
return new MongoChatRepository(this.db, this.getSchema('chats'));
|
||||
}
|
||||
|
||||
getGroupRepository(): IGroupRepository {
|
||||
return new MongoGroupRepository(this.db, this.getSchema('groups'));
|
||||
}
|
||||
|
||||
getMessagesRepository() {
|
||||
return new MongoMessagesRepository(
|
||||
this.db,
|
||||
this.getSchema('messages'),
|
||||
NowebMessagesMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
getLabelsRepository(): ILabelsRepository {
|
||||
return new MongoLabelsRepository(this.db, this.getSchema('labels'));
|
||||
}
|
||||
|
||||
getLabelAssociationRepository(): ILabelAssociationRepository {
|
||||
return new MongoLabelAssociationsRepository(
|
||||
this.db,
|
||||
this.getSchema('labelAssociations'),
|
||||
NowebLabelAssociationsMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
getSchema(name: string) {
|
||||
const schema = this.tables.find((table) => table.name === name);
|
||||
if (!schema) {
|
||||
throw new Error(`Schema not found: ${name}`);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
getLidPNRepository(): INowebLidPNRepository {
|
||||
return new MongoLidPNRepository(this.db, this.getSchema('lid_map'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import esm from '@waha/vendor/esm';
|
||||
import { Collection, Db, Document } from 'mongodb';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
function mongoKey(id: string) {
|
||||
// Replace . to -
|
||||
return id.replace(/\./g, '-');
|
||||
}
|
||||
|
||||
type AuthDocument = Document & {
|
||||
key?: string;
|
||||
data?: any;
|
||||
type?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export class NoWebMongoDbAuth {
|
||||
private collection: Collection<AuthDocument>;
|
||||
// Keep legacy aggregated document reference for gradual migration.
|
||||
private document: AuthDocument | null;
|
||||
private creds: any;
|
||||
|
||||
private db: Db;
|
||||
private lock: any;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
this.collection = this.db.collection('auth');
|
||||
this.lock = new AsyncLock({
|
||||
timeout: 5_000,
|
||||
maxPending: Infinity,
|
||||
maxExecutionTime: 60_000,
|
||||
});
|
||||
this.document = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.ensureIndexes();
|
||||
this.document = await this.loadLegacyDocument();
|
||||
const creds = await this.readData('creds');
|
||||
// @ts-ignore:next-line
|
||||
this.creds = creds || (0, esm.b.initAuthCreds)();
|
||||
}
|
||||
|
||||
private async ensureIndexes() {
|
||||
// Separate docs are keyed by "key", keep collection indexable for new format.
|
||||
await this.collection.createIndex({ key: 1 }, { unique: true });
|
||||
}
|
||||
|
||||
private serialize(data: any) {
|
||||
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
|
||||
}
|
||||
|
||||
private deserialize(data: any) {
|
||||
if (data === null || data === undefined) {
|
||||
return null;
|
||||
}
|
||||
const json = JSON.stringify(data);
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(json, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
private async loadLegacyDocument(): Promise<AuthDocument | null> {
|
||||
// The old format stored everything in one doc; keep it for reads and migrate lazily.
|
||||
return this.collection.findOne({ data: { $exists: false } });
|
||||
}
|
||||
|
||||
private async readFromLegacy(field: string, key: string) {
|
||||
if (!this.document) {
|
||||
return null;
|
||||
}
|
||||
if (!(key in this.document)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = this.deserialize(this.document[key]);
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
await this.writeData(value, field);
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeData(data: any, field: string) {
|
||||
const key = mongoKey(field);
|
||||
await this.lock.acquire(key, async () => {
|
||||
await this.collection.updateOne(
|
||||
{ key: key },
|
||||
{
|
||||
$set: {
|
||||
key: key,
|
||||
data: this.serialize(data),
|
||||
type: 'kv',
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async readData(field: string) {
|
||||
const key = mongoKey(field);
|
||||
try {
|
||||
const document = await this.collection.findOne({ key: key });
|
||||
if (document && 'data' in document) {
|
||||
return this.deserialize(document.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return this.readFromLegacy(field, key);
|
||||
}
|
||||
|
||||
async removeData(field: string) {
|
||||
const key = mongoKey(field);
|
||||
try {
|
||||
await this.collection.deleteOne({ key: key });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
isMyMainSession(id: string) {
|
||||
// Decode the jid
|
||||
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
|
||||
return id == `${meId}.0`;
|
||||
}
|
||||
|
||||
methods() {
|
||||
const creds = this.creds;
|
||||
return {
|
||||
state: {
|
||||
creds: creds,
|
||||
keys: {
|
||||
get: async (type, ids) => {
|
||||
const data = {};
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
// Always reset my session and setup new session
|
||||
if (type === 'session' && this.isMyMainSession(id)) {
|
||||
return;
|
||||
}
|
||||
let value = await this.readData(`${type}-${id}`);
|
||||
if (type === 'app-state-sync-key' && value) {
|
||||
value =
|
||||
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
|
||||
}
|
||||
data[id] = value;
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
set: async (data) => {
|
||||
const tasks = [];
|
||||
for (const category of Object.keys(data)) {
|
||||
for (const id of Object.keys(data[category])) {
|
||||
const value = data[category][id];
|
||||
const key = `${category}-${id}`;
|
||||
const hasValue = !!value || value?.length === 0;
|
||||
if (hasValue) {
|
||||
tasks.push(this.writeData(value, key));
|
||||
} else {
|
||||
// Do not remove any keys for now
|
||||
// Avoid removing pre-keys
|
||||
// tasks.push(this.removeData(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
},
|
||||
},
|
||||
},
|
||||
saveCreds: () => {
|
||||
return this.writeData(this.creds, 'creds');
|
||||
},
|
||||
close: async () => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
convertProtobufToPlainObject,
|
||||
replaceLongsWithNumber,
|
||||
} from '@waha/core/engines/noweb/utils';
|
||||
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
|
||||
import esm from '@waha/vendor/esm';
|
||||
|
||||
// PostgreSQL TEXT or JSONB columns do not allow null bytes (c-style strings)
|
||||
// oxlint-disable-next-line no-control-regex
|
||||
const invalidCharsRegex = /\u0000/g;
|
||||
|
||||
export function sanitizeJsonUnicode(str: string): string {
|
||||
return str.replace(invalidCharsRegex, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Key value repository with extra metadata
|
||||
* Add support for converting protobuf to plain object
|
||||
*/
|
||||
export class NOWEBPsqlKVRepository<Entity> extends PsqlKVRepository<Entity> {
|
||||
protected stringify(data: any): string {
|
||||
let value = JSON.stringify(data, esm.b.BufferJSON.replacer);
|
||||
value = sanitizeJsonUnicode(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public parse(row: any): any {
|
||||
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
protected dump(entity: Entity) {
|
||||
const raw = convertProtobufToPlainObject(entity);
|
||||
replaceLongsWithNumber(raw);
|
||||
return super.dump(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import esm from '@waha/vendor/esm';
|
||||
import { PsqlNowebAuthRepository } from '@waha/core/engines/noweb/store/psql/PsqlAuthRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
export class NoWebPsqlAuth {
|
||||
private creds: any;
|
||||
private repository: PsqlNowebAuthRepository;
|
||||
private lock: any;
|
||||
|
||||
constructor(private knex: Knex.Knex) {
|
||||
this.repository = new PsqlNowebAuthRepository(knex);
|
||||
this.lock = new AsyncLock({
|
||||
timeout: 5_000,
|
||||
maxPending: Infinity,
|
||||
maxExecutionTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.repository.init();
|
||||
this.creds = await this.repository.getCreds();
|
||||
if (!this.creds) {
|
||||
this.creds = esm.b.initAuthCreds();
|
||||
}
|
||||
}
|
||||
|
||||
methods() {
|
||||
return {
|
||||
state: {
|
||||
creds: this.creds,
|
||||
keys: {
|
||||
get: async (type, ids) => {
|
||||
const data = {};
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
// Always reset my session and setup new session
|
||||
if (type === 'session' && this.isMyMainSession(id)) {
|
||||
return;
|
||||
}
|
||||
let value = await this.repository.getCategory(type, id);
|
||||
if (type === 'app-state-sync-key' && value) {
|
||||
value =
|
||||
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
|
||||
}
|
||||
data[id] = value;
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
set: async (data) => {
|
||||
const tasks = [];
|
||||
for (const category of Object.keys(data)) {
|
||||
for (const id of Object.keys(data[category])) {
|
||||
const value = data[category][id];
|
||||
const hasValue = !!value || value?.length === 0;
|
||||
if (hasValue) {
|
||||
tasks.push(this.repository.saveCategory(category, id, value));
|
||||
} else {
|
||||
// Do not remove any keys for now
|
||||
// Avoid removing pre-keys
|
||||
// tasks.push(this.removeData(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return;
|
||||
},
|
||||
},
|
||||
},
|
||||
saveCreds: () => {
|
||||
return this.lock.acquire('creds', async () => {
|
||||
await this.repository.saveCreds(this.creds);
|
||||
});
|
||||
},
|
||||
close: async () => {
|
||||
return await this.knex.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
isMyMainSession(id: string) {
|
||||
// Decode the jid
|
||||
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
|
||||
return id == `${meId}.0`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
INowebLidPNRepository,
|
||||
LidToPN,
|
||||
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { NowebLidMapSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { LidPaginator } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LidPNRepository';
|
||||
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLidPNRepository
|
||||
extends NOWEBPsqlKVRepository<LidToPN>
|
||||
implements INowebLidPNRepository
|
||||
{
|
||||
protected Paginator = LidPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebLidMapSchema;
|
||||
}
|
||||
|
||||
saveLids(lids: LidToPN[]): Promise<void> {
|
||||
return this.upsertMany(lids);
|
||||
}
|
||||
|
||||
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
|
||||
return this.getAll(pagination);
|
||||
}
|
||||
|
||||
getLidsCount(): Promise<number> {
|
||||
return this.getCount();
|
||||
}
|
||||
|
||||
async findLidByPN(pn: string): Promise<string | null> {
|
||||
const value = await this.getBy({ pn: pn });
|
||||
return value?.id || null;
|
||||
}
|
||||
|
||||
async findPNByLid(lid: string): Promise<string | null> {
|
||||
const value = await this.getBy({ id: lid });
|
||||
return value?.pn || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
SqlAuthMigrations,
|
||||
SqlAuthSchema,
|
||||
} from '@waha/core/engines/noweb/store/psql/schemas';
|
||||
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
|
||||
import esm from '@waha/vendor/esm';
|
||||
import Knex from 'knex';
|
||||
|
||||
class AuthData {
|
||||
id: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class PsqlNowebAuthRepository extends PsqlKVRepository<AuthData> {
|
||||
constructor(knex: Knex.Knex) {
|
||||
super(knex);
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return SqlAuthSchema;
|
||||
}
|
||||
|
||||
get migrations() {
|
||||
return SqlAuthMigrations;
|
||||
}
|
||||
|
||||
saveCreds(data) {
|
||||
return this.saveCategory('creds', '', data);
|
||||
}
|
||||
|
||||
getCreds() {
|
||||
return this.getCategory('creds', '');
|
||||
}
|
||||
|
||||
private getDbId(category: string, id: string) {
|
||||
return id ? `${category}-${id}` : category;
|
||||
}
|
||||
|
||||
async saveCategory(category: string, id: string, data) {
|
||||
const dbId = this.getDbId(category, id);
|
||||
await this.upsertOne({ id: dbId, data: data });
|
||||
}
|
||||
|
||||
async getCategory(category: string, id: string): Promise<any> {
|
||||
const dbId = this.getDbId(category, id);
|
||||
const data = await this.getById(dbId);
|
||||
return data?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON helpers
|
||||
*/
|
||||
protected stringify(data: any): string {
|
||||
return JSON.stringify(data, esm.b.BufferJSON.replacer);
|
||||
}
|
||||
|
||||
public parse(row: any) {
|
||||
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Chat } from '@adiwajshing/baileys';
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { NowebChatSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlChatMethods } from '@waha/core/engines/noweb/store/sql/SqlChatMethods';
|
||||
import { NOWEBPsqlKVRepository } from '@waha/core/engines/noweb/store/psql/NOWEBPsqlKVRepository';
|
||||
import { OverviewFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
class ChatPaginator extends KnexPaginator {
|
||||
indexes = ['id', 'conversationTimestamp'];
|
||||
}
|
||||
|
||||
export class PsqlChatRepository
|
||||
extends NOWEBPsqlKVRepository<Chat>
|
||||
implements IChatRepository
|
||||
{
|
||||
protected Paginator = ChatPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebChatSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlChatMethods(this);
|
||||
}
|
||||
|
||||
getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
filter?: OverviewFilter,
|
||||
merge?: boolean,
|
||||
): Promise<Chat[]> {
|
||||
return this.methods.getAllWithMessages(
|
||||
pagination,
|
||||
broadcast,
|
||||
filter,
|
||||
merge,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Contact } from '@adiwajshing/baileys';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
import { NowebContactSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
class ContactPaginator extends KnexPaginator {
|
||||
indexes = ['id'];
|
||||
}
|
||||
|
||||
export class PsqlContactRepository
|
||||
extends NOWEBPsqlKVRepository<Contact>
|
||||
implements IContactRepository
|
||||
{
|
||||
protected Paginator = ContactPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebContactSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { NowebGroupsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
class Paginator extends KnexPaginator {
|
||||
indexes = ['id'];
|
||||
}
|
||||
|
||||
export class PsqlGroupRepository
|
||||
extends NOWEBPsqlKVRepository<GroupMetadata>
|
||||
implements IGroupRepository
|
||||
{
|
||||
protected Paginator = Paginator;
|
||||
|
||||
get schema() {
|
||||
return NowebGroupsSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
LabelAssociation,
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { NowebLabelAssociationsMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebLabelAssociationsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlLabelAssociationsMethods } from '@waha/core/engines/noweb/store/sql/SqlLabelAssociationsMethods';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLabelAssociationsRepository
|
||||
extends NOWEBPsqlKVRepository<LabelAssociation>
|
||||
implements ILabelAssociationRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelAssociationsSchema;
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebLabelAssociationsMetadata;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlLabelAssociationsMethods(this);
|
||||
}
|
||||
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
return this.methods.deleteOne(association);
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
return this.methods.deleteByLabelId(labelId);
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.methods.getAssociationsByLabelId(labelId, type);
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.methods.getAssociationsByChatId(chatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { NowebLabelsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLabelsRepository
|
||||
extends NOWEBPsqlKVRepository<Label>
|
||||
implements ILabelsRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelsSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
|
||||
import { NowebMessagesMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebMessagesSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlMessagesMethods } from '@waha/core/engines/noweb/store/sql/SqlMessagesMethods';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
export class PsqlMessagesRepository
|
||||
extends NOWEBPsqlKVRepository<any>
|
||||
implements IMessagesRepository
|
||||
{
|
||||
constructor(
|
||||
knex: Knex.Knex,
|
||||
private readonly lidRepository: INowebLidPNRepository,
|
||||
) {
|
||||
super(knex);
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return NowebMessagesSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlMessagesMethods(this, this.lidRepository);
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebMessagesMetadata;
|
||||
}
|
||||
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.methods.upsert(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
jid: string,
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
merge?: boolean,
|
||||
): Promise<any[]> {
|
||||
return this.methods.getAllByJid(jid, filter, pagination, merge);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
|
||||
return this.methods.getByJidById(jid, id, merge);
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
jid: string,
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
return this.methods.updateByJidAndId(jid, id, update);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
return this.methods.deleteByJidByIds(jid, ids);
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.methods.deleteAllByJid(jid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
|
||||
import { Migrations } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { PsqlChatRepository } from '@waha/core/engines/noweb/store/psql/PsqlChatRepository';
|
||||
import { PsqlContactRepository } from '@waha/core/engines/noweb/store/psql/PsqlContactRepository';
|
||||
import { PsqlGroupRepository } from '@waha/core/engines/noweb/store/psql/PsqlGroupRepository';
|
||||
import { PsqlLidPNRepository } from '@waha/core/engines/noweb/store/psql/PsqLidPNRepository';
|
||||
import { PsqlLabelAssociationsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelAssociationsRepository';
|
||||
import { PsqlLabelsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelsRepository';
|
||||
import { PsqlMessagesRepository } from '@waha/core/engines/noweb/store/psql/PsqlMessagesRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
export class PsqlStorage extends INowebStorage {
|
||||
private lidRepository: INowebLidPNRepository | null = null;
|
||||
|
||||
constructor(private knex: Knex.Knex) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.migrate();
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
return this.migration0001init();
|
||||
}
|
||||
|
||||
private async migration0001init() {
|
||||
for (const migration of Migrations) {
|
||||
await this.knex.raw(migration);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
return this.knex.destroy();
|
||||
}
|
||||
|
||||
getContactsRepository(): IContactRepository {
|
||||
return new PsqlContactRepository(this.knex);
|
||||
}
|
||||
|
||||
getChatRepository(): IChatRepository {
|
||||
return new PsqlChatRepository(this.knex);
|
||||
}
|
||||
|
||||
getGroupRepository(): IGroupRepository {
|
||||
return new PsqlGroupRepository(this.knex);
|
||||
}
|
||||
|
||||
getMessagesRepository(): IMessagesRepository {
|
||||
return new PsqlMessagesRepository(this.knex, this.getLidRepository());
|
||||
}
|
||||
|
||||
getLabelsRepository(): ILabelsRepository {
|
||||
return new PsqlLabelsRepository(this.knex);
|
||||
}
|
||||
|
||||
getLabelAssociationRepository(): ILabelAssociationRepository {
|
||||
return new PsqlLabelAssociationsRepository(this.knex);
|
||||
}
|
||||
|
||||
getLidPNRepository(): INowebLidPNRepository {
|
||||
return this.getLidRepository();
|
||||
}
|
||||
|
||||
private getLidRepository(): INowebLidPNRepository {
|
||||
if (!this.lidRepository) {
|
||||
this.lidRepository = new PsqlLidPNRepository(this.knex);
|
||||
}
|
||||
return this.lidRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Field, Index, Schema } from '@waha/core/storage/Schema';
|
||||
import { Migration } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
|
||||
export const SqlAuthSchema = new Schema(
|
||||
'auth',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('auth_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const SqlAuthMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS auth (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS auth_id_index ON auth (id)',
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
import * as path from 'path';
|
||||
import pino, { Logger } from 'pino';
|
||||
import { AuthStrategy, Client, Events, Store } from 'whatsapp-web.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
|
||||
/* Require Optional Dependencies */
|
||||
let fs;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
fs = require('fs-extra');
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
fs = undefined;
|
||||
}
|
||||
|
||||
async function isValidPath(path: string) {
|
||||
try {
|
||||
await fs.promises.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesizeInBytes(filename: string) {
|
||||
const stats = await fs.promises.lstat(filename).catch(() => null);
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
export interface Zipper {
|
||||
compress(path: string, archivePath: string): Promise<void>;
|
||||
|
||||
uncompress(archivePath: string, path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote-based authentication
|
||||
* @param {object} options - options
|
||||
* @param {object} options.store - Remote database store instance
|
||||
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
|
||||
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
|
||||
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
|
||||
*/
|
||||
export class RemoteAuth implements AuthStrategy {
|
||||
// Required Files & Dirs in WWebJS to restore session
|
||||
private REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
|
||||
// Initial delay sync required for session to be stable enough to recover
|
||||
private INITIAL_DELAY_MS = 60000;
|
||||
|
||||
private readonly clientId: string;
|
||||
private readonly dataPath: string;
|
||||
private readonly tempDir: string;
|
||||
private readonly store: Store;
|
||||
private readonly logger: Logger;
|
||||
|
||||
private client: any;
|
||||
private userDataDir: string;
|
||||
private sessionName: string;
|
||||
private backupSyncRunner: SinglePeriodicJobRunner;
|
||||
private zipper: Zipper;
|
||||
|
||||
constructor(
|
||||
{ clientId, dataPath, store, backupSyncIntervalMs, logger, zipper } = {
|
||||
clientId: 'default',
|
||||
dataPath: undefined,
|
||||
store: null,
|
||||
backupSyncIntervalMs: 60000,
|
||||
zipper: undefined,
|
||||
logger: undefined,
|
||||
},
|
||||
) {
|
||||
if (!fs)
|
||||
throw new Error(
|
||||
'Optional Dependencies [fs-extra] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
|
||||
);
|
||||
|
||||
const idRegex = /^[-_\w]+$/i;
|
||||
if (clientId && !idRegex.test(clientId)) {
|
||||
throw new Error(
|
||||
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
|
||||
);
|
||||
}
|
||||
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
|
||||
throw new Error(
|
||||
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
|
||||
);
|
||||
}
|
||||
if (!store) throw new Error('Remote database store is required.');
|
||||
|
||||
this.store = store;
|
||||
this.clientId = clientId;
|
||||
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
|
||||
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
|
||||
this.zipper = zipper;
|
||||
this.logger = logger || pino({ name: RemoteAuth.name });
|
||||
this.backupSyncRunner = new SinglePeriodicJobRunner(
|
||||
'RemoteAuth Backup Sync',
|
||||
backupSyncIntervalMs,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
get compressedSessionPath() {
|
||||
return `${this.sessionName}.zip`;
|
||||
}
|
||||
|
||||
setup(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async afterBrowserInitialized() {
|
||||
return;
|
||||
}
|
||||
|
||||
async onAuthenticationNeeded() {
|
||||
return {
|
||||
failed: false,
|
||||
restart: false,
|
||||
failureEventPayload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getAuthEventPayload() {
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeBrowserInitialized() {
|
||||
const puppeteerOpts = this.client.options.puppeteer;
|
||||
const sessionDirName = this.clientId
|
||||
? `RemoteAuth-${this.clientId}`
|
||||
: 'RemoteAuth';
|
||||
const dirPath = path.join(this.dataPath, sessionDirName);
|
||||
|
||||
if (puppeteerOpts.userDataDir && puppeteerOpts.userDataDir !== dirPath) {
|
||||
throw new Error(
|
||||
'RemoteAuth is not compatible with a user-supplied userDataDir.',
|
||||
);
|
||||
}
|
||||
|
||||
this.userDataDir = dirPath;
|
||||
this.logger.debug(`User data dir: ${this.userDataDir}`);
|
||||
this.sessionName = sessionDirName;
|
||||
this.logger.debug(`Session name: ${this.sessionName}`);
|
||||
|
||||
await this.extractRemoteSession();
|
||||
await this.removeSingletonFiles(dirPath);
|
||||
|
||||
this.client.options.puppeteer = {
|
||||
...puppeteerOpts,
|
||||
userDataDir: dirPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find in direction Singleton* files and try to remove it
|
||||
* Fix for SingletonLock and other files
|
||||
*/
|
||||
private async removeSingletonFiles(dir: string) {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('Singleton')) {
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
await fs.promises.rm(filePath, {
|
||||
maxRetries: 4,
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Error deleting: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.disconnect();
|
||||
await this.destroy();
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.backupSyncRunner.stop();
|
||||
// @ts-ignore
|
||||
if (this.store.close) {
|
||||
// @ts-ignore
|
||||
await this.store.close();
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.deleteRemoteSession();
|
||||
await this.deleteLocalSession();
|
||||
}
|
||||
|
||||
async afterAuthReady() {
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (!sessionExists) {
|
||||
/* Initial delay sync required for session to be stable enough to recover */
|
||||
await sleep(this.INITIAL_DELAY_MS);
|
||||
await this.storeRemoteSession();
|
||||
this.client.emit(Events.REMOTE_SESSION_SAVED);
|
||||
}
|
||||
|
||||
this.backupSyncRunner.start(async () => {
|
||||
await this.storeRemoteSession();
|
||||
});
|
||||
}
|
||||
|
||||
async storeRemoteSession() {
|
||||
const pathExists = await isValidPath(this.userDataDir);
|
||||
if (!pathExists) {
|
||||
this.logger.warn(
|
||||
'User data dir does not exist. Skipping session backup.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.compressSession();
|
||||
await this.store.save({ session: this.sessionName });
|
||||
await this.removePathSilently(this.compressedSessionPath);
|
||||
await this.removePathSilently(this.tempDir);
|
||||
}
|
||||
|
||||
async extractRemoteSession() {
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (!sessionExists) {
|
||||
fs.mkdirSync(this.userDataDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.store.extract({
|
||||
session: this.sessionName,
|
||||
path: this.compressedSessionPath,
|
||||
});
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
await this.unCompressSession();
|
||||
await this.removePathSilently(this.compressedSessionPath);
|
||||
}
|
||||
|
||||
private async deleteRemoteSession() {
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (sessionExists) await this.store.delete({ session: this.sessionName });
|
||||
}
|
||||
|
||||
private async deleteLocalSession() {
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
}
|
||||
|
||||
async compressSession() {
|
||||
// Chrome's Storage Partitioning creates symlinks with numeric names inside the
|
||||
// profile. fs-extra's copy resolves them and throws "subdirectory of itself"
|
||||
// when it detects a cycle. We skip symlinks entirely — session-critical data
|
||||
// (IndexedDB, Local Storage, cookies) lives in regular files only.
|
||||
const skipSymlinks = async (src: string) => {
|
||||
try {
|
||||
const lstat = await fs.promises.lstat(src);
|
||||
return !lstat.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
await fs.copy(this.userDataDir, this.tempDir, { filter: skipSymlinks });
|
||||
await this.deleteMetadata();
|
||||
|
||||
this.logger.debug('Compressing session...');
|
||||
await this.zipper.compress(this.tempDir, this.compressedSessionPath);
|
||||
this.logger.debug('Session compressed.');
|
||||
|
||||
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
|
||||
this.logger.debug(`Session archive size: ${prettyBytes(zipSize)}`);
|
||||
}
|
||||
|
||||
async unCompressSession() {
|
||||
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
|
||||
this.logger.debug(`Restored Session archive size: ${prettyBytes(zipSize)}`);
|
||||
|
||||
this.logger.debug('Uncompressing session...');
|
||||
await this.zipper.uncompress(this.compressedSessionPath, this.userDataDir);
|
||||
this.logger.debug('Session uncompressed.');
|
||||
}
|
||||
|
||||
async deleteMetadata() {
|
||||
const sessionDirs = [this.tempDir, path.join(this.tempDir, 'Default')];
|
||||
for (const dir of sessionDirs) {
|
||||
const sessionFiles = await fs.promises.readdir(dir);
|
||||
for (const element of sessionFiles) {
|
||||
if (this.REQUIRED_DIRS.includes(element)) {
|
||||
continue;
|
||||
}
|
||||
const dirElement = path.join(dir, element);
|
||||
await this.removePathSilently(dirElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removePathSilently(path: string) {
|
||||
const exists = await isValidPath(path);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(path, {
|
||||
maxRetries: 4,
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Error deleting: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
|
||||
|
||||
let AdmZip;
|
||||
let archiver;
|
||||
import * as fs from 'fs';
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AdmZip = require('adm-zip');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
archiver = require('archiver');
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AdmZip = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
archiver = undefined;
|
||||
}
|
||||
|
||||
export class StreamZipper implements Zipper {
|
||||
constructor() {
|
||||
if (!archiver && !AdmZip)
|
||||
throw new Error(
|
||||
'Optional Dependencies [archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
|
||||
);
|
||||
}
|
||||
|
||||
compress(path: string, archivePath: string): Promise<void> {
|
||||
const archive = archiver('zip');
|
||||
const stream = fs.createWriteStream(archivePath);
|
||||
return new Promise((resolve, reject) => {
|
||||
archive
|
||||
.directory(path, false)
|
||||
.on('error', (err) => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
stream.on('close', () => resolve(null));
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
async uncompress(archivePath: string, path: string): Promise<void> {
|
||||
const zip = new AdmZip(archivePath);
|
||||
await new Promise((resolve, reject) => {
|
||||
zip.extractAllToAsync(path, false, false, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { RemoteAuth, Zipper } from '@waha/core/engines/webjs/RemoteAuth';
|
||||
import { StreamZipper } from '@waha/core/engines/webjs/StreamZipper';
|
||||
import { WebJSMongoAuth } from '@waha/core/engines/webjs/WebJSMongoAuth';
|
||||
import { ZipUnzipZipper } from '@waha/core/engines/webjs/ZipUnzipZipper';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { LoggerBuilder } from '@waha/utils/logging';
|
||||
import { Logger } from 'pino';
|
||||
import { AuthStrategy } from 'whatsapp-web.js';
|
||||
|
||||
import { WebJSPsqlAuth } from './WebJSPsqlAuth';
|
||||
|
||||
export class WebJSAuthFactory {
|
||||
async buildAuth(
|
||||
store: DataStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
): Promise<AuthStrategy> {
|
||||
if (store instanceof MongoStore)
|
||||
return this.buildMongoAuth(store, name, loggerBuilder);
|
||||
if (store instanceof PsqlStore)
|
||||
return await this.buildPsql(store, name, loggerBuilder);
|
||||
if (store instanceof LocalStore)
|
||||
return this.buildLocalAuth(store, name, loggerBuilder);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
buildLocalAuth(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: LocalAuth.name });
|
||||
return new LocalAuth({
|
||||
clientId: name,
|
||||
dataPath: store.getSessionDirectory(name),
|
||||
logger: logger,
|
||||
rmMaxRetries: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async buildPsql(
|
||||
store: PsqlStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: WebJSPsqlAuth.name });
|
||||
const knex = store.buildSessionKnex(name, 'Session/Auth');
|
||||
const authStore = new WebJSPsqlAuth(knex, logger);
|
||||
await authStore.init();
|
||||
const zipper = this.getAvailableZipper(logger);
|
||||
return new RemoteAuth({
|
||||
backupSyncIntervalMs: 60 * 1000,
|
||||
clientId: name,
|
||||
dataPath: null,
|
||||
logger: loggerBuilder.child({ name: RemoteAuth.name }),
|
||||
store: authStore,
|
||||
zipper: zipper,
|
||||
});
|
||||
}
|
||||
|
||||
private buildMongoAuth(
|
||||
store: MongoStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: WebJSMongoAuth.name });
|
||||
const authStore = new WebJSMongoAuth(name, store, logger);
|
||||
const zipper = this.getAvailableZipper(logger);
|
||||
return new RemoteAuth({
|
||||
backupSyncIntervalMs: 60 * 1000,
|
||||
clientId: name,
|
||||
dataPath: null,
|
||||
logger: loggerBuilder.child({ name: RemoteAuth.name }),
|
||||
store: authStore,
|
||||
zipper: zipper,
|
||||
});
|
||||
}
|
||||
|
||||
private getAvailableZipper(logger: Logger): Zipper {
|
||||
if (process.env.WAHA_ZIPPER == 'ZIPUNZIP') {
|
||||
logger.debug('Using ZipUnzipZipper');
|
||||
return new ZipUnzipZipper();
|
||||
}
|
||||
logger.debug('Using StreamZipper');
|
||||
return new StreamZipper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { sleep } from '@nestjs/terminus/dist/utils';
|
||||
import * as fs from 'fs';
|
||||
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
|
||||
import { Logger } from 'pino';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Store } from 'whatsapp-web.js';
|
||||
|
||||
import { MongoStore } from '../../storage/mongo/MongoStore';
|
||||
|
||||
class WebJSMongoAuth implements Store {
|
||||
private store: MongoStore;
|
||||
private logger: Logger;
|
||||
private sessionName: string;
|
||||
private db: Db;
|
||||
private bucket: GridFSBucket;
|
||||
|
||||
constructor(sessionName: string, store: MongoStore, logger: Logger) {
|
||||
this.store = store;
|
||||
this.logger = logger;
|
||||
this.sessionName = sessionName;
|
||||
this.db = this.store.getSessionDb(sessionName);
|
||||
const bucketName = this.getBucketName();
|
||||
this.bucket = new GridFSBucket(this.db, {
|
||||
bucketName: bucketName,
|
||||
});
|
||||
}
|
||||
|
||||
async sessionExists(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.info('Checking if session exists...');
|
||||
const filesCollection = this.getFilesCollectionName();
|
||||
const multiDeviceCollection = this.db.collection(filesCollection);
|
||||
const hasExistingSession = await multiDeviceCollection.countDocuments();
|
||||
const result = !!hasExistingSession;
|
||||
this.logger.info(`Session exists: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async save(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.debug('Saving session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const readStream = fs.createReadStream(filename);
|
||||
const uploadStream = this.bucket.openUploadStream(filename);
|
||||
await pipeline(readStream, uploadStream);
|
||||
this.logger.debug('Session saved.');
|
||||
await this.#deletePrevious(options, this.bucket);
|
||||
}
|
||||
|
||||
async extract(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.info('Extracting existing session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const downloadStream = this.bucket.openDownloadStreamByName(filename);
|
||||
const writeStream = fs.createWriteStream(options.path);
|
||||
await pipeline(downloadStream, writeStream);
|
||||
// Wait a second before giving the zip file to next phase
|
||||
await sleep(1_000);
|
||||
this.logger.info('Session has been extracted.');
|
||||
}
|
||||
|
||||
async delete(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.debug('Deleting session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const documents = await this.bucket
|
||||
.find({
|
||||
filename: filename,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
documents.map(async (doc) => {
|
||||
return this.bucket.delete(doc._id);
|
||||
});
|
||||
this.logger.debug('Session deleted.');
|
||||
}
|
||||
|
||||
async #deletePrevious(options, bucket) {
|
||||
const filename = this.getAuthFileName(options);
|
||||
const documents = await bucket
|
||||
.find({
|
||||
filename: filename,
|
||||
})
|
||||
.toArray();
|
||||
if (documents.length > 1) {
|
||||
this.logger.debug('Deleting old auth files...');
|
||||
// Got all, but not the last one
|
||||
const oldDocuments = documents.slice();
|
||||
// Sort by uploadDate, desc
|
||||
oldDocuments.sort((a, b) => {
|
||||
return a.uploadDate - b.uploadDate;
|
||||
});
|
||||
const keepDocument = oldDocuments.pop();
|
||||
this.logger.debug(
|
||||
`Keeping document - '${keepDocument.uploadDate}', '${keepDocument._id}'`,
|
||||
);
|
||||
oldDocuments.map((document: GridFSFile) => {
|
||||
this.logger.debug(
|
||||
`Deleting document - '${document.uploadDate}', '${document._id}'`,
|
||||
);
|
||||
return bucket.delete(document._id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session name
|
||||
*/
|
||||
private getSessionName(options): string {
|
||||
const prefix = 'RemoteAuth';
|
||||
if (!options.session || options.session === prefix) {
|
||||
return 'default';
|
||||
}
|
||||
// Remote prefix
|
||||
return options.session.replace(`${prefix}-`, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Even tho we accept any "session" in options,
|
||||
* but the store hold a single session database link (bucket)
|
||||
* This is why we need to check if the session name
|
||||
* is the same as the store session name
|
||||
*/
|
||||
private checkSessionName(options) {
|
||||
const session = this.getSessionName(options);
|
||||
if (session !== this.sessionName) {
|
||||
throw new Error(
|
||||
`Session name '${session}' does not match the store session name '${this.sessionName}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getBucketName(): string {
|
||||
return `auth`;
|
||||
}
|
||||
|
||||
private getFilesCollectionName(): string {
|
||||
const bucket = this.getBucketName();
|
||||
return `${bucket}.files`;
|
||||
}
|
||||
|
||||
private getAuthFileName(options): string {
|
||||
return `${options.session}.zip`;
|
||||
}
|
||||
}
|
||||
|
||||
export { WebJSMongoAuth };
|
||||
@@ -0,0 +1,75 @@
|
||||
import { sleep } from '@nestjs/terminus/dist/utils';
|
||||
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'pino';
|
||||
import { Store } from 'whatsapp-web.js';
|
||||
|
||||
interface Options {
|
||||
session: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
class WebjsFileRepository extends PsqlFileRepository {
|
||||
get tableName() {
|
||||
return 'files';
|
||||
}
|
||||
}
|
||||
|
||||
export class WebJSPsqlAuth implements Store {
|
||||
private repository: PsqlFileRepository;
|
||||
|
||||
constructor(
|
||||
private knex: Knex.Knex,
|
||||
private logger: Logger,
|
||||
) {
|
||||
this.repository = new WebjsFileRepository(knex, logger);
|
||||
}
|
||||
|
||||
async sessionExists(options: Options): Promise<boolean> {
|
||||
this.logger.info('Checking if session exists...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const exists = await this.repository.exists(filename);
|
||||
this.logger.info(`Session exists: ${exists}`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
async delete(options: Options): Promise<any> {
|
||||
this.logger.debug('Deleting session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
await this.repository.delete(filename);
|
||||
this.logger.debug('Session deleted.');
|
||||
}
|
||||
|
||||
async save(options: Options): Promise<any> {
|
||||
this.logger.debug('Saving session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
await this.repository.saveFromFile(filename, filename);
|
||||
this.logger.debug('Session saved.');
|
||||
}
|
||||
|
||||
async extract(options: Options) {
|
||||
this.logger.debug('Extracting existing session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const found = await this.repository.fetchToFile(filename, options.path);
|
||||
if (!found) {
|
||||
this.logger.warn('Session does not exist.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a second before giving the zip file to next phase
|
||||
await sleep(1_000);
|
||||
this.logger.info('Session has been extracted.');
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
private getAuthFileName(options: Options): string {
|
||||
return `${options.session}.zip`;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.knex.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebJSPresence } from '@waha/core/engines/webjs/types';
|
||||
import { GetSerialized } from '@waha/core/utils/serialized';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { Label } from '@waha/structures/labels.dto';
|
||||
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
|
||||
@@ -7,15 +8,14 @@ import { TextStatus } from '@waha/structures/status.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as lodash from 'lodash';
|
||||
import { Logger } from 'pino';
|
||||
import { Page } from 'puppeteer';
|
||||
import { Client, Events } from 'whatsapp-web.js';
|
||||
import { Client, Events, Message as WebjsMessage } from 'whatsapp-web.js';
|
||||
import { Message } from 'whatsapp-web.js/src/structures';
|
||||
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
|
||||
|
||||
import { CallErrorEvent, PAGE_CALL_ERROR_EVENT, WPage } from './WPage';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { LoadWAHA } = require('./_WAHA.js');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { LoadLodash } = require('./_lodash.js');
|
||||
|
||||
@@ -30,24 +30,95 @@ const {
|
||||
exposeFunctionIfAbsent,
|
||||
} = require('whatsapp-web.js/src/util/Puppeteer');
|
||||
|
||||
export interface WebjsChannelMessage {
|
||||
message: WebjsMessage;
|
||||
reactions: ChannelMessageReaction[];
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
class ChannelMessageReaction {
|
||||
reaction: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface _Id {
|
||||
id: string;
|
||||
fromMe: boolean;
|
||||
remote: string;
|
||||
_serialized: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "parentMsgKey": {
|
||||
* "fromMe": false,
|
||||
* "remote": "111111111111111111@newsletter",
|
||||
* "id": "AAAAAAAAAAAAAAAAAAAA",
|
||||
* "_serialized": "false_111111111111111111@newsletter_AAAAAAAAAAAAAAAAAAAA"
|
||||
* },
|
||||
* "serverTimestamp": 1738536731,
|
||||
* "emojiCountMap": {emoji=>count}
|
||||
*/
|
||||
interface _NewsletterReaction {
|
||||
parentMsgKey: _Id;
|
||||
serverTimestamp: number;
|
||||
emojiCountMap: any;
|
||||
}
|
||||
|
||||
interface _GetNewsletterPreviewDataResponse {
|
||||
ids: any[];
|
||||
newsletterMetadata: any;
|
||||
newsletterMessages: any[];
|
||||
newsletterReactions: _NewsletterReaction[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function extractReactionsByMessageKey(
|
||||
newsletterReactions: _NewsletterReaction[],
|
||||
): Map<string, ChannelMessageReaction[]> {
|
||||
const reactions = new Map();
|
||||
for (const reaction of newsletterReactions) {
|
||||
const key = GetSerialized(reaction.parentMsgKey);
|
||||
const emojiCountMap = reaction.emojiCountMap;
|
||||
const reactionList: ChannelMessageReaction[] = [];
|
||||
for (const emoji in emojiCountMap) {
|
||||
reactionList.push({
|
||||
reaction: emoji,
|
||||
count: emojiCountMap[emoji],
|
||||
});
|
||||
}
|
||||
reactions.set(key, reactionList);
|
||||
}
|
||||
return reactions;
|
||||
}
|
||||
|
||||
export class WebjsClientCore extends Client {
|
||||
public events = new EventEmitter();
|
||||
private wpage: WPage = null;
|
||||
private injecting: Promise<void> = null;
|
||||
|
||||
constructor(
|
||||
options,
|
||||
protected tags: boolean,
|
||||
protected logger: Logger,
|
||||
) {
|
||||
super(options);
|
||||
// Wait until it's READY and inject more utils
|
||||
this.on(Events.AUTHENTICATED, async () => {
|
||||
await this.attachCustomEventListeners();
|
||||
await this.injectWaha();
|
||||
});
|
||||
this.on(Events.READY, async () => {
|
||||
await this.attachCustomEventListeners();
|
||||
await this.injectWaha();
|
||||
});
|
||||
// AUTHENTICATED and READY fire back to back - run one injection for both
|
||||
this.on(Events.AUTHENTICATED, () => this.injectUtils());
|
||||
this.on(Events.READY, () => this.injectUtils());
|
||||
}
|
||||
|
||||
private injectUtils(): Promise<void> {
|
||||
if (this.injecting) {
|
||||
return this.injecting;
|
||||
}
|
||||
this.injecting = this.attachCustomEventListeners()
|
||||
.then(() => this.injectWaha())
|
||||
.catch((err) => this.logger.error(err, 'Failed to inject utils'))
|
||||
.finally(() => {
|
||||
this.injecting = null;
|
||||
});
|
||||
return this.injecting;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -65,7 +136,6 @@ export class WebjsClientCore extends Client {
|
||||
async injectWaha() {
|
||||
await this.pupPage.evaluate(LoadLodash);
|
||||
await this.pupPage.evaluate(LoadPaginator);
|
||||
await this.pupPage.evaluate(LoadWAHA);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +158,37 @@ export class WebjsClientCore extends Client {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @result indicating whether the "What's New" auto-modal was prevented or dismissed.
|
||||
*/
|
||||
hideWhatsNewModal(): Promise<boolean> {
|
||||
return this.pupPage.evaluate(() => {
|
||||
// Module registry is not available until the app bundle has loaded
|
||||
if (typeof window.require !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const WAWebWhatsNewNux = window.require('WAWebWhatsNewNux');
|
||||
if (!WAWebWhatsNewNux) {
|
||||
return false;
|
||||
}
|
||||
// user prefs are not writable before login
|
||||
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
|
||||
if (!WAWebUserPrefsMeUser.getMaybeMePnUser()) {
|
||||
return false;
|
||||
}
|
||||
const nux = WAWebWhatsNewNux.createWhatsNewNux();
|
||||
if (!nux.shouldShow()) {
|
||||
return false;
|
||||
}
|
||||
// Bump the dismiss count and start the cool-off, so the app never auto-opens the modal
|
||||
nux.dismiss();
|
||||
// If the modal has already opened - close the topmost modal (no-op when nothing is open)
|
||||
const WAWebModalManager = window.require('WAWebModalManager');
|
||||
WAWebModalManager.ModalManager.close();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async attachCustomEventListeners() {
|
||||
await exposeFunctionIfAbsent(
|
||||
this.pupPage,
|
||||
@@ -110,10 +211,10 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
|
||||
const tags = ['receipt', 'presence', 'chatstate'];
|
||||
const WAWap = window.require('WAWap');
|
||||
// @ts-ignore
|
||||
window.decodeStanzaBack = window.Store.SocketWap.decodeStanza;
|
||||
// @ts-ignore
|
||||
window.Store.SocketWap.decodeStanza = async (...args) => {
|
||||
window.decodeStanzaBack = WAWap.decodeStanza;
|
||||
WAWap.decodeStanza = async (...args) => {
|
||||
// @ts-ignore
|
||||
const result = await window.decodeStanzaBack(...args);
|
||||
if (tags.includes(result?.tag)) {
|
||||
@@ -134,9 +235,9 @@ export class WebjsClientCore extends Client {
|
||||
async setPushName(name: string) {
|
||||
await this.ensureWahaInjected();
|
||||
await this.pupPage.evaluate(async (pushName) => {
|
||||
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
|
||||
pushName,
|
||||
);
|
||||
return await window
|
||||
.require('WAWebSetPushnameConnAction')
|
||||
.setPushname(pushName);
|
||||
}, name);
|
||||
if (this.info) {
|
||||
this.info.pushname = name;
|
||||
@@ -145,16 +246,9 @@ export class WebjsClientCore extends Client {
|
||||
|
||||
async unpair() {
|
||||
await this.pupPage.evaluate(async () => {
|
||||
if (
|
||||
// @ts-ignore
|
||||
window.Store &&
|
||||
// @ts-ignore
|
||||
window.Store.AppState &&
|
||||
// @ts-ignore
|
||||
typeof window.Store.AppState.logout === 'function'
|
||||
) {
|
||||
// @ts-ignore
|
||||
await window.Store.AppState.logout();
|
||||
const Socket = window.require('WAWebSocketModel')?.Socket;
|
||||
if (Socket && typeof Socket.logout === 'function') {
|
||||
await Socket.logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -163,11 +257,9 @@ export class WebjsClientCore extends Client {
|
||||
await this.ensureWahaInjected();
|
||||
const labelId: number = (await this.pupPage.evaluate(
|
||||
async (name, color) => {
|
||||
// @ts-ignore
|
||||
return await window.WAHA.WAWebBizLabelEditingAction.labelAddAction(
|
||||
name,
|
||||
color,
|
||||
);
|
||||
return await window
|
||||
.require('WAWebBizLabelEditingAction')
|
||||
.labelAddAction(name, color);
|
||||
},
|
||||
name,
|
||||
color,
|
||||
@@ -178,20 +270,16 @@ export class WebjsClientCore extends Client {
|
||||
async deleteLabel(label: Label) {
|
||||
await this.ensureWahaInjected();
|
||||
return await this.pupPage.evaluate(async (label) => {
|
||||
// @ts-ignore
|
||||
return await window.WAHA.WAWebBizLabelEditingAction.labelDeleteAction(
|
||||
label.id,
|
||||
label.name,
|
||||
label.color,
|
||||
);
|
||||
return await window
|
||||
.require('WAWebBizLabelEditingAction')
|
||||
.labelDeleteAction(label.id, label.name, label.color);
|
||||
}, label);
|
||||
}
|
||||
|
||||
async updateLabel(label: Label) {
|
||||
await this.ensureWahaInjected();
|
||||
return await this.pupPage.evaluate(async (label) => {
|
||||
// @ts-ignore
|
||||
return await window.WAHA.WAWebBizLabelEditingAction.labelEditAction(
|
||||
return await window.require('WAWebBizLabelEditingAction').labelEditAction(
|
||||
label.id,
|
||||
label.name,
|
||||
undefined, // predefinedId
|
||||
@@ -213,8 +301,27 @@ export class WebjsClientCore extends Client {
|
||||
|
||||
const chats = await this.pupPage.evaluate(
|
||||
async (pagination, filter) => {
|
||||
let chats = window
|
||||
.require('WAWebCollections')
|
||||
.Chat.getModelsArray()
|
||||
.slice();
|
||||
|
||||
// Filter chats by IDs if filter is provided
|
||||
if (filter && filter.ids && filter.ids.length > 0) {
|
||||
chats = chats.filter((chat) =>
|
||||
// @ts-ignore
|
||||
filter.ids.includes(window.WWebJS.GetSerialized(chat.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return await window.WAHA.getChats(pagination, filter);
|
||||
const paginator = new window.Paginator(pagination);
|
||||
chats = paginator.apply(chats);
|
||||
const chatPromises = chats.map((chat) =>
|
||||
// @ts-ignore
|
||||
window.WWebJS.getChatModel(chat),
|
||||
);
|
||||
return await Promise.all(chatPromises);
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
@@ -226,7 +333,7 @@ export class WebjsClientCore extends Client {
|
||||
protected async ensureWahaInjected() {
|
||||
const hasWaha = await this.pupPage.evaluate(() => {
|
||||
// @ts-ignore
|
||||
return Boolean(window.WAHA && window.WAHA.getChats);
|
||||
return Boolean(window.Paginator);
|
||||
});
|
||||
if (!hasWaha) {
|
||||
await this.injectWaha();
|
||||
@@ -244,14 +351,14 @@ export class WebjsClientCore extends Client {
|
||||
font: status.font,
|
||||
};
|
||||
const sentMsg = await this.pupPage.evaluate(async (status) => {
|
||||
// @ts-ignore
|
||||
await window.Store.SendStatus.sendStatusTextMsgAction(status);
|
||||
// @ts-ignore
|
||||
const meUser = window.Store.User.getMaybeMePnUser();
|
||||
// @ts-ignore
|
||||
const myStatus = window.Store.Status.getModelsArray().findLast(
|
||||
(x) => x.id == meUser,
|
||||
);
|
||||
await window
|
||||
.require('WAWebSendStatusMsgAction')
|
||||
.sendStatusTextMsgAction(status);
|
||||
const meUser = window.require('WAWebUserPrefsMeUser').getMaybeMePnUser();
|
||||
const myStatus = window
|
||||
.require('WAWebCollections')
|
||||
.Status.getModelsArray()
|
||||
.findLast((x) => x.id == meUser);
|
||||
if (!myStatus) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -319,17 +426,20 @@ export class WebjsClientCore extends Client {
|
||||
// Construct the initial anchor the same way wa-js does:
|
||||
// serialize to string then reconstruct via MsgKey.fromString so the
|
||||
// object has the exact shape msgFindByDirection expects.
|
||||
const lastReceivedSerialized = chat.lastReceivedKey?._serialized;
|
||||
if (!lastReceivedSerialized) return [];
|
||||
// @ts-ignore
|
||||
let currentAnchorKey = window.Store.MsgKey.fromString(
|
||||
lastReceivedSerialized,
|
||||
const lastReceivedSerialized = window.WWebJS.GetSerialized(
|
||||
chat.lastReceivedKey,
|
||||
);
|
||||
if (!lastReceivedSerialized) return [];
|
||||
let currentAnchorKey = window
|
||||
.require('WAWebMsgKey')
|
||||
.fromString(lastReceivedSerialized);
|
||||
|
||||
// msgFindByDirection is exclusive of the anchor; include the anchor
|
||||
// message itself (the most recent message in the chat) upfront
|
||||
// @ts-ignore
|
||||
const anchorMsg = window.Store.Msg.get(lastReceivedSerialized);
|
||||
const anchorMsg = window
|
||||
.require('WAWebCollections')
|
||||
.Msg.get(lastReceivedSerialized);
|
||||
if (anchorMsg) {
|
||||
msgs.push(anchorMsg);
|
||||
}
|
||||
@@ -343,14 +453,14 @@ export class WebjsClientCore extends Client {
|
||||
// @ts-ignore
|
||||
const toModel = (m) => {
|
||||
if (m && typeof m.serialize === 'function') return m;
|
||||
const serializedId = m?.id?._serialized;
|
||||
// @ts-ignore
|
||||
const serializedId = window.WWebJS.GetSerialized(m?.id);
|
||||
const Msg = window.require('WAWebCollections').Msg;
|
||||
if (serializedId) {
|
||||
// @ts-ignore
|
||||
const stored = window.Store.Msg.get(serializedId);
|
||||
const stored = Msg.get(serializedId);
|
||||
if (stored) return stored;
|
||||
}
|
||||
// @ts-ignore
|
||||
return new window.Store.Msg.modelClass(m);
|
||||
return new Msg.modelClass(m);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -375,7 +485,8 @@ export class WebjsClientCore extends Client {
|
||||
// appear in multiple batches when anchors overlap
|
||||
const seenIds = new Set();
|
||||
msgs = msgs.filter((m) => {
|
||||
const sid = m?.id?._serialized;
|
||||
// @ts-ignore
|
||||
const sid = window.WWebJS.GetSerialized(m?.id);
|
||||
if (!sid || seenIds.has(sid)) return false;
|
||||
seenIds.add(sid);
|
||||
return true;
|
||||
@@ -401,21 +512,22 @@ export class WebjsClientCore extends Client {
|
||||
// the oldest message in this batch — use it as the next anchor to
|
||||
// walk further back in history without overlap
|
||||
const oldestInBatch = batchModels[batchModels.length - 1];
|
||||
const oldestSerialized = oldestInBatch?.id?._serialized;
|
||||
if (!oldestSerialized) break;
|
||||
// @ts-ignore
|
||||
currentAnchorKey = window.Store.MsgKey.fromString(oldestSerialized);
|
||||
const oldestSerialized = window.WWebJS.GetSerialized(
|
||||
oldestInBatch?.id,
|
||||
);
|
||||
if (!oldestSerialized) break;
|
||||
currentAnchorKey = window
|
||||
.require('WAWebMsgKey')
|
||||
.fromString(oldestSerialized);
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback: loadEarlierMsgs loop
|
||||
msgs = chat.msgs.getModelsArray();
|
||||
while (msgs.length < pagination.limit + pagination.offset) {
|
||||
const loadedMessages =
|
||||
// @ts-ignore
|
||||
await window.Store.ConversationMsgs.loadEarlierMsgs(
|
||||
chat,
|
||||
chat.msgs,
|
||||
);
|
||||
const loadedMessages = await window
|
||||
.require('WAWebChatLoadMessages')
|
||||
.loadEarlierMsgs(chat, chat.msgs);
|
||||
if (!loadedMessages || loadedMessages.length == 0) break;
|
||||
|
||||
msgs = [...loadedMessages, ...msgs];
|
||||
@@ -464,8 +576,7 @@ export class WebjsClientCore extends Client {
|
||||
pagination.offset ||= 0;
|
||||
pagination.sortBy ||= 'lid';
|
||||
|
||||
// @ts-ignore
|
||||
const WAWebApiContact = window.Store.LidUtils;
|
||||
const WAWebApiContact = window.require('WAWebApiContact');
|
||||
|
||||
await WAWebApiContact.warmUpAllLidPnMappings();
|
||||
const lidMap = WAWebApiContact.lidPnCache['$1'];
|
||||
@@ -473,9 +584,9 @@ export class WebjsClientCore extends Client {
|
||||
const result = values.map((map) => {
|
||||
return {
|
||||
// @ts-ignore
|
||||
lid: map.lid._serialized,
|
||||
lid: window.WWebJS.GetSerialized(map.lid),
|
||||
// @ts-ignore
|
||||
pn: map.phoneNumber._serialized,
|
||||
pn: window.WWebJS.GetSerialized(map.phoneNumber),
|
||||
};
|
||||
});
|
||||
// @ts-ignore
|
||||
@@ -490,8 +601,7 @@ export class WebjsClientCore extends Client {
|
||||
|
||||
public async getLidsCount(): Promise<number> {
|
||||
const count: number = (await this.pupPage.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
const WAWebApiContact = window.Store.LidUtils;
|
||||
const WAWebApiContact = window.require('WAWebApiContact');
|
||||
|
||||
await WAWebApiContact.warmUpAllLidPnMappings();
|
||||
const lidMap = WAWebApiContact.lidPnCache['$1'];
|
||||
@@ -502,28 +612,26 @@ export class WebjsClientCore extends Client {
|
||||
|
||||
public async findPNByLid(lid: string): Promise<string> {
|
||||
const pn = await this.pupPage.evaluate(async (lid) => {
|
||||
// @ts-ignore
|
||||
const WAWebApiContact = window.Store.LidUtils;
|
||||
// @ts-ignore
|
||||
const WAWebWidFactory = window.Store.WidFactory;
|
||||
const WAWebApiContact = window.require('WAWebApiContact');
|
||||
const WAWebWidFactory = window.require('WAWebWidFactory');
|
||||
|
||||
const wid = WAWebWidFactory.createWid(lid);
|
||||
const result = WAWebApiContact.getPhoneNumber(wid);
|
||||
return result ? result._serialized : null;
|
||||
// @ts-ignore
|
||||
return window.WWebJS.GetSerialized(result);
|
||||
}, lid);
|
||||
return pn;
|
||||
}
|
||||
|
||||
public async findLIDByPhoneNumber(phoneNumber: string): Promise<string> {
|
||||
const lid: string = (await this.pupPage.evaluate(async (pn) => {
|
||||
// @ts-ignore
|
||||
const WAWebApiContact = window.Store.LidUtils;
|
||||
// @ts-ignore
|
||||
const WAWebWidFactory = window.Store.WidFactory;
|
||||
const WAWebApiContact = window.require('WAWebApiContact');
|
||||
const WAWebWidFactory = window.require('WAWebWidFactory');
|
||||
|
||||
const wid = WAWebWidFactory.createWid(pn);
|
||||
const result = WAWebApiContact.getCurrentLid(wid);
|
||||
return result ? result._serialized : null;
|
||||
// @ts-ignore
|
||||
return window.WWebJS.GetSerialized(result);
|
||||
}, phoneNumber)) as any;
|
||||
return lid;
|
||||
}
|
||||
@@ -563,7 +671,8 @@ export class WebjsClientCore extends Client {
|
||||
}
|
||||
return chatstates.map((chatstate) => {
|
||||
return {
|
||||
participant: chatstate.id._serialized,
|
||||
// @ts-ignore
|
||||
participant: window.WWebJS.GetSerialized(chatstate.id),
|
||||
lastSeen: chatstate.t,
|
||||
state: chatstate.type,
|
||||
};
|
||||
@@ -578,4 +687,80 @@ export class WebjsClientCore extends Client {
|
||||
await sleep(3_000);
|
||||
return await this.getCurrentPresence(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
async channelFetchMessageByInvite(
|
||||
inviteCode: string,
|
||||
limit: number,
|
||||
): Promise<WebjsChannelMessage[]> {
|
||||
const response: _GetNewsletterPreviewDataResponse =
|
||||
await this.pupPage.evaluate(
|
||||
async (code, limit) => {
|
||||
// Overwrite the server-side message count so the preview fetches
|
||||
// exactly `limit` messages
|
||||
window.require(
|
||||
'WAWebNewsletterGatingUtils',
|
||||
).getMaxMsgCountFromServer = () => limit;
|
||||
|
||||
const result = await window
|
||||
.require('WAWebNewsletterPreviewJob')
|
||||
.getNewsletterPreviewData(code, 'guest');
|
||||
for (const newsletterReaction of result.newsletterReactions) {
|
||||
// puppeter doesn't support Map,
|
||||
// so we need to convert it to object
|
||||
newsletterReaction.emojiCountMap = Object.fromEntries(
|
||||
newsletterReaction.emojiCountMap,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch one more time to save in database so we can fetch media later
|
||||
await window
|
||||
.require('WAWebLoadNewsletterPreviewChatAction')
|
||||
.loadNewsletterPreviewChat(code);
|
||||
return result;
|
||||
},
|
||||
inviteCode,
|
||||
limit,
|
||||
);
|
||||
const messageInstances = response.newsletterMessages
|
||||
.filter((msg) => msg.type != 'revoked')
|
||||
.map((msg) => {
|
||||
return new MessageInstance(this, msg);
|
||||
});
|
||||
const reactions = extractReactionsByMessageKey(
|
||||
response.newsletterReactions,
|
||||
);
|
||||
|
||||
const messages: WebjsChannelMessage[] = messageInstances.map((msg) => {
|
||||
return {
|
||||
message: msg,
|
||||
reactions: reactions.get(GetSerialized(msg.id)) || [],
|
||||
viewCount: msg.rawData.viewCount,
|
||||
};
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels Search methods
|
||||
*/
|
||||
async searchChannelsView(params: any): Promise<any> {
|
||||
const newsletters: any = await this.pupPage.evaluate(async (params) => {
|
||||
return await window
|
||||
.require('WAWebNewsletterDirectorySearchJob')
|
||||
.getNewsletterDirectoryList(params);
|
||||
}, params);
|
||||
return newsletters;
|
||||
}
|
||||
|
||||
async searchChannelsText(params: any): Promise<any> {
|
||||
const newsletters: any = await this.pupPage.evaluate(async (params) => {
|
||||
return await window
|
||||
.require('WAWebNewsletterDirectorySearchJob')
|
||||
.getNewsletterDirectorySearchResults(params);
|
||||
}, params);
|
||||
return newsletters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
|
||||
import { execFile as execFileOrigin } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFile = promisify(execFileOrigin);
|
||||
|
||||
export class ZipUnzipZipper implements Zipper {
|
||||
async compress(dirPath: string, archivePath: string): Promise<void> {
|
||||
// resolve archivePath to full path
|
||||
const archiveFullPath = path.resolve(archivePath);
|
||||
const dirFullPath = path.resolve(dirPath);
|
||||
const opts = {
|
||||
cwd: dirFullPath,
|
||||
};
|
||||
await execFile('zip', ['-rq1', archiveFullPath, '.'], opts);
|
||||
return;
|
||||
}
|
||||
|
||||
async uncompress(archivePath: string, dirPath: string): Promise<void> {
|
||||
const archiveFullPath = path.resolve(archivePath);
|
||||
const dirFullPath = path.resolve(dirPath);
|
||||
// upsert path, create if not exist
|
||||
fs.mkdirSync(dirFullPath, { recursive: true });
|
||||
|
||||
await execFile('unzip', ['-q', archiveFullPath, '-d', dirFullPath]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import { parseMessageCapping } from '@waha/core/abc/capping';
|
||||
import {
|
||||
getChannelInviteLink,
|
||||
getPublicUrlFromDirectPath,
|
||||
WhatsappSession,
|
||||
} from '@waha/core/abc/session.abc';
|
||||
import {
|
||||
@@ -18,20 +20,21 @@ import {
|
||||
ToGroupV2ParticipantsEvent,
|
||||
ToGroupV2UpdateEvent,
|
||||
} from '@waha/core/engines/webjs/groups.webjs';
|
||||
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
|
||||
import {
|
||||
TagChatstateToPresence,
|
||||
TagPresenceToPresence,
|
||||
} from '@waha/core/engines/webjs/presence';
|
||||
import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
|
||||
import {
|
||||
WebjsChannelMessage,
|
||||
WebjsClientCore,
|
||||
} from '@waha/core/engines/webjs/WebjsClientCore';
|
||||
import {
|
||||
CallErrorEvent,
|
||||
PAGE_CALL_ERROR_EVENT,
|
||||
} from '@waha/core/engines/webjs/WPage';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { QR } from '@waha/core/QR';
|
||||
@@ -47,6 +50,7 @@ import {
|
||||
Channel,
|
||||
ChannelListResult,
|
||||
ChannelMessage,
|
||||
ChannelPublicInfo,
|
||||
ChannelRole,
|
||||
ChannelSearchByText,
|
||||
ChannelSearchByView,
|
||||
@@ -75,11 +79,12 @@ import {
|
||||
MessageImageRequest,
|
||||
MessageLocationRequest,
|
||||
MessagePollRequest,
|
||||
MessagePollVoteRequest,
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVoiceRequest,
|
||||
MessageVideoRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
@@ -104,6 +109,7 @@ import {
|
||||
GroupParticipant,
|
||||
GroupSortField,
|
||||
ParticipantsRequest,
|
||||
SettingsMemberAddMode,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '@waha/structures/groups.dto';
|
||||
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
|
||||
@@ -121,8 +127,21 @@ import {
|
||||
WAMessageReaction,
|
||||
} from '@waha/structures/responses.dto';
|
||||
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
|
||||
import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import { DeleteStatusRequest, TextStatus } from '@waha/structures/status.dto';
|
||||
import {
|
||||
MeInfo,
|
||||
MessageCappingData,
|
||||
ReachoutTimelockData,
|
||||
ReachoutTimelockEnforcementType,
|
||||
} from '@waha/structures/sessions.dto';
|
||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
PollVote as WAHAPollVote,
|
||||
@@ -132,12 +151,13 @@ import {
|
||||
WAMessageRevokedBody,
|
||||
} from '@waha/structures/webhooks.dto';
|
||||
import { PaginatorInMemory } from '@waha/utils/Paginator';
|
||||
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
import { TmpDir } from '@waha/utils/tmpdir';
|
||||
import * as lodash from 'lodash';
|
||||
import * as path from 'path';
|
||||
import { ProtocolError } from 'puppeteer';
|
||||
import { Browser, ProtocolError } from 'puppeteer';
|
||||
import {
|
||||
filter,
|
||||
fromEvent,
|
||||
@@ -171,8 +191,10 @@ import {
|
||||
Message as MessageInstance,
|
||||
Call as CallInstance,
|
||||
} from 'whatsapp-web.js/src/structures';
|
||||
import { GetSerialized } from '@waha/core/utils/serialized';
|
||||
|
||||
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
|
||||
import { WebJSAuthFactory } from './WebJSAuthFactory';
|
||||
import {
|
||||
isJidCus,
|
||||
isJidGroup,
|
||||
@@ -200,12 +222,17 @@ export interface WebJSConfig {
|
||||
|
||||
export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
private START_ATTEMPT_DELAY_SECONDS = 2;
|
||||
// how long to wait on stop for the browser to close gracefully before force-killing it
|
||||
private DESTROY_TIMEOUT_MS = 10_000;
|
||||
|
||||
authFactory = new WebJSAuthFactory();
|
||||
|
||||
engine = WAHAEngine.WEBJS;
|
||||
protected engineConfig?: WebJSConfig;
|
||||
|
||||
private startDelayedJob: SingleDelayedJobRunner;
|
||||
private engineStateCheckDelayedJob: SingleDelayedJobRunner;
|
||||
private whatsNewModalJob: SinglePeriodicJobRunner;
|
||||
private shouldRestart: boolean;
|
||||
private lastQRDate: Date = null;
|
||||
private static readonly REACTION_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
@@ -230,6 +257,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
2 * SECOND,
|
||||
this.logger,
|
||||
);
|
||||
// Hide the "What's New" modal that blocks app init after QR login
|
||||
this.whatsNewModalJob = new SinglePeriodicJobRunner(
|
||||
'hide-whats-new-modal',
|
||||
SECOND,
|
||||
this.logger,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,16 +318,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async buildClient() {
|
||||
const authStrategy = await this.authFactory.buildAuth(
|
||||
this.sessionStore,
|
||||
this.name,
|
||||
this.loggerBuilder,
|
||||
);
|
||||
const clientOptions = this.getClientOptions();
|
||||
const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
|
||||
clientOptions.authStrategy = new LocalAuth({
|
||||
clientId: this.name,
|
||||
dataPath: `${base}/webjs/default`,
|
||||
logger: this.logger,
|
||||
rmMaxRetries: undefined,
|
||||
});
|
||||
clientOptions.authStrategy = authStrategy;
|
||||
this.addProxyConfig(clientOptions);
|
||||
return new WebjsClientCore(clientOptions, this.getWebjsTagsFlag());
|
||||
return new WebjsClientCore(
|
||||
clientOptions,
|
||||
this.getWebjsTagsFlag(),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
protected getWebjsTagsFlag() {
|
||||
@@ -401,6 +438,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
console.log(`url is ${location.href}`),
|
||||
);
|
||||
}
|
||||
|
||||
this.startWhatsNewModalJob();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(error);
|
||||
@@ -434,11 +473,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
async stop() {
|
||||
this.cleanupPresenceTimeout();
|
||||
this.shouldRestart = false;
|
||||
this.startDelayedJob.cancel();
|
||||
await this.end();
|
||||
this.status = WAHASessionStatus.STOPPED;
|
||||
this.stopEvents();
|
||||
this.startDelayedJob.cancel();
|
||||
this.mediaManager.close();
|
||||
await this.end();
|
||||
}
|
||||
|
||||
protected failed() {
|
||||
@@ -469,6 +508,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
this.cleanupPresenceTimeout();
|
||||
this.presence = null;
|
||||
this.engineStateCheckDelayedJob.cancel();
|
||||
this.whatsNewModalJob.stop();
|
||||
this.whatsapp?.removeAllListeners();
|
||||
this.whatsapp?.pupBrowser?.removeAllListeners();
|
||||
this.whatsapp?.pupPage?.removeAllListeners();
|
||||
@@ -495,10 +535,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.whatsapp?.destroy();
|
||||
await promiseTimeout(this.DESTROY_TIMEOUT_MS, this.whatsapp?.destroy());
|
||||
this.logger.debug('Successfully destroyed whatsapp client');
|
||||
} catch (error) {
|
||||
this.logger.error(error, 'Failed to destroy whatsapp client');
|
||||
const browser = this.whatsapp?.pupBrowser;
|
||||
if (browser) {
|
||||
await this.kill(browser);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -511,6 +555,33 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill the Chromium process when the graceful destroy() failed or timed out.
|
||||
* Throws if the browser can not be proven dead - the session is not fully stopped then.
|
||||
*/
|
||||
private async kill(browser: Browser) {
|
||||
// shadows the Node.js global 'process' on purpose - the global one is not used here
|
||||
const process = browser.process();
|
||||
if (!process) {
|
||||
// remote or attached browser - we have no process handle to kill
|
||||
throw new Error(
|
||||
'No browser process to kill, can not guarantee the browser is closed',
|
||||
);
|
||||
}
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
this.logger.warn('Force killing the browser process');
|
||||
const exited = new Promise<void>((resolve) =>
|
||||
process.once('exit', () => resolve()),
|
||||
);
|
||||
process.kill('SIGKILL');
|
||||
// the session is stopped only when the browser process is really dead - verify it, do not assume
|
||||
await promiseTimeout(5_000, exited).catch(() => {
|
||||
throw new Error('The browser process did not exit after SIGKILL');
|
||||
});
|
||||
}
|
||||
|
||||
getSessionMeInfo(): MeInfo | null {
|
||||
const clientInfo = this.whatsapp?.info;
|
||||
if (!clientInfo) {
|
||||
@@ -518,8 +589,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
const wid = clientInfo.wid;
|
||||
return {
|
||||
id: wid?._serialized,
|
||||
id: GetSerialized(wid),
|
||||
lid: GetSerialized(clientInfo.lid),
|
||||
pushName: clientInfo?.pushname,
|
||||
reachoutTimelock: this.reachoutTimelock.value,
|
||||
messageCapping: this.messageCapping.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,7 +609,54 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
private startWhatsNewModalJob() {
|
||||
// The "What's New" modal can block app init right after QR login, so the session never leaves STARTING
|
||||
// https://github.com/devlikeapro/waha/issues/2217
|
||||
this.whatsNewModalJob.start(async () => {
|
||||
let hidden = false;
|
||||
try {
|
||||
hidden = await this.whatsapp.hideWhatsNewModal();
|
||||
} catch (err) {
|
||||
// The page navigates during login, evaluate() can fail - retry on the next tick
|
||||
this.logger.debug(`Failed to hide "What's New" modal: ${err}`);
|
||||
return;
|
||||
}
|
||||
if (hidden) {
|
||||
this.logger.info(`"What's New" modal has been dismissed`);
|
||||
return;
|
||||
}
|
||||
// The cool-off is stored user-scoped, so keep re-checking until it holds after login
|
||||
if (this.status === WAHASessionStatus.WORKING) {
|
||||
this.whatsNewModalJob.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected listenConnectionEvents() {
|
||||
this.whatsapp.on(Events.REACHOUT_TIMELOCK_UPDATE, (record: any) => {
|
||||
this.updateReachoutTimelockFromRecord(record);
|
||||
});
|
||||
|
||||
this.whatsapp.on(Events.MESSAGE_CAPPING_UPDATE, (record: any) => {
|
||||
// Unlike the timelock, a null record means "no local data yet", not "capping lifted"
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
this.messageCapping.update(parseMessageCapping(record));
|
||||
});
|
||||
|
||||
this.whatsapp.on(Events.READY, async () => {
|
||||
// Ask WhatsApp for the current message capping state, the same fetch the app runs on startup
|
||||
try {
|
||||
const data = await this.whatsapp.fetchMessageCapping();
|
||||
if (data) {
|
||||
this.messageCapping.update(parseMessageCapping(data));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(err, 'Failed to fetch message capping');
|
||||
}
|
||||
});
|
||||
|
||||
this.whatsapp.on(Events.QR_RECEIVED, async (qr) => {
|
||||
this.logger.debug('QR received');
|
||||
// Convert to image and save
|
||||
@@ -651,14 +772,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
|
||||
private async loadClientInfo() {
|
||||
const data = await this.whatsapp.pupPage.evaluate(() => {
|
||||
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
|
||||
return {
|
||||
// @ts-ignore
|
||||
...window.Store.Conn.serialize(),
|
||||
...window.require('WAWebConnModel').Conn.serialize(),
|
||||
wid:
|
||||
// @ts-ignore
|
||||
window.Store.User.getMaybeMePnUser() ||
|
||||
// @ts-ignore
|
||||
window.Store.User.getMaybeMeLidUser(),
|
||||
WAWebUserPrefsMeUser.getMaybeMePnUser() ||
|
||||
WAWebUserPrefsMeUser.getMaybeMeLidUser(),
|
||||
};
|
||||
});
|
||||
this.whatsapp.info = data as any;
|
||||
@@ -727,7 +846,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
return {
|
||||
numberExists: true,
|
||||
chatId: result._serialized,
|
||||
chatId: GetSerialized(result),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -746,12 +865,105 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async fileToMedia(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<MessageMedia> {
|
||||
if ('url' in file) {
|
||||
const mediaOptions = { unsafeMime: true };
|
||||
const media = await MessageMedia.fromUrl(file.url, mediaOptions);
|
||||
media.mimetype = file.mimetype || media.mimetype;
|
||||
media.filename = file.filename || media.filename;
|
||||
return media;
|
||||
}
|
||||
return new MessageMedia(file.mimetype, file.data, file.filename);
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
return await this.whatsapp.setProfilePicture(media);
|
||||
}
|
||||
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
return await this.whatsapp.deleteProfilePicture();
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchMessageCapping(): Promise<MessageCappingData> {
|
||||
const data = await this.whatsapp.fetchMessageCapping();
|
||||
if (!data) {
|
||||
throw new NotImplementedByEngineError(
|
||||
'Message capping is not available in the current WhatsApp Web version',
|
||||
);
|
||||
}
|
||||
const capping = parseMessageCapping(data);
|
||||
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
|
||||
this.messageCapping.update(capping);
|
||||
return capping;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
|
||||
const record = await this.whatsapp.fetchReachoutTimelock();
|
||||
if (record === undefined) {
|
||||
throw new NotImplementedByEngineError(
|
||||
'Reachout timelock is not available in the current WhatsApp Web version',
|
||||
);
|
||||
}
|
||||
return this.updateReachoutTimelockFromRecord(record);
|
||||
}
|
||||
|
||||
// Applies the stored 'WAReachoutTimelockState' record (or its removal) to the tracker,
|
||||
// so MeInfo and 'session.status' reflect it, and returns the resulting state
|
||||
private updateReachoutTimelockFromRecord(record: any): ReachoutTimelockData {
|
||||
if (!record) {
|
||||
// The app removes the stored record when the enforcement is lifted (or there is none)
|
||||
const current = this.reachoutTimelock.value;
|
||||
if (current?.isActive) {
|
||||
this.reachoutTimelock.update({ ...current, isActive: false });
|
||||
}
|
||||
return (
|
||||
this.reachoutTimelock.value ?? {
|
||||
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
|
||||
isActive: false,
|
||||
timeEnforcementEnds: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
const enforcementType =
|
||||
record.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
|
||||
const timelock: ReachoutTimelockData = {
|
||||
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
|
||||
// The record only exists while the enforcement is active
|
||||
isActive: true,
|
||||
// The app stores 'time_enforcement_ends' as unix milliseconds
|
||||
timeEnforcementEnds: record.time_enforcement_ends
|
||||
? EnsureSeconds(record.time_enforcement_ends)
|
||||
: null,
|
||||
};
|
||||
this.reachoutTimelock.update(timelock);
|
||||
return timelock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups methods
|
||||
*/
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return await groupChat.setPicture(media);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return await groupChat.deletePicture();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -845,20 +1057,141 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
if (!media.mimetype) {
|
||||
media.mimetype = await detectMimetype(Buffer.from(media.data, 'base64'));
|
||||
}
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
sendMediaAsDocument: true,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.mimetype = media.mimetype || WAMimeType.IMAGE;
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVoice(request) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
if (request.convert) {
|
||||
await this.convertVoice(media);
|
||||
}
|
||||
media.mimetype = request.file.mimetype || WAMimeType.VOICE;
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
sendAudioAsVoice: true,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendButtonsReply(request: MessageButtonReply) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
this.checkBrowserIsChrome();
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.mimetype = media.mimetype || WAMimeType.VIDEO;
|
||||
if (request.convert) {
|
||||
await this.convertVideo(media);
|
||||
}
|
||||
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private async convertVideo(media: MessageMedia) {
|
||||
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
|
||||
content = await this.mediaConverter.video(content);
|
||||
media.data = content.toString('base64');
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
media.filename = null;
|
||||
media.filesize = null;
|
||||
}
|
||||
|
||||
private async convertVoice(media: MessageMedia) {
|
||||
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
|
||||
content = await this.mediaConverter.voice(content);
|
||||
media.data = content.toString('base64');
|
||||
media.mimetype = WAMimeType.VOICE;
|
||||
media.filename = null;
|
||||
media.filesize = null;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendPollVote(request: MessagePollVoteRequest) {
|
||||
const message = await this.whatsapp.getMessageById(request.pollMessageId);
|
||||
if (!message) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Poll message not found: ${request.pollMessageId}`,
|
||||
);
|
||||
}
|
||||
return message.vote(request.votes);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendButtonsReply(request: MessageButtonReply) {
|
||||
const options = this.getMessageOptions(request);
|
||||
const extra: any = {
|
||||
type: 'buttons_response',
|
||||
kind: 'buttonsResponse',
|
||||
// WAWebGenerateButtonsResponseMessageProto reads the top-level field; nested buttonsResponse is legacy-gated
|
||||
// behind the "wa_web_buttons_response_prop_removal_killswitch" ABProp (default off).
|
||||
// Keep both like the native WAWebSendButtonsMsgReplyChatAction does
|
||||
selectedButtonId: request.selectedButtonID,
|
||||
buttonsResponse: {
|
||||
selectedButtonId: request.selectedButtonID,
|
||||
selectedDisplayText: request.selectedDisplayText,
|
||||
type: 1,
|
||||
},
|
||||
viewMode: 'VISIBLE',
|
||||
};
|
||||
options.extra = extra;
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
request.selectedDisplayText,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private checkBrowserIsChrome() {
|
||||
if (!IsChrome) {
|
||||
const msg =
|
||||
'Use "devlikeapro/waha:chrome" docker image to send video in WEBJS';
|
||||
throw new UnprocessableEntityException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -971,14 +1304,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
|
||||
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
|
||||
const picture = await this.getContactProfilePicture(
|
||||
chat.id._serialized,
|
||||
GetSerialized(chat.id),
|
||||
false,
|
||||
);
|
||||
const lastMessage = chat.lastMessage
|
||||
? this.toWAMessage(chat.lastMessage)
|
||||
: null;
|
||||
return {
|
||||
id: chat.id._serialized,
|
||||
id: GetSerialized(chat.id),
|
||||
name: chat.name || null,
|
||||
picture: picture,
|
||||
lastMessage: lastMessage,
|
||||
@@ -1084,7 +1417,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
// @ts-ignore
|
||||
message.rawData.receipts = await message.getInfo().catch((error) => {
|
||||
this.logger.error(
|
||||
{ error: error, msg: message.id._serialized },
|
||||
{ error: error, msg: GetSerialized(message.id) },
|
||||
'Failed to get receipts',
|
||||
);
|
||||
return null;
|
||||
@@ -1342,6 +1675,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return groupChat.setMessagesAdminsOnly(value);
|
||||
}
|
||||
|
||||
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return {
|
||||
membersCanAddNewMember:
|
||||
// @ts-ignore
|
||||
groupChat.groupMetadata.memberAddMode === 'all_member_add',
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async setMemberAddMode(id, value) {
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
// The library setter is inverted - it takes "adminsOnly"
|
||||
return groupChat.setAddMembersAdminsOnly(!value);
|
||||
}
|
||||
|
||||
public async getGroups(pagination: PaginationParams) {
|
||||
const chats = await this.whatsapp.getChats();
|
||||
const groups = lodash.filter(chats, (chat) => chat.isGroup);
|
||||
@@ -1460,23 +1809,85 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const params = {
|
||||
view: query.view || 'TRENDING',
|
||||
countryCodes: query.countries,
|
||||
cursorToken: query.startCursor,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
};
|
||||
const data = await this.whatsapp.searchChannelsView(params);
|
||||
return this.channelsRawDataToResponse(data);
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const params = {
|
||||
searchText: query.text,
|
||||
cursorToken: query.startCursor,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
};
|
||||
const data = await this.whatsapp.searchChannelsText(params);
|
||||
return this.channelsRawDataToResponse(data);
|
||||
}
|
||||
|
||||
private channelsRawDataToResponse(data: any): ChannelListResult {
|
||||
const pageInfo = data.pageInfo;
|
||||
const newsletters = data.newsletters;
|
||||
return {
|
||||
page: {
|
||||
startCursor: pageInfo.startCursor,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
hasPreviousPage: pageInfo.hasPreviousPage,
|
||||
},
|
||||
channels: newsletters.map(NewsletterMetadataToChannel),
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const channelMessages = await this.whatsapp.channelFetchMessageByInvite(
|
||||
inviteCode,
|
||||
query.limit,
|
||||
);
|
||||
const promises = [];
|
||||
for (const msg of channelMessages) {
|
||||
promises.push(
|
||||
this.WebjsChannelMessageToChannelMessage(msg, query.downloadMedia),
|
||||
);
|
||||
}
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
|
||||
private async WebjsChannelMessageToChannelMessage(
|
||||
channelMessage: WebjsChannelMessage,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
const message = await this.processIncomingMessage(
|
||||
channelMessage.message,
|
||||
downloadMedia,
|
||||
);
|
||||
const reactions = {};
|
||||
for (const reaction of channelMessage.reactions.sort((x) => -x.count)) {
|
||||
reactions[reaction.reaction] = reaction.count;
|
||||
}
|
||||
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: channelMessage.viewCount,
|
||||
};
|
||||
}
|
||||
|
||||
protected ChatToChannel(chat: WEBJSChannel): Channel {
|
||||
@@ -1487,7 +1898,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
role = ChannelRole.GUEST;
|
||||
}
|
||||
return {
|
||||
id: chat.id._serialized,
|
||||
id: GetSerialized(chat.id),
|
||||
name: chat.name,
|
||||
description: chat.description,
|
||||
invite: getChannelInviteLink(metadata.inviteCode),
|
||||
@@ -1680,6 +2091,44 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
const options = {
|
||||
caption: status.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
if (status.convert) {
|
||||
await this.convertVoice(media);
|
||||
}
|
||||
media.mimetype = status.file.mimetype || WAMimeType.VOICE;
|
||||
const options = {
|
||||
sendAudioAsVoice: true,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
this.checkBrowserIsChrome();
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
if (status.convert) {
|
||||
await this.convertVideo(media);
|
||||
}
|
||||
const options = {
|
||||
caption: status.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public sendTextStatus(status: TextStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
@@ -1948,10 +2397,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
},
|
||||
);
|
||||
const chatsArchived$ = chatArchived$.pipe(
|
||||
filter((event: any) => this.jids.include(event?.chat?.id?._serialized)),
|
||||
filter((event: any) => this.jids.include(GetSerialized(event?.chat?.id))),
|
||||
map((event) => {
|
||||
return {
|
||||
id: event.chat.id._serialized,
|
||||
id: GetSerialized(event.chat.id),
|
||||
archived: event.archived,
|
||||
timestamp: event.chat.timestamp,
|
||||
};
|
||||
@@ -2038,7 +2487,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
|
||||
const source = this.getMessageSource(reaction.id.id);
|
||||
return {
|
||||
id: reaction.id._serialized,
|
||||
id: GetSerialized(reaction.id),
|
||||
from: normalizeJid(reaction.senderId),
|
||||
fromMe: reaction.id.fromMe,
|
||||
source: source,
|
||||
@@ -2047,13 +2496,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
timestamp: reaction.timestamp,
|
||||
reaction: {
|
||||
text: reaction.reaction,
|
||||
messageId: reaction.msgId._serialized,
|
||||
messageId: GetSerialized(reaction.msgId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toPollVotePayload(vote: WebjsPollVote): PollVotePayload | null {
|
||||
const pollMessageId = vote?.parentMessage?.id?._serialized;
|
||||
const pollMessageId = GetSerialized(vote?.parentMessage?.id);
|
||||
if (!pollMessageId) {
|
||||
return null;
|
||||
}
|
||||
@@ -2133,10 +2582,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
protected toWAMessage(message: Message): WAMessage {
|
||||
const replyTo = this.extractReplyTo(message);
|
||||
const source = this.getMessageSource(message.id.id);
|
||||
const key = parseMessageIdSerialized(message.id._serialized);
|
||||
const key = parseMessageIdSerialized(GetSerialized(message.id));
|
||||
// @ts-ignore
|
||||
return {
|
||||
id: message.id._serialized,
|
||||
id: GetSerialized(message.id),
|
||||
timestamp: message.timestamp,
|
||||
from: message.from,
|
||||
fromMe: message.fromMe,
|
||||
@@ -2210,7 +2659,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
|
||||
protected toWAContact(contact: Contact) {
|
||||
// @ts-ignore
|
||||
contact.id = contact.id._serialized;
|
||||
contact.id = GetSerialized(contact.id);
|
||||
return contact;
|
||||
}
|
||||
|
||||
@@ -2249,6 +2698,29 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
function NewsletterMetadataToChannel(data: any): ChannelPublicInfo {
|
||||
const pictureDirectPath =
|
||||
data.newsletterPictureMetadataMixin.picture?.[0]
|
||||
.queryPictureDirectPathOrEmptyResponseMixinGroup?.value?.directPath;
|
||||
const pictureUrl = getPublicUrlFromDirectPath(pictureDirectPath);
|
||||
return {
|
||||
id: data.idJid,
|
||||
name: data.newsletterNameMetadataMixin.nameElementValue,
|
||||
picture: pictureUrl,
|
||||
description:
|
||||
data.newsletterDescriptionMetadataMixin
|
||||
.descriptionQueryDescriptionResponseMixin.elementValue,
|
||||
invite: getChannelInviteLink(
|
||||
data.newsletterInviteLinkMetadataMixin.inviteCode,
|
||||
),
|
||||
subscribersCount: Number(
|
||||
data.newsletterSubscribersMetadataMixin.subscribersCount,
|
||||
),
|
||||
verified:
|
||||
data.newsletterVerificationMetadataMixin.verificationState === 'verified',
|
||||
};
|
||||
}
|
||||
|
||||
export class WEBJSEngineMediaProcessor
|
||||
implements IMediaEngineProcessor<Message>
|
||||
{
|
||||
@@ -2265,7 +2737,7 @@ export class WEBJSEngineMediaProcessor
|
||||
}
|
||||
|
||||
getMessageId(message: Message): string {
|
||||
return message.id._serialized;
|
||||
return GetSerialized(message.id);
|
||||
}
|
||||
|
||||
getMimetype(message: Message): string {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
|
||||
import { WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import { LoggerBuilder } from '@waha/utils/logging';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { Zipper } from '../webjs/RemoteAuth';
|
||||
import { StreamZipper } from '../webjs/StreamZipper';
|
||||
import { ZipUnzipZipper } from '../webjs/ZipUnzipZipper';
|
||||
import { WPPAuthManager } from './WPPAuthManager';
|
||||
import { WPPMongoStore } from './WPPMongoStore';
|
||||
import { WPPPsqlStore } from './WPPPsqlStore';
|
||||
|
||||
export class WPPAuthFactory {
|
||||
async build(
|
||||
store: DataStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): Promise<IWPPAuthManager | null> {
|
||||
if (store instanceof LocalStore) {
|
||||
return null;
|
||||
}
|
||||
if (store instanceof MongoStore) {
|
||||
return this.buildMongo(
|
||||
store,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
if (store instanceof PsqlStore) {
|
||||
return this.buildPsql(
|
||||
store,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`WPPAuthFactory: unsupported store type '${store.constructor.name}'`,
|
||||
);
|
||||
}
|
||||
|
||||
private buildMongo(
|
||||
store: MongoStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): IWPPAuthManager {
|
||||
const logger = loggerBuilder.child({ name: WPPMongoStore.name });
|
||||
const wppStore = new WPPMongoStore(store, sessionName, logger);
|
||||
return this.buildManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private async buildPsql(
|
||||
store: PsqlStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): Promise<IWPPAuthManager> {
|
||||
const logger = loggerBuilder.child({ name: WPPPsqlStore.name });
|
||||
const wppStore = await WPPPsqlStore.build(store, sessionName, logger);
|
||||
return this.buildManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private buildManager(
|
||||
wppStore: WPPMongoStore | WPPPsqlStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): WPPAuthManager {
|
||||
const logger = loggerBuilder.child({ name: WPPAuthManager.name });
|
||||
const zipper = this.getZipper(logger);
|
||||
return new WPPAuthManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
zipper,
|
||||
logger,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private getZipper(logger: Logger): Zipper {
|
||||
if (process.env.WAHA_ZIPPER === 'ZIPUNZIP') {
|
||||
logger.debug('Using ZipUnzipZipper');
|
||||
return new ZipUnzipZipper();
|
||||
}
|
||||
logger.debug('Using StreamZipper');
|
||||
return new StreamZipper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
|
||||
import { WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { Zipper } from '../webjs/RemoteAuth';
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
// Only these dirs need to be backed up from the Chromium profile
|
||||
const REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
|
||||
const INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export class WPPAuthManager implements IWPPAuthManager {
|
||||
private backupRunner: SinglePeriodicJobRunner | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly store: WPPStore,
|
||||
private readonly sessionName: string,
|
||||
private readonly userDataDir: string,
|
||||
private readonly zipper: Zipper,
|
||||
private readonly logger: Logger,
|
||||
private readonly getStatus: () => WAHASessionStatus,
|
||||
private readonly backupIntervalMs: number = INITIAL_DELAY_MS,
|
||||
) {}
|
||||
|
||||
async beforeStart(): Promise<void> {
|
||||
const sessionExists = await this.store.sessionExists(this.sessionName);
|
||||
if (!sessionExists) {
|
||||
this.logger.info('No remote WPP session found, starting fresh');
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Remote WPP session found, restoring...');
|
||||
const data = await this.store.load(this.sessionName);
|
||||
if (!data) {
|
||||
this.logger.warn('Remote WPP session data is empty, starting fresh');
|
||||
return;
|
||||
}
|
||||
|
||||
const zipPath = this.getZipPath();
|
||||
await fsp.mkdir(path.dirname(zipPath), { recursive: true });
|
||||
await fsp.writeFile(zipPath, data);
|
||||
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
await this.zipper.uncompress(zipPath, this.userDataDir);
|
||||
await this.removePathSilently(zipPath);
|
||||
|
||||
await this.removeSingletonFiles(this.userDataDir);
|
||||
await sleep(2_000);
|
||||
this.logger.info('Remote WPP session restored successfully');
|
||||
}
|
||||
|
||||
async afterConnected(): Promise<void> {
|
||||
if (this.backupRunner) {
|
||||
// Already running from a previous WORKING transition
|
||||
return;
|
||||
}
|
||||
|
||||
this.backupRunner = new SinglePeriodicJobRunner(
|
||||
'WPP Auth Backup',
|
||||
this.backupIntervalMs,
|
||||
this.logger,
|
||||
);
|
||||
this.backupRunner.start(() => this.saveSnapshot());
|
||||
}
|
||||
|
||||
async saveSnapshot(): Promise<void> {
|
||||
if (this.getStatus() !== WAHASessionStatus.WORKING) {
|
||||
this.logger.debug('Skipping WPP snapshot - session not WORKING');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug('Saving WPP session snapshot...');
|
||||
const tempDir = this.getTempDir();
|
||||
const zipPath = this.getZipPath();
|
||||
|
||||
try {
|
||||
await fs.copy(this.userDataDir, tempDir);
|
||||
// await this.deleteMetadata(tempDir);
|
||||
|
||||
await this.zipper.compress(tempDir, zipPath);
|
||||
|
||||
const data = await fsp.readFile(zipPath);
|
||||
await this.store.save(this.sessionName, data);
|
||||
this.logger.info('WPP session snapshot saved');
|
||||
} finally {
|
||||
await this.removePathSilently(tempDir);
|
||||
await this.removePathSilently(zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.backupRunner?.stop();
|
||||
this.backupRunner = null;
|
||||
}
|
||||
|
||||
private async deleteMetadata(tempDir: string): Promise<void> {
|
||||
const dirsToScan = [tempDir, path.join(tempDir, 'Default')];
|
||||
for (const dir of dirsToScan) {
|
||||
const exists = await fs.pathExists(dir);
|
||||
if (!exists) {
|
||||
continue;
|
||||
}
|
||||
const entries = await fsp.readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (REQUIRED_DIRS.includes(entry)) {
|
||||
continue;
|
||||
}
|
||||
await this.removePathSilently(path.join(dir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSingletonFiles(dir: string): Promise<void> {
|
||||
const exists = await fs.pathExists(dir);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
const files = await fsp.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('Singleton')) {
|
||||
continue;
|
||||
}
|
||||
await this.removePathSilently(path.join(dir, file));
|
||||
}
|
||||
}
|
||||
|
||||
private async removePathSilently(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.rm(targetPath, { recursive: true, force: true, maxRetries: 4 });
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to remove: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTempDir(): string {
|
||||
return `/tmp/wpp_temp_${this.sessionName}`;
|
||||
}
|
||||
|
||||
private getZipPath(): string {
|
||||
return `/tmp/wpp_session_${this.sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
|
||||
import { Logger } from 'pino';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
const BUCKET_NAME = 'auth';
|
||||
|
||||
export class WPPMongoStore implements WPPStore {
|
||||
private readonly db: Db;
|
||||
private readonly bucket: GridFSBucket;
|
||||
|
||||
constructor(
|
||||
store: MongoStore,
|
||||
sessionName: string,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.db = store.getSessionDb(sessionName);
|
||||
this.bucket = new GridFSBucket(this.db, { bucketName: BUCKET_NAME });
|
||||
}
|
||||
|
||||
async sessionExists(sessionName: string): Promise<boolean> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const count = await this.db
|
||||
.collection(`${BUCKET_NAME}.files`)
|
||||
.countDocuments({ filename: filename });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async load(sessionName: string): Promise<Buffer | null> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const downloadStream = this.bucket.openDownloadStreamByName(filename);
|
||||
for await (const chunk of downloadStream) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async save(sessionName: string, data: Buffer): Promise<void> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const readable = Readable.from(data);
|
||||
const uploadStream = this.bucket.openUploadStream(filename);
|
||||
await pipeline(readable, uploadStream);
|
||||
await this.deletePrevious(filename);
|
||||
}
|
||||
|
||||
async delete(sessionName: string): Promise<void> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
await Promise.all(files.map((f: GridFSFile) => this.bucket.delete(f._id)));
|
||||
}
|
||||
|
||||
private async deletePrevious(filename: string): Promise<void> {
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
if (files.length <= 1) {
|
||||
return;
|
||||
}
|
||||
files.sort(
|
||||
(a: GridFSFile, b: GridFSFile) =>
|
||||
a.uploadDate.getTime() - b.uploadDate.getTime(),
|
||||
);
|
||||
// Keep the last one, delete the rest
|
||||
const toDelete = files.slice(0, files.length - 1);
|
||||
await Promise.all(
|
||||
toDelete.map((f: GridFSFile) => this.bucket.delete(f._id)),
|
||||
);
|
||||
}
|
||||
|
||||
private getFilename(sessionName: string): string {
|
||||
return `${sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
class WPPFileRepository extends PsqlFileRepository {
|
||||
get tableName() {
|
||||
return 'files';
|
||||
}
|
||||
}
|
||||
|
||||
export class WPPPsqlStore implements WPPStore {
|
||||
private readonly repository: WPPFileRepository;
|
||||
|
||||
constructor(
|
||||
knex: Knex.Knex,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.repository = new WPPFileRepository(knex, logger);
|
||||
}
|
||||
|
||||
static async build(
|
||||
store: PsqlStore,
|
||||
sessionName: string,
|
||||
logger: Logger,
|
||||
): Promise<WPPPsqlStore> {
|
||||
const knex = store.buildSessionKnex(sessionName, 'Session/Auth');
|
||||
const instance = new WPPPsqlStore(knex, logger);
|
||||
await instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
async sessionExists(sessionName: string): Promise<boolean> {
|
||||
return this.repository.exists(this.getFilename(sessionName));
|
||||
}
|
||||
|
||||
async load(sessionName: string): Promise<Buffer | null> {
|
||||
const data = await this.repository.fetch(this.getFilename(sessionName));
|
||||
return data ? data.content : null;
|
||||
}
|
||||
|
||||
async save(sessionName: string, data: Buffer): Promise<void> {
|
||||
await this.repository.save(this.getFilename(sessionName), data);
|
||||
}
|
||||
|
||||
async delete(sessionName: string): Promise<void> {
|
||||
await this.repository.delete(this.getFilename(sessionName));
|
||||
}
|
||||
|
||||
private getFilename(sessionName: string): string {
|
||||
return `${sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface WPPStore {
|
||||
sessionExists(sessionName: string): Promise<boolean>;
|
||||
|
||||
load(sessionName: string): Promise<Buffer | null>;
|
||||
|
||||
save(sessionName: string, data: Buffer): Promise<void>;
|
||||
|
||||
delete(sessionName: string): Promise<void>;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
MessageFileRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -81,7 +82,13 @@ import {
|
||||
import { WAHAChatPresences } from '@waha/structures/presence.dto';
|
||||
import { WAMessage } from '@waha/structures/responses.dto';
|
||||
import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import { TextStatus } from '@waha/structures/status.dto';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import { ReplyToMessage } from '@waha/structures/message.dto';
|
||||
import {
|
||||
PollVotePayload,
|
||||
@@ -121,10 +128,7 @@ import {
|
||||
WppSendTextOptions,
|
||||
WppSendTextStatusOptions,
|
||||
} from '@waha/core/engines/wpp/WppTypes';
|
||||
import {
|
||||
AvailableInPlusVersion,
|
||||
NotImplementedByEngineError,
|
||||
} from '@waha/core/exceptions';
|
||||
import { NotImplementedByEngineError } from '@waha/core/exceptions';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
|
||||
@@ -161,6 +165,7 @@ import {
|
||||
GroupParticipantRole,
|
||||
GroupSortField,
|
||||
ParticipantsRequest,
|
||||
SettingsMemberAddMode,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '@waha/structures/groups.dto';
|
||||
import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto';
|
||||
@@ -168,6 +173,16 @@ import { LidToPhoneNumber } from '@waha/structures/lids.dto';
|
||||
import { evaluateAndReturn } from '@wppconnect-team/wppconnect/dist/api/helpers';
|
||||
import { getFromToParticipant } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { IsChrome } from '@waha/version';
|
||||
import { WPPAuthFactory } from '@waha/core/engines/wpp/WPPAuthFactory';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import { detectMimetype } from '@waha/utils/files';
|
||||
import type {
|
||||
AudioMessageOptions,
|
||||
DocumentMessageOptions,
|
||||
FileMessageOptions,
|
||||
ImageMessageOptions,
|
||||
VideoMessageOptions,
|
||||
} from '@wppconnect/wa-js/dist/chat';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -194,6 +209,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
protected qr: QR;
|
||||
protected wpp?: WPPWhatsapp;
|
||||
protected authManager: IWPPAuthManager | null = null;
|
||||
private authFactory = new WPPAuthFactory();
|
||||
private meInfo: MeInfo | null = null;
|
||||
private pairingCode?: string;
|
||||
private presencesByChatId = new Map<string, WAHAChatPresences>();
|
||||
@@ -218,6 +234,13 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.authManager = await this.authFactory.build(
|
||||
this.sessionStore,
|
||||
this.name,
|
||||
this.getUserDataDir(),
|
||||
this.loggerBuilder,
|
||||
() => this.status,
|
||||
);
|
||||
this.shouldRestart = true;
|
||||
this.status = WAHASessionStatus.STARTING;
|
||||
this.pairingCode = null;
|
||||
@@ -624,19 +647,218 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
return this.toWAMessage(sent);
|
||||
}
|
||||
|
||||
public sendImage(request: MessageImageRequest) {
|
||||
void request;
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
const quotedMessageId = this.getReplyToMessageId(request as any);
|
||||
const content = await this.fileToBuffer(request.file);
|
||||
const mimetype = request.file.mimetype || WAMimeType.IMAGE;
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: ImageMessageOptions = {
|
||||
type: 'image',
|
||||
caption: request.caption,
|
||||
filename: request.file.filename,
|
||||
mimetype: mimetype,
|
||||
quotedMsg: quotedMessageId,
|
||||
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
|
||||
waitForAck: false,
|
||||
};
|
||||
return await this.sendMedia(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
public sendFile(request: MessageFileRequest) {
|
||||
void request;
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
const quotedMessageId = this.getReplyToMessageId(request as any);
|
||||
const content = await this.fileToBuffer(request.file);
|
||||
const mimetype = request.file.mimetype || (await detectMimetype(content));
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: DocumentMessageOptions = {
|
||||
type: 'document',
|
||||
caption: request.caption,
|
||||
filename: request.file.filename,
|
||||
mimetype: mimetype,
|
||||
quotedMsg: quotedMessageId,
|
||||
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
|
||||
waitForAck: false,
|
||||
};
|
||||
return await this.sendMedia(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
public sendVoice(request: MessageVoiceRequest) {
|
||||
void request;
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVoice(request: MessageVoiceRequest) {
|
||||
const quotedMessageId = this.getReplyToMessageId(request as any);
|
||||
let content = await this.fileToBuffer(request.file);
|
||||
let mimetype = request.file.mimetype || WAMimeType.VOICE;
|
||||
if (request.convert) {
|
||||
content = await this.convertVoice(content);
|
||||
mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: AudioMessageOptions = {
|
||||
type: 'audio',
|
||||
isPtt: true,
|
||||
mimetype: mimetype,
|
||||
quotedMsg: quotedMessageId,
|
||||
waitForAck: false,
|
||||
};
|
||||
return await this.sendMedia(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
const quotedMessageId = this.getReplyToMessageId(request as any);
|
||||
let content = await this.fileToBuffer(request.file);
|
||||
let mimetype = request.file.mimetype || WAMimeType.VIDEO;
|
||||
if (request.convert) {
|
||||
content = await this.convertVideo(content);
|
||||
mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: VideoMessageOptions = {
|
||||
type: 'video',
|
||||
isPtv: request.asNote,
|
||||
caption: request.caption,
|
||||
filename: request.file.filename,
|
||||
mimetype: mimetype,
|
||||
quotedMsg: quotedMessageId,
|
||||
mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)),
|
||||
waitForAck: false,
|
||||
};
|
||||
return await this.sendMedia(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendImageStatus(status: ImageStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
const content = await this.fileToBuffer(status.file);
|
||||
const mimetype = status.file.mimetype || WAMimeType.IMAGE;
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: any = {
|
||||
caption: status.caption,
|
||||
waitForAck: false,
|
||||
};
|
||||
if (status.id) {
|
||||
options.messageId = status.id;
|
||||
}
|
||||
const sent = (await this.wpp.sendImageStatus(media, options)) as any;
|
||||
return await this.toStatusResponse(sent, status.id);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendVoiceStatus(status: VoiceStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
let content = await this.fileToBuffer(status.file);
|
||||
let mimetype = status.file.mimetype || WAMimeType.VOICE;
|
||||
if (status.convert) {
|
||||
content = await this.convertVoice(content);
|
||||
mimetype = WAMimeType.VOICE;
|
||||
}
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: AudioMessageOptions = {
|
||||
type: 'audio',
|
||||
isPtt: true,
|
||||
mimetype: mimetype,
|
||||
waitForAck: true,
|
||||
};
|
||||
return await this.sendMedia(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendVideoStatus(status: VideoStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
let content = await this.fileToBuffer(status.file);
|
||||
let mimetype = status.file.mimetype || WAMimeType.VIDEO;
|
||||
if (status.convert) {
|
||||
content = await this.convertVideo(content);
|
||||
mimetype = WAMimeType.VIDEO;
|
||||
}
|
||||
const media = WPPMedia(content, mimetype);
|
||||
const options: any = {
|
||||
caption: status.caption,
|
||||
waitForAck: false,
|
||||
};
|
||||
if (status.id) {
|
||||
options.messageId = status.id;
|
||||
}
|
||||
const sent = (await this.wpp.sendVideoStatus(media, options)) as any;
|
||||
return await this.toStatusResponse(sent, status.id);
|
||||
}
|
||||
|
||||
private async sendMedia(
|
||||
chatId: string,
|
||||
media: string,
|
||||
options: FileMessageOptions,
|
||||
) {
|
||||
const sent = await this.wpp.sendFile(chatId, media, options);
|
||||
const sentId = sent?.id || null;
|
||||
if (!sentId) {
|
||||
return {
|
||||
id: null,
|
||||
_data: sent,
|
||||
};
|
||||
}
|
||||
this.saveSentMessageId(this.extractMessageIdPart(sentId));
|
||||
const sentMessage = await this.wpp.getMessageById(sentId).catch(() => null);
|
||||
if (!sentMessage) {
|
||||
return {
|
||||
id: sentId,
|
||||
_data: sent,
|
||||
};
|
||||
}
|
||||
return this.toWAMessage(sentMessage);
|
||||
}
|
||||
|
||||
private async convertVideo(content: Buffer): Promise<Buffer> {
|
||||
return await this.mediaConverter.video(content);
|
||||
}
|
||||
|
||||
private async convertVoice(content: Buffer): Promise<Buffer> {
|
||||
return await this.mediaConverter.voice(content);
|
||||
}
|
||||
|
||||
private extractMessageIdPart(messageId: string): string {
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const parts = messageId.split('_');
|
||||
if (parts.length >= 3) {
|
||||
return parts[2];
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private async toStatusResponse(sent: any, fallbackId?: string) {
|
||||
const sentId = sent?.id || fallbackId || null;
|
||||
if (!sentId) {
|
||||
return {
|
||||
id: null,
|
||||
_data: sent,
|
||||
};
|
||||
}
|
||||
this.saveSentMessageId(this.extractMessageIdPart(sentId));
|
||||
const sentMessage = await this.wpp.getMessageById(sentId).catch(() => null);
|
||||
if (!sentMessage) {
|
||||
return {
|
||||
id: sentId,
|
||||
_data: sent,
|
||||
};
|
||||
}
|
||||
return this.toWAMessage(sentMessage);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1191,6 +1413,26 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
|
||||
const group = await this.wpp!.getChatById(this.ensureSuffix(id));
|
||||
// Undocumented property - 'all_member_add' or 'admin_add'
|
||||
const memberAddMode = group?.groupMetadata?.memberAddMode;
|
||||
return {
|
||||
membersCanAddNewMember: memberAddMode === 'all_member_add',
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public setMemberAddMode(id: string, value: boolean): Promise<boolean> {
|
||||
// Not in the GroupProperty enum, but wa-js passes the property through
|
||||
// to WhatsApp's setGroupProperty - 1 - all members, 0 - admins only
|
||||
return this.wpp!.setGroupProperty(
|
||||
this.ensureSuffix(id),
|
||||
'member_add_mode' as GroupProperty,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public deleteGroup(id: string): Promise<boolean> {
|
||||
return this.wpp!.deleteChat(this.ensureSuffix(id));
|
||||
@@ -1271,14 +1513,15 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public promoteParticipantsToAdmin(
|
||||
public async promoteParticipantsToAdmin(
|
||||
id: string,
|
||||
request: ParticipantsRequest,
|
||||
): Promise<boolean> {
|
||||
const participants = request.participants.map((participant) =>
|
||||
this.ensureSuffix(participant.id),
|
||||
);
|
||||
return this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
|
||||
await this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1653,7 +1896,8 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
subject: group?.name || '',
|
||||
description: group?.groupMetadata?.desc || '',
|
||||
invite: null,
|
||||
membersCanAddNewMember: !group?.groupMetadata?.restrict,
|
||||
membersCanAddNewMember:
|
||||
group?.groupMetadata?.memberAddMode === 'all_member_add',
|
||||
membersCanSendMessages: !group?.groupMetadata?.announce,
|
||||
newMembersApprovalRequired:
|
||||
!!group?.groupMetadata?.membershipApprovalMode,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
HealthCheckError,
|
||||
HealthIndicator,
|
||||
HealthIndicatorResult,
|
||||
} from '@nestjs/terminus';
|
||||
import checkDiskSpace from 'check-disk-space';
|
||||
|
||||
export interface CheckFreeDiskSpaceOptions {
|
||||
path: string;
|
||||
thresholdBytes: number;
|
||||
}
|
||||
|
||||
export class CheckFreeDiskSpaceIndicator extends HealthIndicator {
|
||||
public async check(
|
||||
key: string,
|
||||
options: CheckFreeDiskSpaceOptions,
|
||||
): Promise<HealthIndicatorResult> {
|
||||
const diskSpace = await checkDiskSpace(options.path);
|
||||
if (diskSpace.free < options.thresholdBytes) {
|
||||
const result = super.getStatus(key, false, {
|
||||
path: options.path,
|
||||
diskPath: diskSpace.diskPath,
|
||||
free: diskSpace.free,
|
||||
threshold: options.thresholdBytes,
|
||||
});
|
||||
throw new HealthCheckError('Disk space is low', result);
|
||||
}
|
||||
return super.getStatus(key, true, {
|
||||
path: options.path,
|
||||
diskPath: diskSpace.diskPath,
|
||||
free: diskSpace.free,
|
||||
threshold: options.thresholdBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
HealthCheckError,
|
||||
HealthIndicator,
|
||||
HealthIndicatorResult,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
import { promiseTimeout } from '../../utils/promiseTimeout';
|
||||
import { MongoStore } from '../storage/mongo/MongoStore';
|
||||
|
||||
export interface MongoStoreHealthIndicatorOptions {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export class MongoStoreHealthIndicator extends HealthIndicator {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private async ping(mongoStore: MongoStore) {
|
||||
await mongoStore.command({ ismaster: 1 });
|
||||
}
|
||||
|
||||
public async check(
|
||||
key: string,
|
||||
mongo: MongoStore,
|
||||
options: MongoStoreHealthIndicatorOptions,
|
||||
): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
await promiseTimeout(options.timeout, this.ping(mongo));
|
||||
return super.getStatus(key, true, { message: 'Up and running' });
|
||||
} catch (e) {
|
||||
const result = super.getStatus(key, false, { error: 'Timeout' });
|
||||
throw new HealthCheckError('Timeout happened', result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HealthCheckResult } from '@nestjs/terminus';
|
||||
import { HealthCheckResult, HealthCheckService } from '@nestjs/terminus';
|
||||
import type { HealthIndicatorFunction } from '@nestjs/terminus/dist/health-indicator';
|
||||
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
|
||||
import * as path from 'path';
|
||||
|
||||
import { WhatsappConfigService } from '../../config.service';
|
||||
import { SessionManager } from '../abc/manager.abc';
|
||||
import { WAHAHealthCheckService } from '../abc/WAHAHealthCheckService';
|
||||
import { AvailableInPlusVersion } from '../exceptions';
|
||||
import { LocalStore } from '../storage/LocalStore';
|
||||
import { MongoStore } from '../storage/mongo/MongoStore';
|
||||
import { CheckFreeDiskSpaceIndicator } from './CheckFreeDiskSpaceIndicator';
|
||||
import { MongoStoreHealthIndicator } from './MongoStoreHealthIndicator';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class WAHAHealthCheckServiceCore extends WAHAHealthCheckService {
|
||||
constructor(
|
||||
protected sessionManager: SessionManager,
|
||||
protected health: HealthCheckService,
|
||||
protected config: WhatsappConfigService,
|
||||
protected mongoStoreHealthIndicator: MongoStoreHealthIndicator,
|
||||
protected checkFreeDiskSpaceIndicator: CheckFreeDiskSpaceIndicator,
|
||||
protected mediaLocalStorageConfig: MediaLocalStorageConfig,
|
||||
) {
|
||||
super(sessionManager, health, config);
|
||||
}
|
||||
|
||||
check(): Promise<HealthCheckResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const indicators = this.getIndicators();
|
||||
return this.health.check(indicators);
|
||||
}
|
||||
|
||||
getIndicators(): HealthIndicatorFunction[] {
|
||||
const indicators = [
|
||||
() =>
|
||||
this.checkFreeDiskSpaceIndicator.check('mediaFiles.space', {
|
||||
path: path.resolve(this.mediaLocalStorageConfig.filesFolder),
|
||||
thresholdBytes: this.config.getHealthMediaFilesThreshold() * MB,
|
||||
}),
|
||||
];
|
||||
if (this.sessionManager.store instanceof LocalStore) {
|
||||
indicators.push(() =>
|
||||
this.checkFreeDiskSpaceIndicator.check('sessionsFiles.space', {
|
||||
path: path.resolve(this.sessionManager.store.getBaseDirectory()),
|
||||
thresholdBytes: this.config.getHealthSessionFilesThreshold() * MB,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.sessionManager.store instanceof MongoStore) {
|
||||
indicators.push(() =>
|
||||
this.mongoStoreHealthIndicator.check(
|
||||
'mongodb',
|
||||
this.sessionManager.store,
|
||||
{ timeout: this.config.getHealthMongoTimeout() },
|
||||
),
|
||||
);
|
||||
}
|
||||
return indicators;
|
||||
}
|
||||
}
|
||||
+400
-199
@@ -2,8 +2,8 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleInit,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AppsService,
|
||||
@@ -11,21 +11,50 @@ import {
|
||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
|
||||
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
|
||||
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
|
||||
import { WhatsappSessionGoWSCore } from '@waha/core/engines/gows/session.gows.core';
|
||||
import { WhatsappSessionNoWebCore } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { WhatsappSessionWPPCore } from '@waha/core/engines/wpp/session.wpp.core';
|
||||
import { WhatsappSessionWebJSCore } from '@waha/core/engines/webjs/session.webjs.core';
|
||||
import { getProxyConfig } from '@waha/core/helpers.proxy';
|
||||
import { WebhookConductor } from '@waha/core/integrations/webhooks/WebhookConductor';
|
||||
import { MediaManager } from '@waha/core/media/MediaManager';
|
||||
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
|
||||
import { LocalSessionAuthRepository } from '@waha/core/storage/LocalSessionAuthRepository';
|
||||
import { LocalSessionConfigRepository } from '@waha/core/storage/LocalSessionConfigRepository';
|
||||
import { LocalStoreCore } from '@waha/core/storage/LocalStoreCore';
|
||||
import { MongoApiKeyRepository } from '@waha/core/storage/mongo/MongoApiKeyRepository';
|
||||
import { MongoSessionAuthRepository } from '@waha/core/storage/mongo/MongoSessionAuthRepository';
|
||||
import { MongoSessionConfigRepository } from '@waha/core/storage/mongo/MongoSessionConfigRepository';
|
||||
import { MongoSessionMeRepository } from '@waha/core/storage/mongo/MongoSessionMeRepository';
|
||||
import { MongoSessionWorkerRepository } from '@waha/core/storage/mongo/MongoSessionWorkerRepository';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { parsePsql } from '@waha/core/storage/psql/PsqlConnectionConfig';
|
||||
import { PsqlApiKeyRepository } from '@waha/core/storage/psql/PsqlApiKeyRepository';
|
||||
import { PsqlSessionAuthRepository } from '@waha/core/storage/psql/PsqlSessionAuthRepository';
|
||||
import { PsqlSessionConfigRepository } from '@waha/core/storage/psql/PsqlSessionConfigRepository';
|
||||
import { PsqlSessionMeRepository } from '@waha/core/storage/psql/PsqlSessionMeRepository';
|
||||
import { PsqlSessionWorkerRepository } from '@waha/core/storage/psql/PsqlSessionWorkerRepository';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { Sqlite3ApiKeyRepository } from '@waha/core/storage/sqlite3/Sqlite3ApiKeyRepository';
|
||||
import { Sqlite3SessionMeRepository } from '@waha/core/storage/sqlite3/Sqlite3SessionMeRepository';
|
||||
import { Sqlite3SessionWorkerRepository } from '@waha/core/storage/sqlite3/Sqlite3SessionWorkerRepository';
|
||||
import { WAHAWebhookSessionStatus } from '@waha/structures/webhooks.dto';
|
||||
import { DefaultMap } from '@waha/utils/DefaultMap';
|
||||
import { getPinoLogLevel, LoggerBuilder } from '@waha/utils/logging';
|
||||
import { promiseTimeout, sleep } from '@waha/utils/promiseTimeout';
|
||||
import { complete } from '@waha/utils/reactive/complete';
|
||||
import { SwitchObservable } from '@waha/utils/reactive/SwitchObservable';
|
||||
import { getNamespace, getSessionNamespace } from '@waha/config';
|
||||
import { getEngineName, VERSION } from '@waha/version';
|
||||
import * as lodash from 'lodash';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { Observable, retry, share } from 'rxjs';
|
||||
import { merge, Observable, retry, share } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { getNamespace, getSessionNamespace } from '../config';
|
||||
import { WhatsappConfigService } from '../config.service';
|
||||
import {
|
||||
WAHAEngine,
|
||||
@@ -43,51 +72,32 @@ import { WebhookConfig } from '../structures/webhooks.config.dto';
|
||||
import { populateSessionInfo, SessionManager } from './abc/manager.abc';
|
||||
import { SessionParams, WhatsappSession } from './abc/session.abc';
|
||||
import { EngineConfigService } from './config/EngineConfigService';
|
||||
import { WhatsappSessionNoWebCore } from './engines/noweb/session.noweb.core';
|
||||
import { WhatsappSessionWPPCore } from './engines/wpp/session.wpp.core';
|
||||
import { WhatsappSessionWebJSCore } from './engines/webjs/session.webjs.core';
|
||||
import { DOCS_URL } from './exceptions';
|
||||
import { getProxyConfig } from './helpers.proxy';
|
||||
import { MediaManager } from './media/MediaManager';
|
||||
import { LocalSessionAuthRepository } from './storage/LocalSessionAuthRepository';
|
||||
import { LocalStoreCore } from './storage/LocalStoreCore';
|
||||
import { CoreApiKeyRepository } from './storage/CoreApiKeyRepository';
|
||||
|
||||
export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException {
|
||||
constructor(name: string) {
|
||||
const encoded = Buffer.from(name, 'utf-8').toString('base64');
|
||||
super(
|
||||
`WAHA Core support only 'default' session. You tried to access '${name}' session (base64: ${encoded}). ` +
|
||||
`If you want to run more then one WhatsApp account - please get WAHA PLUS version. Check this out: ${DOCS_URL}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum DefaultSessionStatus {
|
||||
REMOVED = undefined,
|
||||
STOPPED = null,
|
||||
}
|
||||
const ALL = '*';
|
||||
|
||||
@Injectable()
|
||||
export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
SESSION_STOP_TIMEOUT = 3000;
|
||||
|
||||
// session - exists and running (or failed or smth)
|
||||
// null - stopped
|
||||
// undefined - removed
|
||||
private session: WhatsappSession | DefaultSessionStatus;
|
||||
private sessionConfig?: SessionConfig;
|
||||
DEFAULT = 'default';
|
||||
export class SessionManagerCore
|
||||
extends SessionManager
|
||||
implements OnModuleInit, OnApplicationBootstrap
|
||||
{
|
||||
private SESSION_STOP_TIMEOUT = 3000;
|
||||
SESSION_UNPAIR_TIMEOUT = 1000;
|
||||
private readonly sessions: Record<string, WhatsappSession>;
|
||||
|
||||
protected readonly EngineClass: typeof WhatsappSession;
|
||||
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
|
||||
protected readonly engineBootstrap: EngineBootstrap;
|
||||
|
||||
protected events2: DefaultMap<
|
||||
string,
|
||||
DefaultMap<WAHAEvents, SwitchObservable<any>>
|
||||
>;
|
||||
|
||||
constructor(
|
||||
config: WhatsappConfigService,
|
||||
private engineConfigService: EngineConfigService,
|
||||
private webjsEngineConfigService: WebJSEngineConfigService,
|
||||
private wppEngineConfigService: WPPEngineConfigService,
|
||||
private nowebEngineConfigService: NowebEngineConfigService,
|
||||
gowsConfigService: GowsEngineConfigService,
|
||||
log: PinoLogger,
|
||||
private mediaStorageFactory: MediaStorageFactory,
|
||||
@@ -95,58 +105,188 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
appsService: IAppsService,
|
||||
) {
|
||||
super(log, config, gowsConfigService, appsService);
|
||||
this.session = DefaultSessionStatus.STOPPED;
|
||||
this.sessionConfig = null;
|
||||
this.sessions = {};
|
||||
const engineName = this.engineConfigService.getDefaultEngineName();
|
||||
this.EngineClass = this.getEngine(engineName);
|
||||
this.engineBootstrap = this.getEngineBootstrap(engineName);
|
||||
|
||||
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
|
||||
(key) =>
|
||||
new SwitchObservable((obs$) => {
|
||||
return obs$.pipe(retry(), share());
|
||||
}),
|
||||
this.events2 = new DefaultMap(
|
||||
(session: string) =>
|
||||
new DefaultMap<WAHAEvents, SwitchObservable<any>>(
|
||||
(key) =>
|
||||
new SwitchObservable((obs$) => {
|
||||
return obs$.pipe(retry(), share());
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.store = new LocalStoreCore(getNamespace(), getSessionNamespace());
|
||||
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
|
||||
this.clearStorage().catch((error) => {
|
||||
this.log.error({ error }, 'Error while clearing storage');
|
||||
});
|
||||
async onModuleInit() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.engineBootstrap.bootstrap();
|
||||
await this.restartSessions();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const mongoUrl = this.config.getSessionMongoUrl();
|
||||
const postgresUrl = this.config.getSessionPostgresUrl();
|
||||
if (mongoUrl) {
|
||||
this.log.info('Using mongo storage for session info.');
|
||||
const mongo = new MongoClient(mongoUrl);
|
||||
this.log.info(`Connecting to mongo '${mongoUrl}'...`);
|
||||
await mongo.connect();
|
||||
this.log.info(`Connected to mongo '${mongoUrl}'!`);
|
||||
|
||||
this.store = new MongoStore(mongo, getNamespace(), getSessionNamespace());
|
||||
await this.store.init();
|
||||
this.sessionAuthRepository = new MongoSessionAuthRepository(this.store);
|
||||
this.sessionConfigRepository = new MongoSessionConfigRepository(
|
||||
this.store,
|
||||
);
|
||||
this.sessionMeRepository = new MongoSessionMeRepository(this.store);
|
||||
this.sessionWorkerRepository = new MongoSessionWorkerRepository(
|
||||
this.store,
|
||||
);
|
||||
this.apiKeyRepository = new MongoApiKeyRepository(this.store);
|
||||
} else if (postgresUrl) {
|
||||
this.log.info('Using Postgres storage for session info.');
|
||||
const config = parsePsql(postgresUrl);
|
||||
const engine = getEngineName();
|
||||
config.application_name = `WAHA(${engine}) ${VERSION.version} - Manager`;
|
||||
this.store = new PsqlStore(config, getNamespace(), getSessionNamespace());
|
||||
await this.store.init();
|
||||
this.sessionAuthRepository = new PsqlSessionAuthRepository(this.store);
|
||||
this.sessionConfigRepository = new PsqlSessionConfigRepository(
|
||||
this.store,
|
||||
);
|
||||
this.sessionMeRepository = new PsqlSessionMeRepository(this.store);
|
||||
this.sessionWorkerRepository = new PsqlSessionWorkerRepository(
|
||||
this.store,
|
||||
);
|
||||
this.apiKeyRepository = new PsqlApiKeyRepository(this.store);
|
||||
const knex = this.store.getWAHADatabase();
|
||||
await this.appsService.migrate(knex);
|
||||
} else {
|
||||
this.log.info('Using local storage for session info.');
|
||||
this.store = new LocalStoreCore(getNamespace(), getSessionNamespace());
|
||||
await this.store.init();
|
||||
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
|
||||
this.sessionConfigRepository = new LocalSessionConfigRepository(
|
||||
this.store,
|
||||
);
|
||||
this.sessionMeRepository = new Sqlite3SessionMeRepository(this.store);
|
||||
this.sessionWorkerRepository = new Sqlite3SessionWorkerRepository(
|
||||
this.store,
|
||||
);
|
||||
this.apiKeyRepository = new Sqlite3ApiKeyRepository(this.store);
|
||||
const knex = this.store.getWAHADatabase();
|
||||
await this.appsService.migrate(knex);
|
||||
}
|
||||
|
||||
await this.sessionConfigRepository.init();
|
||||
await this.sessionMeRepository.init();
|
||||
await this.sessionWorkerRepository.init();
|
||||
await this.apiKeyRepository.init();
|
||||
this.listenEvents();
|
||||
await this.clearStorage();
|
||||
}
|
||||
|
||||
async restartSessions() {
|
||||
let restartSessions: string[];
|
||||
if (this.config.shouldRestartAllSessions) {
|
||||
this.log.info(`Restarting ALL STOPPED sessions...`);
|
||||
restartSessions = await this.sessionConfigRepository.getAllConfigs();
|
||||
} else if (this.config.shouldRestartWorkerSessions) {
|
||||
this.log.info(`Starting sessions for the worker "${this.workerId}"...`);
|
||||
restartSessions = await this.sessionWorkerRepository.getSessionsByWorker(
|
||||
this.workerId,
|
||||
);
|
||||
}
|
||||
|
||||
if (restartSessions != null) {
|
||||
this.restartStoppedSessions(restartSessions).catch((error) => {
|
||||
this.log.error(`Error while restarting STOPPED sessions: ${error}`);
|
||||
this.log.error(error.stack);
|
||||
});
|
||||
} else {
|
||||
this.log.info(`No sessions to restart.`);
|
||||
}
|
||||
|
||||
this.startPredefinedSessions();
|
||||
}
|
||||
|
||||
private listenEvents() {
|
||||
this.events2
|
||||
.get(ALL)
|
||||
.get(WAHAEvents.SESSION_STATUS)
|
||||
.subscribe(async (data: WAHAWebhookSessionStatus) => {
|
||||
if (data.me) {
|
||||
await this.sessionMeRepository.upsertMe(data.session, data.me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async restartStoppedSessions(sessions: string[]) {
|
||||
// Wait until HTTP/WS server is ready
|
||||
await sleep(1000);
|
||||
|
||||
const sleepS = this.config.autoStartDelaySeconds;
|
||||
this.log.info(`Restarting sessions with delay of ${sleepS} seconds...`);
|
||||
const sleepMs = this.config.autoStartDelaySeconds * 1000;
|
||||
for (const sessionName of sessions) {
|
||||
const log = this.log.logger.child({ session: sessionName });
|
||||
await this.withLock(sessionName, async () => {
|
||||
log.info(`Restarting STOPPED session...`);
|
||||
await this.start(sessionName).catch((error) => {
|
||||
log.error(`Failed to start STOPPED session: ${error}`);
|
||||
log.error(error.stack);
|
||||
});
|
||||
})
|
||||
// withLock() itself can reject (e.g. "Maximum execution time is exceeded"),
|
||||
// catch it so one stuck session doesn't abort restarting the rest
|
||||
.catch((error) => {
|
||||
log.error(`Failed to restart STOPPED session: ${error}`);
|
||||
log.error(error.stack);
|
||||
});
|
||||
await sleep(sleepMs);
|
||||
}
|
||||
this.log.info(`STOPPED sessions have been restarted.`);
|
||||
}
|
||||
|
||||
protected getEngine(engine: WAHAEngine): typeof WhatsappSession {
|
||||
if (engine === WAHAEngine.WEBJS) {
|
||||
this.SESSION_STOP_TIMEOUT = 3_000;
|
||||
return WhatsappSessionWebJSCore;
|
||||
} else if (engine === WAHAEngine.WPP) {
|
||||
this.SESSION_STOP_TIMEOUT = 3_000;
|
||||
return WhatsappSessionWPPCore;
|
||||
} else if (engine === WAHAEngine.NOWEB) {
|
||||
this.SESSION_STOP_TIMEOUT = 1_000;
|
||||
return WhatsappSessionNoWebCore;
|
||||
} else if (engine === WAHAEngine.GOWS) {
|
||||
this.SESSION_STOP_TIMEOUT = 10;
|
||||
return WhatsappSessionGoWSCore;
|
||||
} else {
|
||||
throw new NotFoundException(`Unknown whatsapp engine '${engine}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
private onlyDefault(name: string) {
|
||||
if (name !== this.DEFAULT) {
|
||||
throw new OnlyDefaultSessionIsAllowed(name);
|
||||
throw new Error(`Unknown whatsapp engine '${engine}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
async beforeApplicationShutdown(signal?: string) {
|
||||
if (this.session) {
|
||||
await this.stop(this.DEFAULT, true);
|
||||
}
|
||||
this.stopEvents();
|
||||
await this.engineBootstrap.shutdown();
|
||||
}
|
||||
this.log.info('Stopping all sessions...');
|
||||
const promises = Object.keys(this.sessions).map(async (sessionName) => {
|
||||
await this.withLock(sessionName, async () => {
|
||||
await this.stop(sessionName, true);
|
||||
});
|
||||
});
|
||||
await Promise.all(promises);
|
||||
this.log.info('All sessions have been stopped.');
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.apiKeyRepository = new CoreApiKeyRepository();
|
||||
await this.engineBootstrap.bootstrap();
|
||||
this.startPredefinedSessions();
|
||||
this.stopEvents();
|
||||
await this.store?.close();
|
||||
await this.engineBootstrap.shutdown();
|
||||
}
|
||||
|
||||
private async clearStorage() {
|
||||
@@ -161,30 +301,41 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
// API Methods
|
||||
//
|
||||
async exists(name: string): Promise<boolean> {
|
||||
this.onlyDefault(name);
|
||||
return this.session !== DefaultSessionStatus.REMOVED;
|
||||
return await this.sessionConfigRepository.exists(name);
|
||||
}
|
||||
|
||||
isRunning(name: string): boolean {
|
||||
this.onlyDefault(name);
|
||||
return !!this.session;
|
||||
return !!this.sessions[name];
|
||||
}
|
||||
|
||||
async upsert(name: string, config?: SessionConfig): Promise<void> {
|
||||
this.onlyDefault(name);
|
||||
this.sessionConfig = config;
|
||||
this.log.info({ session: name }, `Saving session...`);
|
||||
await this.sessionAuthRepository.init(name);
|
||||
await this.sessionConfigRepository.saveConfig(name, config || {});
|
||||
this.log.info({ session: name }, `Session saved.`);
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
this.log.info({ session: name }, `Deleting session...`);
|
||||
await this.appsService.removeBySession(this, name);
|
||||
await this.sessionConfigRepository.deleteConfig(name);
|
||||
await this.sessionAuthRepository.clean(name);
|
||||
await this.sessionMeRepository.removeMe(name);
|
||||
await this.sessionWorkerRepository.remove(name);
|
||||
this.log.info({ session: name }, `Session deleted.`);
|
||||
}
|
||||
|
||||
async start(name: string): Promise<SessionDTO> {
|
||||
this.onlyDefault(name);
|
||||
if (this.session) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Session '${this.DEFAULT}' is already started.`,
|
||||
);
|
||||
}
|
||||
this.log.info({ session: name }, `Starting session...`);
|
||||
if (this.isRunning(name)) {
|
||||
this.log.info({ session: name }, `Session is already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = this.log.logger.child({ session: name });
|
||||
logger.level = getPinoLogLevel(this.sessionConfig?.debug);
|
||||
const config = await this.sessionConfigRepository.getConfig(name);
|
||||
await this.sessionAuthRepository.init(name);
|
||||
logger.level = getPinoLogLevel(config?.debug);
|
||||
const loggerBuilder: LoggerBuilder = logger;
|
||||
|
||||
const storage = await this.mediaStorageFactory.build(
|
||||
@@ -197,9 +348,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
this.config.mimetypes,
|
||||
loggerBuilder.child({ name: 'MediaManager' }),
|
||||
);
|
||||
|
||||
const webhook = new WebhookConductor(loggerBuilder);
|
||||
const proxyConfig = this.getProxyConfig();
|
||||
const proxyConfig = this.getProxyConfig(name, config);
|
||||
const sessionConfig: SessionParams = {
|
||||
name,
|
||||
mediaManager,
|
||||
@@ -207,8 +357,8 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
printQR: this.engineConfigService.shouldPrintQR,
|
||||
sessionStore: this.store,
|
||||
proxyConfig: proxyConfig,
|
||||
sessionConfig: this.sessionConfig,
|
||||
ignore: this.ignoreChatsConfig(this.sessionConfig),
|
||||
sessionConfig: config,
|
||||
ignore: this.ignoreChatsConfig(config),
|
||||
};
|
||||
if (this.EngineClass === WhatsappSessionWebJSCore) {
|
||||
sessionConfig.engineConfig = this.webjsEngineConfigService.getConfig();
|
||||
@@ -216,15 +366,17 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
sessionConfig.engineConfig = this.wppEngineConfigService.getConfig();
|
||||
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
|
||||
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
|
||||
} else if (this.EngineClass === WhatsappSessionNoWebCore) {
|
||||
sessionConfig.engineConfig =
|
||||
await this.nowebEngineConfigService.getConfig();
|
||||
}
|
||||
await this.sessionAuthRepository.init(name);
|
||||
// @ts-ignore
|
||||
const session = new this.EngineClass(sessionConfig);
|
||||
this.session = session;
|
||||
this.updateSession();
|
||||
this.sessions[name] = session;
|
||||
this.updateSessions();
|
||||
|
||||
// configure webhooks
|
||||
const webhooks = this.getWebhooks();
|
||||
const webhooks = this.getWebhooks(config);
|
||||
webhook.configure(session, webhooks);
|
||||
|
||||
// Apps
|
||||
@@ -243,9 +395,6 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
await this.appsService.afterSessionStart(session, this.store);
|
||||
}
|
||||
|
||||
// Apps
|
||||
await this.appsService.afterSessionStart(session, this.store);
|
||||
|
||||
return {
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
@@ -253,26 +402,35 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
private updateSession() {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
const session: WhatsappSession = this.session as WhatsappSession;
|
||||
private updateSessions() {
|
||||
const sessions = Object.values(this.sessions);
|
||||
for (const eventName in WAHAEvents) {
|
||||
const event = WAHAEvents[eventName];
|
||||
const stream$ = session
|
||||
.getEventObservable(event)
|
||||
.pipe(map(populateSessionInfo(event, session)));
|
||||
this.events2.get(event).switch(stream$);
|
||||
const streams = [];
|
||||
for (const session of sessions) {
|
||||
const stream$ = session
|
||||
.getEventObservable(event)
|
||||
.pipe(map(populateSessionInfo(event, session)), share());
|
||||
this.events2.get(session.name).get(event).switch(stream$);
|
||||
streams.push(stream$);
|
||||
}
|
||||
this.events2
|
||||
.get(ALL)
|
||||
.get(event)
|
||||
.switch(merge(...streams));
|
||||
}
|
||||
}
|
||||
|
||||
getSessionEvent(session: string, event: WAHAEvents): Observable<any> {
|
||||
return this.events2.get(event);
|
||||
return this.events2.get(session).get(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop session
|
||||
* @param name
|
||||
* @param silent - if true, throw error if session is not stopped successfully
|
||||
*/
|
||||
async stop(name: string, silent: boolean): Promise<void> {
|
||||
this.onlyDefault(name);
|
||||
if (!this.isRunning(name)) {
|
||||
this.log.debug({ session: name }, `Session is not running.`);
|
||||
return;
|
||||
@@ -283,50 +441,43 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
const session = this.getSession(name);
|
||||
await session.stop();
|
||||
} catch (err) {
|
||||
this.log.warn(`Error while stopping session '${name}'`);
|
||||
this.log.warn({ session: name }, `Error while stopping session`);
|
||||
if (!silent) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
this.log.info({ session: name }, `Session has been stopped.`);
|
||||
this.session = DefaultSessionStatus.STOPPED;
|
||||
this.updateSession();
|
||||
delete this.sessions[name];
|
||||
this.updateSessions();
|
||||
await sleep(this.SESSION_STOP_TIMEOUT);
|
||||
}
|
||||
|
||||
async unpair(name: string) {
|
||||
if (!this.session) {
|
||||
const session = this.sessions[name];
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const session = this.session as WhatsappSession;
|
||||
|
||||
this.log.info({ session: name }, 'Unpairing the device from account...');
|
||||
await session.unpair().catch((err) => {
|
||||
this.log.warn(`Error while unpairing from device: ${err}`);
|
||||
});
|
||||
await sleep(1000);
|
||||
await sleep(this.SESSION_UNPAIR_TIMEOUT);
|
||||
}
|
||||
|
||||
async logout(name: string): Promise<void> {
|
||||
this.onlyDefault(name);
|
||||
this.log.info({ session: name }, `Logging out session...`);
|
||||
await this.sessionAuthRepository.clean(name);
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
this.onlyDefault(name);
|
||||
await this.appsService.removeBySession(this, name);
|
||||
this.session = DefaultSessionStatus.REMOVED;
|
||||
this.updateSession();
|
||||
this.sessionConfig = undefined;
|
||||
await this.sessionMeRepository.removeMe(name);
|
||||
this.log.info({ session: name }, `Session has been logged out.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine per session and global webhooks
|
||||
*/
|
||||
private getWebhooks() {
|
||||
private getWebhooks(config: SessionConfig) {
|
||||
let webhooks: WebhookConfig[] = [];
|
||||
if (this.sessionConfig?.webhooks) {
|
||||
webhooks = webhooks.concat(this.sessionConfig.webhooks);
|
||||
if (config?.webhooks) {
|
||||
webhooks = webhooks.concat(config.webhooks);
|
||||
}
|
||||
const globalWebhookConfig = this.config.getWebhookConfig();
|
||||
if (globalWebhookConfig) {
|
||||
@@ -338,113 +489,163 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
/**
|
||||
* Get either session's or global proxy if defined
|
||||
*/
|
||||
protected getProxyConfig(): ProxyConfig | undefined {
|
||||
if (this.sessionConfig?.proxy) {
|
||||
return this.sessionConfig.proxy;
|
||||
protected getProxyConfig(
|
||||
name: string,
|
||||
config?: SessionConfig,
|
||||
): ProxyConfig | undefined {
|
||||
if (config?.proxy) {
|
||||
return config.proxy;
|
||||
}
|
||||
if (!this.session) {
|
||||
return undefined;
|
||||
}
|
||||
const sessions = { [this.DEFAULT]: this.session as WhatsappSession };
|
||||
return getProxyConfig(this.config, sessions, this.DEFAULT);
|
||||
return getProxyConfig(this.config, this.sessions, name);
|
||||
}
|
||||
|
||||
getSession(name: string): WhatsappSession {
|
||||
this.onlyDefault(name);
|
||||
const session = this.session;
|
||||
const session = this.sessions[name];
|
||||
if (!session) {
|
||||
throw new NotFoundException(
|
||||
`We didn't find a session with name '${name}'.\n` +
|
||||
`Please start it first by using POST /api/sessions/${name}/start request`,
|
||||
);
|
||||
}
|
||||
return session as WhatsappSession;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runtime sessions
|
||||
*/
|
||||
private getRuntimeSessions(name: string = null): SessionInfo[] {
|
||||
let names = Object.keys(this.sessions);
|
||||
if (name) {
|
||||
names = names.filter((n) => n === name);
|
||||
}
|
||||
const sessions = names.map((sessionName) => {
|
||||
const status = this.sessions[sessionName].status;
|
||||
const sessionConfig = this.sessions[sessionName].sessionConfig;
|
||||
const me = this.sessions[sessionName].getSessionMeInfo();
|
||||
return {
|
||||
name: sessionName,
|
||||
status: status,
|
||||
config: sessionConfig,
|
||||
me: me,
|
||||
presence: this.sessions[sessionName].presence,
|
||||
timestamps: {
|
||||
activity: this.sessions[sessionName].getLastActivityTimestamp(),
|
||||
},
|
||||
};
|
||||
});
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions
|
||||
* Even tho it's "offline", it usually contains both offline and online sessions
|
||||
**/
|
||||
private async getOfflineSessions(
|
||||
name: string = null,
|
||||
): Promise<SessionInfo[]> {
|
||||
let names = await this.sessionConfigRepository.getAllConfigs();
|
||||
if (name) {
|
||||
names = names.filter((n) => n === name);
|
||||
}
|
||||
const configBySession =
|
||||
await this.sessionConfigRepository.getConfigBySessions(names);
|
||||
const meBySession = await this.sessionMeRepository.getMeBySessions(names);
|
||||
const sessions = names.map((sessionName) => {
|
||||
const status = WAHASessionStatus.STOPPED;
|
||||
return {
|
||||
name: sessionName,
|
||||
status: status,
|
||||
config: configBySession.get(sessionName) ?? null,
|
||||
me: meBySession.get(sessionName) ?? null,
|
||||
presence: null,
|
||||
timestamps: {
|
||||
activity: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async getSessions(all: boolean): Promise<SessionInfo[]> {
|
||||
if (this.session === DefaultSessionStatus.STOPPED && all) {
|
||||
return [
|
||||
{
|
||||
name: this.DEFAULT,
|
||||
status: WAHASessionStatus.STOPPED,
|
||||
config: this.sessionConfig,
|
||||
me: null,
|
||||
presence: null,
|
||||
timestamps: {
|
||||
activity: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (this.session === DefaultSessionStatus.REMOVED && all) {
|
||||
return [];
|
||||
}
|
||||
if (!this.session && !all) {
|
||||
return [];
|
||||
const runtimeSessions = this.getRuntimeSessions();
|
||||
let offlineSessions: SessionInfo[] = [];
|
||||
if (all) {
|
||||
offlineSessions = await this.getOfflineSessions();
|
||||
}
|
||||
// Merge runtime and offline by name
|
||||
// Runtime one will overwrite offline one
|
||||
const sessions = lodash.keyBy(
|
||||
[...offlineSessions, ...runtimeSessions],
|
||||
'name',
|
||||
);
|
||||
|
||||
const session = this.session as WhatsappSession;
|
||||
const me = session?.getSessionMeInfo();
|
||||
return [
|
||||
{
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
config: session.sessionConfig,
|
||||
me: me,
|
||||
presence: session.presence,
|
||||
timestamps: {
|
||||
activity: session?.getLastActivityTimestamp(),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
// Get assigned worker
|
||||
const workersInfo = await this.sessionWorkerRepository.getAll();
|
||||
const workerBySession = lodash.keyBy(workersInfo, 'id');
|
||||
Object.keys(sessions).forEach((sessionName) => {
|
||||
sessions[sessionName].assignedWorker =
|
||||
workerBySession[sessionName]?.worker;
|
||||
});
|
||||
|
||||
private async fetchEngineInfo() {
|
||||
const session = this.session as WhatsappSession;
|
||||
// Get engine info
|
||||
let engineInfo = {};
|
||||
if (session) {
|
||||
try {
|
||||
engineInfo = await promiseTimeout(1000, session.getEngineInfo());
|
||||
} catch (error) {
|
||||
this.log.debug(
|
||||
{ session: session.name, error: `${error}` },
|
||||
'Can not get engine info',
|
||||
);
|
||||
}
|
||||
}
|
||||
const engine = {
|
||||
engine: session?.engine,
|
||||
...engineInfo,
|
||||
};
|
||||
return engine;
|
||||
return Object.values(sessions);
|
||||
}
|
||||
|
||||
async getSessionInfo(name: string): Promise<SessionDetailedInfo | null> {
|
||||
this.onlyDefault(name);
|
||||
const sessions = await this.getSessions(true);
|
||||
if (sessions.length === 0) {
|
||||
let session: SessionDetailedInfo = null;
|
||||
|
||||
// Try to find session in runtime sessions
|
||||
const runtimeSessions = this.getRuntimeSessions(name);
|
||||
if (runtimeSessions.length === 1) {
|
||||
session = runtimeSessions[0];
|
||||
}
|
||||
|
||||
// If session is not found in runtime sessions,
|
||||
// try to find it in offline sessions
|
||||
if (!session) {
|
||||
const offlineSessions = await this.getOfflineSessions(name);
|
||||
if (offlineSessions.length === 1) {
|
||||
session = offlineSessions[0];
|
||||
}
|
||||
}
|
||||
|
||||
// No session found
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const session = sessions[0];
|
||||
const engine = await this.fetchEngineInfo();
|
||||
|
||||
// If session is found, get engine info
|
||||
const engine = await this.fetchEngineInfo(name);
|
||||
return {
|
||||
...session,
|
||||
engine: engine,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchEngineInfo(sessionName: string) {
|
||||
// Get engine info
|
||||
if (!this.sessions[sessionName]) {
|
||||
return {};
|
||||
}
|
||||
const session = this.sessions[sessionName];
|
||||
let engineInfo = {};
|
||||
try {
|
||||
engineInfo = await promiseTimeout(3_000, session.getEngineInfo());
|
||||
} catch (error) {
|
||||
this.log.debug(
|
||||
{ session: session.name, error: `${error}` },
|
||||
'Can not get engine info',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
engine: this.sessions[sessionName]?.engine,
|
||||
...engineInfo,
|
||||
};
|
||||
}
|
||||
|
||||
protected stopEvents() {
|
||||
complete(this.events2);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.store.init();
|
||||
const knex = this.store.getWAHADatabase();
|
||||
await this.appsService.migrate(knex);
|
||||
for (const events of this.events2.values()) {
|
||||
complete(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface Message {
|
||||
interface File {
|
||||
extension: string;
|
||||
filename?: string;
|
||||
mimetype?: string;
|
||||
}
|
||||
|
||||
export interface MediaData {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -108,7 +108,7 @@ export class MediaLocalStorage implements IMediaStorage {
|
||||
this.log.debug(`File ${filepath} was removed`);
|
||||
}),
|
||||
delay,
|
||||
);
|
||||
).unref();
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
getMetadata,
|
||||
IMediaStorage,
|
||||
MediaData,
|
||||
MediaStorageData,
|
||||
} from '@waha/core/media/IMediaStorage';
|
||||
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
class MediaFileRepository extends PsqlFileRepository {
|
||||
get tableName() {
|
||||
return 'media';
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaPsqlStorage implements IMediaStorage {
|
||||
repository: MediaFileRepository;
|
||||
|
||||
constructor(
|
||||
private knex: Knex.Knex,
|
||||
private baseUrl: string,
|
||||
private logger: Logger,
|
||||
) {
|
||||
this.repository = new MediaFileRepository(knex, logger);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
private getFilepath(data: MediaData) {
|
||||
return `${data.message.id}.${data.file.extension}`;
|
||||
}
|
||||
|
||||
async save(buffer: Buffer, data: MediaData): Promise<boolean> {
|
||||
const filepath = this.getFilepath(data);
|
||||
const metadata = getMetadata(data);
|
||||
await this.repository.save(filepath, buffer, metadata);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(data: MediaData): Promise<boolean> {
|
||||
const filepath = this.getFilepath(data);
|
||||
return await this.repository.exists(filepath);
|
||||
}
|
||||
|
||||
async fetch(filepath: string) {
|
||||
return await this.repository.fetch(filepath);
|
||||
}
|
||||
|
||||
async remove(filepath: string) {
|
||||
return await this.repository.delete(filepath);
|
||||
}
|
||||
|
||||
async getStorageData(data: MediaData): Promise<MediaStorageData> {
|
||||
const filepath = this.getFilepath(data);
|
||||
const url = `${this.baseUrl}${data.session}/${filepath}`;
|
||||
return { url: url };
|
||||
}
|
||||
|
||||
async purge(): Promise<void> {
|
||||
this.logger.debug('Purging S3 bucket is not supported');
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.knex.destroy();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user