Compare commits

..
32 Commits
Author SHA1 Message Date
devlikepro 7e719d8894 [core] 2026.7.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-15 20:04:29 +07:00
devlikepro 8faa3ca5a4 [core] Up dashboard 2026-07-15 20:04:29 +07:00
devlikepro 5dd8bf140d [core] Up WEBJS 2026-07-15 20:04:29 +07:00
devlikepro 0501c37500 [core] WEBJS - add lid to /me 2026-07-15 20:04:29 +07:00
devlikepro 1496274a99 [core] Up WEBJS - fix _serialized id rename fix #2157 fix #2158 fix #2159 fix #2160 fix #2162 2026-07-15 20:04:29 +07:00
devlikepro b6ba3951da [core] Up NOWEB 2026-07-15 20:04:29 +07:00
devlikepro a4a3dc93ad [core] Up WPP - fix _serialized issues 2026-07-15 20:04:28 +07:00
devlikepro 57eb0e14ca [core] Up dashboard - fix image emoji 2026-07-15 20:04:28 +07:00
devlikepro d8970326be [core] GOWS - fix "Session silently stops sending webhook events after <stream:error> (media ack)" - fix #2151 2026-07-15 20:04:28 +07:00
devlikepro 06412c2c1e [core] Up WEBJS - fix sending image with "msg.avParams is not a function" fix #2149 2026-07-15 20:04:28 +07:00
devlikepro 0b39645c50 [core] Update Dashboard - passkey flow driven by session.status
Picks up the UI side of the passkey rework: no more passkey.* events,
PASSKEY_CONFIRMATION_REQUIRED handled, GET /auth/passkey/challenge.
2026-07-15 20:04:28 +07:00
devlikepro 76f8cc6f53 [core] Don't watch src/dashboard assets - fixes ENOSPC inotify limit on start:dev 2026-07-15 20:04:28 +07:00
devlikepro ea79b1d886 [core] Passkey - collapse events into session.status, add GET /auth/passkey/challenge
- Remove 'passkey.required' and 'passkey.confirmation.required' events.
  Passkey pairing is a session state, so it rides on 'session.status'
  instead - one new status value per pairing step WhatsApp adds, not
  one new event.
- Add PASSKEY_CONFIRMATION_REQUIRED session status.
- Add 'data' to the session.status payload - the extra info that belongs
  to the current status. PASSKEY_REQUIRED carries the WebAuthn challenge,
  PASSKEY_CONFIRMATION_REQUIRED carries { code }, null otherwise.
- Base session gets setStatus(status, data); the plain 'status = value'
  setter delegates to it with no data, so the data clears itself as soon
  as the session moves on (WORKING, STOPPED, ...) with no extra bookkeeping.
- REST: GET /auth/passkey/challenge (was GET /auth/passkey) returns the
  challenge object, GET /auth/passkey/confirmation returns { code }.
  Both throw 422 when nothing is pending.
- MCP: auth-passkey-challenge, auth-passkey-submit, auth-passkey-confirmation,
  auth-passkey-confirm.
- Drop the SkipHandoffUX auto-confirm branch - it was dead code. whatsmeow
  confirms on its own in that case and only emits passkey-confirmation for
  the manual one (qrchan.go).
- Keep QR rotation from bouncing PASSKEY_CONFIRMATION_REQUIRED back to
  SCAN_QR_CODE, same as PASSKEY_REQUIRED.
2026-07-15 20:04:27 +07:00
devlikepro e54604eb97 [core] rm settings.local.json 2026-07-15 20:04:27 +07:00
Berg Pinheiro d267a29e87 [core] Update Dashboard - adds Passkey UI (extension-assisted + manual DevTools fallback) 2026-07-15 20:04:27 +07:00
Berg Pinheiro d4625359e6 [core] Up GOWS - v1.0.43, adds SubmitPasskeyResponse/ConfirmPasskey RPCs 2026-07-15 20:04:27 +07:00
Berg Pinheiro 3618b92c3e feat(passkey): Add Passkey (WebAuthn) session pairing
- New engine step: gows emits passkey-request/passkey-confirmation, session
  status PASSKEY_REQUIRED, challenge stored and exposed via getPasskeyChallenge().
- REST: GET/POST /api/:session/auth/passkey, GET /api/:session/auth/passkey/confirmation,
  POST /api/:session/auth/passkey/confirm.
- Webhooks: passkey.required (challenge) and passkey.confirmation.required (manual
  code case; most pairings auto-confirm server-side right after the assertion).
- QR rotation no longer bounces PASSKEY_REQUIRED back to SCAN_QR_CODE.
2026-07-15 20:04:27 +07:00
devlikepro 982092cf2b [core] MCP - do no share real api key for media and auth - qr or screenshot - fix #2146 2026-07-15 20:04:27 +07:00
devlikepro 6a452cd66e [core] Up WPP. Use 'main' branch versions for wa-js and wppconnect 2026-07-15 20:04:27 +07:00
devlikepro 19625e38e9 [core] Up NOWEB. Fix chat history ordering fix #2139. 2026-07-15 20:04:27 +07:00
devlikepro 208f4f3d78 [core] GOWS - fix document media — 403 on live media + media-retry never completes/refreshes directPath - fix ##2131
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-06-27 16:49:26 +07:00
devlikepro a9ff7d763d [core] GOWS - use gows-plus repo 2026-06-27 16:49:26 +07:00
devlikepro e290fd545f [core] make release 2026-06-27 16:32:05 +07:00
devlikepro 84f111e2c1 [core] 2026.6.2 2026-06-27 16:32:05 +07:00
devlikepro 55dddd5991 [core] WEBJS - remove all mentions of window.WAHA 2026-06-27 16:32:05 +07:00
devlikepro 638555297c [core] Up WEBJS 2026-06-27 16:32:04 +07:00
devlikepro 0e4de03da3 [core] Up GOWS 2026-06-27 16:32:04 +07:00
devlikepro c6219be356 [core] Up NOWEB 2026-06-27 16:32:04 +07:00
devlikepro 2460a23cab [core] Up WPP 2026-06-27 16:32:04 +07:00
devlikepro c2ed34a171 [core] 2026.6.1
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
2026-06-22 15:14:17 +07:00
devlikepro 1db8ae3423 [core] Merge PLUS functionality into CORE
Copy the storage (Mongo/Postgres/SQLite), media (S3/Postgres), engine
auth/store and util implementations from src/plus into src/core, and fold
the *Plus engine sessions, session manager, factories, health and channels
services into their *Core classes.

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

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)",
"Bash(pre-commit run:*)",
"Bash(yarn test:*)"
]
}
}
+14 -3
View File
@@ -41,11 +41,11 @@ up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
up-wpp:
yarn up @wppconnect-team/wppconnect
yarn up @wppconnect/wa-js
yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
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 -6
View File
@@ -5,12 +5,12 @@
"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
}
}
+3 -3
View File
@@ -60,8 +60,8 @@
"@types/passport": "^1.0.17",
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^2.2.1",
"@wppconnect/wa-js": "^4.3.0",
"@wppconnect-team/wppconnect": "github:wppconnect-team/wppconnect#master",
"@wppconnect/wa-js": "github:wppconnect-team/wa-js#main",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
@@ -119,7 +119,7 @@
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1",
"zod": "^4.3.6"
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
'use strict';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { execFileSync } = require('child_process');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readline = require('readline');
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
const CORE_PREFIX = '[core]';
const PLUS_PREFIX = '[PLUS]';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const assumeYes = args.includes('--yes') || args.includes('-y');
function git(gitArgs, options) {
const opts = options || {};
return execFileSync('git', gitArgs, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
}
function gitOut(gitArgs) {
return git(gitArgs, { inherit: false }).trim();
}
function log(message) {
console.log(message);
}
function fail(message) {
console.error(`\n✗ ${message}`);
process.exit(1);
}
function ensureCleanTree() {
const status = gitOut(['status', '--porcelain']);
if (status) {
fail(
'Working tree is not clean. Commit or stash your changes before releasing.',
);
}
}
function ensureBranchExists(branch) {
try {
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
} catch {
fail(`Branch "${branch}" does not exist locally.`);
}
}
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
// plus every release, so `plus..dev` is exactly the new, unreleased work.
// We intentionally do not use `git cherry` (patch-id matching) because core is
// a rewritten history whose old commits share no patch-ids with dev.
function commitsToCherryPick(boundary, head, prefix) {
const format = '%H%x09%s';
const output = gitOut([
'rev-list',
'--reverse',
'--no-merges',
`--format=${format}`,
`${boundary}..${head}`,
]);
if (!output) {
return [];
}
const picks = [];
for (const line of output.split('\n')) {
// rev-list --format prefixes each entry with a "commit <sha>" header line.
if (!line || line.startsWith('commit ')) {
continue;
}
const tab = line.indexOf('\t');
const sha = line.slice(0, tab);
const subject = line.slice(tab + 1);
if (subject.startsWith(prefix)) {
picks.push({ sha: sha, subject: subject });
}
}
return picks;
}
function cherryPickAll(picks) {
for (const pick of picks) {
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
if (dryRun) {
continue;
}
try {
git(['cherry-pick', pick.sha], { inherit: true });
} catch {
git(['cherry-pick', '--abort'], { inherit: true });
fail(
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
`Aborted the cherry-pick — resolve manually and re-run.`,
);
}
}
}
function checkout(branch) {
log(`\n→ checkout ${branch}`);
if (!dryRun) {
git(['checkout', branch], { inherit: true });
}
}
function confirm(question) {
if (assumeYes || dryRun) {
return Promise.resolve(true);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(function resolver(resolve) {
rl.question(`${question} [y/N] `, function onAnswer(answer) {
rl.close();
resolve(/^y(es)?$/i.test(answer.trim()));
});
});
}
async function main() {
ensureCleanTree();
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
if (dryRun) {
log('Running in --dry-run mode: no branches will be modified.\n');
}
// Capture the last-released plus tip before we touch anything. Step 3 merges
// core into plus and moves the branch, so both pick sets must be computed
// against this saved boundary, not the live plus ref.
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
log(
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
// Step 1 + 2: bring missing [core] commits onto core.
checkout(CORE_BRANCH);
cherryPickAll(corePicks);
// Step 3: merge the freshly updated core into plus.
checkout(PLUS_BRANCH);
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
if (!dryRun) {
try {
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
} catch {
git(['merge', '--abort'], { inherit: true });
fail(
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the merge — resolve manually and re-run.`,
);
}
}
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
log(
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
cherryPickAll(plusPicks);
// Step 5: rebase dev onto the freshly built plus.
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
const proceed = await confirm(
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
);
if (!proceed) {
fail('Aborted before rebasing dev. core/plus changes are kept.');
}
checkout(DEV_BRANCH);
if (!dryRun) {
try {
git(['rebase', PLUS_BRANCH], { inherit: true });
} catch {
git(['rebase', '--abort'], { inherit: true });
fail(
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the rebase — resolve manually and re-run.`,
);
}
}
if (dryRun) {
checkout(startBranch);
log('\nDry run complete. Re-run without --dry-run to apply.');
} else {
log(
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
);
log(
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
);
}
}
main().catch(function onError(error) {
fail(error.message || String(error));
});
+26 -2
View File
@@ -12,12 +12,16 @@ import {
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CanServer, CanSession, FromBody } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import {
ApiKeyDTO,
ApiKeyRequest,
ScopedApiKeyRequest,
} from '@waha/structures/apikeys.dto';
@ApiSecurity('api_key')
@Controller('api/keys')
@@ -44,6 +48,26 @@ export class ApiKeysController {
return this.service.list();
}
@Post('/media')
@ApiOperation({
summary: 'Create or get a media-download-only API key for a session',
})
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetMediaKey(body.session);
}
@Post('/control')
@ApiOperation({
summary: 'Create or get a control-only API key for a session',
})
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetControlKey(body.session);
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
+57 -1
View File
@@ -7,7 +7,12 @@ import {
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import {
QRCodeSessionParam,
@@ -19,6 +24,9 @@ import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import {
PasskeyAssertionRequest,
PasskeyChallenge,
PasskeyConfirmationResponse,
QRCodeFormat,
QRCodeQuery,
QRCodeValue,
@@ -68,6 +76,54 @@ class AuthController {
) {
return session.requestCode(request.phoneNumber, request.method, request);
}
@Get('passkey/challenge')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey (WebAuthn) challenge.',
description:
'Available while the session is in PASSKEY_REQUIRED status. ' +
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
'on the https://web.whatsapp.com origin.',
})
@ApiOkResponse({ type: PasskeyChallenge })
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
return session.getPasskeyChallenge();
}
@Post('passkey')
@SessionApiParam
@ApiOperation({
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
})
submitPasskey(
@SessionParam session: WhatsappSession,
@Body() request: PasskeyAssertionRequest,
) {
return session.sendPasskeyResponse(JSON.stringify(request));
}
@Get('passkey/confirmation')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey confirmation code.',
description:
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
})
@ApiOkResponse({ type: PasskeyConfirmationResponse })
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
return session.getPasskeyConfirmation();
}
@Post('passkey/confirm')
@SessionApiParam
@ApiOperation({
summary: 'Confirm passkey pairing (only needed for the manual code case).',
})
confirmPasskey(@SessionParam session: WhatsappSession) {
return session.confirmPasskey();
}
}
export { AuthController };
+24
View File
@@ -31,4 +31,28 @@ export class McpController {
});
return ImageMcpResponse(Buffer.from(response.data));
}
protected async scopedApiKey(
url: string,
session: string,
): Promise<string | null> {
const response = await this.request({
method: 'POST',
url: url,
data: { session: session },
});
if (response.status >= 200 && response.status < 300) {
return response.data?.key ?? null;
}
// e.g. 403 (caller lacks the scope) or 422 (session missing) → fall back
return null;
}
protected async mediaApiKey(session: string): Promise<string | null> {
return this.scopedApiKey('/api/keys/media', session);
}
protected async controlApiKey(session: string): Promise<string | null> {
return this.scopedApiKey('/api/keys/control', session);
}
}
+2
View File
@@ -19,6 +19,7 @@ import { PresenceTools } from '@waha/apps/mcp/tools/presence.tools';
import { ProfileTools } from '@waha/apps/mcp/tools/profile.tools';
import { StatusTools } from '@waha/apps/mcp/tools/status.tools';
import { ServerTools } from '@waha/apps/mcp/tools/server.tools';
import { KeysTools } from '@waha/apps/mcp/tools/keys.tools';
@Injectable()
export class McpService {
@@ -42,6 +43,7 @@ export class McpService {
new ProfileTools(api),
new StatusTools(api),
new ServerTools(api),
new KeysTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
+104 -4
View File
@@ -3,17 +3,24 @@ import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
AuthPasskeyChallengeInput,
AuthPasskeyConfirmationInput,
AuthPasskeyConfirmInput,
AuthPasskeySubmitInput,
AuthQRInput,
AuthRequestCodeInput,
ScreenshotInput,
} from '@waha/apps/mcp/tools/auth.zod';
function AuthContent(key: string): any {
function AuthContent(key: string | null): any {
const open = key
? `add "?x-api-key=${key}" to the query params (this is a control-only key scoped to this session)`
: `append "?x-api-key=YOUR_API_KEY" to the query params, using the key you already have`;
return {
type: 'text' as const,
text: `
You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +.
If the user wants to open the QR code or screenshot in a browser, add "?x-api-key=${key}" to the query params.
If the user wants to open the QR code or screenshot in a browser, ${open}.
`,
};
}
@@ -39,7 +46,8 @@ export class AuthTools extends McpController {
})
async authQR({ session }: z.infer<typeof AuthQRInput>) {
const result = await this.imageRequest(`/api/${session}/auth/qr`);
result.content.push(AuthContent(this.api.key));
const key = await this.controlApiKey(session);
result.content.push(AuthContent(key));
return result;
}
@@ -58,7 +66,8 @@ export class AuthTools extends McpController {
const result = await this.imageRequest(
`/api/screenshot?session=${session}`,
);
result.content.push(AuthContent(this.api.key));
const key = await this.controlApiKey(session);
result.content.push(AuthContent(key));
return result;
}
@@ -94,4 +103,95 @@ export class AuthTools extends McpController {
});
return result;
}
@Tool('auth-passkey-challenge', {
title: 'Get passkey challenge',
description:
'Get the pending passkey (WebAuthn) challenge for a session in PASSKEY_REQUIRED status. ' +
'Fails with 422 when nothing is pending. ' +
'You cannot sign the challenge yourself - the assertion has to be produced by an authenticator ' +
'on the https://web.whatsapp.com origin (the WAHA browser extension, or the DevTools fallback). ' +
'Hand the challenge to the user, then submit the result with auth-passkey-submit.',
inputSchema: AuthPasskeyChallengeInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyChallenge({
session,
}: z.infer<typeof AuthPasskeyChallengeInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/auth/passkey/challenge`,
});
}
@Tool('auth-passkey-submit', {
title: 'Submit passkey assertion',
description:
'Submit the WebAuthn assertion produced by navigator.credentials.get() to finish passkey pairing. ' +
'Get the challenge from auth-passkey-challenge first. ' +
'After this the session usually goes straight to WORKING; ' +
'if it goes to PASSKEY_CONFIRMATION_REQUIRED instead, follow up with auth-passkey-confirmation.',
inputSchema: AuthPasskeySubmitInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async passkeySubmit({
session,
...body
}: z.infer<typeof AuthPasskeySubmitInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/auth/passkey`,
data: body,
});
}
@Tool('auth-passkey-confirmation', {
title: 'Get passkey confirmation code',
description:
'Get the pending passkey confirmation code for a session in PASSKEY_CONFIRMATION_REQUIRED status. ' +
'Fails with 422 when nothing is pending. ' +
'Show the code to the user, ask them to check it matches the one on their phone, ' +
'then call auth-passkey-confirm.',
inputSchema: AuthPasskeyConfirmationInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyConfirmation({
session,
}: z.infer<typeof AuthPasskeyConfirmationInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/auth/passkey/confirmation`,
});
}
@Tool('auth-passkey-confirm', {
title: 'Confirm passkey pairing',
description:
'Finish passkey pairing after the user confirmed the code matches the one shown on their phone. ' +
'Only call it once the user has verified the code from auth-passkey-confirmation.',
inputSchema: AuthPasskeyConfirmInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async passkeyConfirm({ session }: z.infer<typeof AuthPasskeyConfirmInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/auth/passkey/confirm`,
});
}
}
+20 -1
View File
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { RequestCodeRequest } from '@waha/structures/auth.dto';
import {
PasskeyAssertionRequest,
RequestCodeRequest,
} from '@waha/structures/auth.dto';
export const AuthQRInput = z.object({
session: z.string(),
@@ -13,3 +16,19 @@ export const ScreenshotInput = z.object({
export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({
session: z.string(),
});
export const AuthPasskeyChallengeInput = z.object({
session: z.string(),
});
export const AuthPasskeyConfirmationInput = z.object({
session: z.string(),
});
export const AuthPasskeySubmitInput = DtoToZod(PasskeyAssertionRequest).extend({
session: z.string(),
});
export const AuthPasskeyConfirmInput = z.object({
session: z.string(),
});
+18 -4
View File
@@ -18,10 +18,22 @@ import {
UnpinMessageInput,
} from '@waha/apps/mcp/tools/chats.zod';
function FetchMediaUsingApiKeyContent(key: string) {
function FetchMediaContent(key: string | null) {
if (key) {
return {
type: 'text' as const,
text:
`To fetch media use "X-Api-Key: ${key}" HTTP header. ` +
`To open it in a browser add "?x-api-key=${key}" to the query params. ` +
`This is a media-only API key: it can ONLY download files for this session — ` +
`it cannot read messages, send, or control the session.`,
};
}
return {
type: 'text' as const,
text: `To fetch media use "X-Api-Key: ${key}" HTTP header. If the user wants to open it - add "?x-api-key=${key}" to query params`,
text:
`To fetch media, use your existing WAHA API key in the "X-Api-Key" HTTP header ` +
`(or append "?x-api-key=YOUR_API_KEY" to open it in a browser).`,
};
}
@@ -170,7 +182,8 @@ export class ChatTools extends McpController {
JSON.stringify({ status: response.status, response: responseText }),
);
if (query.downloadMedia) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
const mediaKey = await this.mediaApiKey(session);
result.content.push(FetchMediaContent(mediaKey));
}
return result;
}
@@ -219,7 +232,8 @@ export class ChatTools extends McpController {
params: query,
});
if (query.downloadMedia) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
const mediaKey = await this.mediaApiKey(session);
result.content.push(FetchMediaContent(mediaKey));
}
return result;
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { TextMcpResponse } from '@waha/apps/mcp/responses';
import { ScopedKeyInput } from '@waha/apps/mcp/tools/keys.zod';
export class KeysTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('keys-get-scoped-key', {
title: 'Get a scoped API key',
description:
'Create or get a minimal, scoped API key for a session. ' +
'Use "media" scope for a download-only key to fetch media files, ' +
'or "control" scope for a control-only key to open the QR code / screenshot in a browser. ' +
'The returned key is far weaker than your own key and is safe to hand to the user for that single purpose.',
inputSchema: ScopedKeyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async getScopedKey({ session, scope }: z.infer<typeof ScopedKeyInput>) {
let key: string | null = null;
if (scope === 'media') {
key = await this.mediaApiKey(session);
} else {
key = await this.controlApiKey(session);
}
if (!key) {
return TextMcpResponse(
JSON.stringify({
error:
'Could not mint a scoped API key. Check that the session exists and that your key has access to it.',
}),
);
}
return TextMcpResponse(JSON.stringify({ scope: scope, key: key }));
}
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
const SessionField = z.string().describe('Session name');
export const ScopedKeyInput = z.object({
session: SessionField,
scope: z
.enum(['media', 'control'])
.describe(
'Scope of the key. ' +
'"media" — download-only key for fetching media files of the session. ' +
'"control" — control-only key to open QR code / screenshot in a browser.',
),
});
+85 -29
View File
@@ -1,8 +1,6 @@
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 +63,10 @@ import {
import { distinctUntilChanged, map } from 'rxjs/operators';
import { MessageId } from 'whatsapp-web.js';
import {
PasskeyChallenge,
PasskeyConfirmationResponse,
} from '../../structures/auth.dto';
import {
ChatRequest,
CheckNumberStatusQuery,
@@ -165,6 +167,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 +195,7 @@ export abstract class WhatsappSession {
protected jids: JidFilter;
private _status: WAHASessionStatus;
private _statusData: any = null;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
@@ -192,7 +208,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 +221,7 @@ export abstract class WhatsappSession {
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public mediaConverter: IMediaConverter;
public constructor({
name,
@@ -219,12 +235,13 @@ 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.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -253,46 +270,55 @@ 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
distinctUntilChanged(
(prev, curr) => prev === curr && curr === WAHASessionStatus.WORKING,
(prev, curr) =>
prev.status === curr.status &&
curr.status === WAHASessionStatus.WORKING,
),
// 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 +338,34 @@ 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);
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:
@@ -451,6 +491,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 {
+26 -4
View File
@@ -2,7 +2,7 @@ import * as process from 'node:process';
import { INestApplication, MiddlewareConsumer, Module } from '@nestjs/common';
import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConfigModule } from '@nestjs/config';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
@@ -26,6 +26,10 @@ 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 +38,7 @@ import {
getPinoHttpUseLevel,
getPinoLogLevel,
getPinoTransport,
isDebugEnabled,
redactUrlParams,
} from '@waha/utils/logging';
import * as Joi from 'joi';
@@ -144,10 +149,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,
@@ -189,6 +209,8 @@ export const PROVIDERS_BASE: Provider[] = [
EngineConfigService,
WebsocketGatewayCore,
MediaLocalStorageConfig,
MongoStoreHealthIndicator,
CheckFreeDiskSpaceIndicator,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyAuthService,
@@ -203,7 +225,7 @@ export const PROVIDERS_BASE: Provider[] = [
...AppsModuleExports.providers,
];
const PROVIDERS = [
export const PROVIDERS = [
{
provide: SessionManager,
useClass: SessionManagerCore,
@@ -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');
};
});
}
+4
View File
@@ -18,5 +18,9 @@ export function BuildEventStreamClient(
return new messages.EventStreamClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
// Detect a hung server, so the stream errors out and we reconnect
'grpc.keepalive_time_ms': 30_000,
'grpc.keepalive_timeout_ms': 10_000,
'grpc.keepalive_permit_without_calls': 1,
});
}
+119
View File
@@ -1132,6 +1132,99 @@ export namespace messages {
return PairCodeResponse.deserialize(bytes);
}
}
export class PasskeyResponseRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
response_json?: string;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("response_json" in data && data.response_json != undefined) {
this.response_json = data.response_json;
}
}
}
get session() {
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
}
set session(value: Session) {
pb_1.Message.setWrapperField(this, 1, value);
}
get has_session() {
return pb_1.Message.getField(this, 1) != null;
}
get response_json() {
return pb_1.Message.getFieldWithDefault(this, 2, "") as string;
}
set response_json(value: string) {
pb_1.Message.setField(this, 2, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
response_json?: string;
}): PasskeyResponseRequest {
const message = new PasskeyResponseRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.response_json != null) {
message.response_json = data.response_json;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
response_json?: string;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.response_json != null) {
data.response_json = this.response_json;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_session)
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
if (this.response_json.length)
writer.writeString(2, this.response_json);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): PasskeyResponseRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new PasskeyResponseRequest();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
break;
case 2:
message.response_json = reader.readString();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): PasskeyResponseRequest {
return PasskeyResponseRequest.deserialize(bytes);
}
}
export class Empty extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {}) {
@@ -11053,6 +11146,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,
@@ -11599,6 +11710,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;
@@ -11676,6 +11789,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);
};
@@ -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,
+203
View File
@@ -84,6 +84,7 @@ goog.exportSymbol('proto.messages.Pagination', null, global);
goog.exportSymbol('proto.messages.PairCodeRequest', null, global);
goog.exportSymbol('proto.messages.PairCodeResponse', null, global);
goog.exportSymbol('proto.messages.ParticipantAction', null, global);
goog.exportSymbol('proto.messages.PasskeyResponseRequest', null, global);
goog.exportSymbol('proto.messages.PhoneInfo', null, global);
goog.exportSymbol('proto.messages.PollMessage', null, global);
goog.exportSymbol('proto.messages.PollVoteMessage', null, global);
@@ -395,6 +396,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.PairCodeResponse.displayName = 'proto.messages.PairCodeResponse';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.PasskeyResponseRequest = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.PasskeyResponseRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.PasskeyResponseRequest.displayName = 'proto.messages.PasskeyResponseRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -4190,6 +4212,187 @@ proto.messages.PairCodeResponse.prototype.setCode = function(value) {
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.PasskeyResponseRequest.prototype.toObject = function(opt_includeInstance) {
return proto.messages.PasskeyResponseRequest.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.PasskeyResponseRequest} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.PasskeyResponseRequest.toObject = function(includeInstance, msg) {
var f, obj = {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
responseJson: jspb.Message.getFieldWithDefault(msg, 2, "")
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.PasskeyResponseRequest}
*/
proto.messages.PasskeyResponseRequest.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.PasskeyResponseRequest;
return proto.messages.PasskeyResponseRequest.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.PasskeyResponseRequest} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.PasskeyResponseRequest}
*/
proto.messages.PasskeyResponseRequest.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = new proto.messages.Session;
reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader);
msg.setSession(value);
break;
case 2:
var value = /** @type {string} */ (reader.readString());
msg.setResponseJson(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.PasskeyResponseRequest.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.PasskeyResponseRequest.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.PasskeyResponseRequest} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.PasskeyResponseRequest.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getSession();
if (f != null) {
writer.writeMessage(
1,
f,
proto.messages.Session.serializeBinaryToWriter
);
}
f = message.getResponseJson();
if (f.length > 0) {
writer.writeString(
2,
f
);
}
};
/**
* optional Session session = 1;
* @return {?proto.messages.Session}
*/
proto.messages.PasskeyResponseRequest.prototype.getSession = function() {
return /** @type{?proto.messages.Session} */ (
jspb.Message.getWrapperField(this, proto.messages.Session, 1));
};
/**
* @param {?proto.messages.Session|undefined} value
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.setSession = function(value) {
return jspb.Message.setWrapperField(this, 1, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.clearSession = function() {
return this.setSession(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.PasskeyResponseRequest.prototype.hasSession = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* optional string response_json = 2;
* @return {string}
*/
proto.messages.PasskeyResponseRequest.prototype.getResponseJson = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
/**
* @param {string} value
* @return {!proto.messages.PasskeyResponseRequest} returns this
*/
proto.messages.PasskeyResponseRequest.prototype.setResponseJson = function(value) {
return jspb.Message.setProto3StringField(this, 2, value);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
+511 -25
View File
@@ -34,10 +34,7 @@ import {
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 +43,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 +80,7 @@ import {
ChatRequest,
CheckNumberStatusQuery,
EditMessageRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
@@ -89,6 +92,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -136,7 +140,10 @@ import {
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -192,10 +199,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 {
@@ -409,6 +423,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 +453,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) => {
@@ -839,6 +883,36 @@ 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);
}
@@ -899,12 +973,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 +1116,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()
@@ -1132,22 +1281,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()
@@ -1339,6 +1675,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 +1975,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 +2138,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;
@@ -2555,6 +3022,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 +3139,12 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
} catch (err) {
if (err?.code === grpc.status.DEADLINE_EXCEEDED) {
err.message = `DownloadMedia timed out after ${mediaDownloadTimeoutMs}ms for message '${message?.Info?.ID}'`;
} else if (NON_RETRIABLE_DOWNLOAD_MEDIA_CODES.has(err?.code)) {
// The media is not currently downloadable (e.g. CDN 403 after the
// anonymous + media-retry fallbacks, or the object is gone). Retrying
// the gRPC call won't help and each attempt can block on a media-retry
// wait, so mark it so MediaManager stops retrying.
err.nonRetriable = true;
}
throw err;
}
@@ -3,15 +3,23 @@ import * as path from 'node:path';
import { DataStore } from '@waha/core/abc/DataStore';
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { GowsAuth } from './GowsAuth';
export class GowsAuthFactoryCore {
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
await store.init(name);
const connection = store.getSessionDbURL(name);
return new GowsAuthSimple(connection, 'postgres');
}
protected async buildSqlite3(
store: LocalStore,
name: string,
+41 -2
View File
@@ -1,17 +1,56 @@
import { DataStore } from '../../abc/DataStore';
import { LocalStore } from '../../storage/LocalStore';
import { DataStore } from '@waha/core/abc/DataStore';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
import { join } from 'path';
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
import { useMultiFileAuthState } from './useMultiFileAuthState';
export class NowebAuthFactoryCore {
buildAuth(store: DataStore, name: string) {
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
if (store instanceof PsqlStore) return this.buildPsql(store, name);
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
private async buildMongoAuth(store: MongoStore, name: string) {
const db = store.getSessionDb(name);
const authStore = new NoWebMongoDbAuth(db);
await authStore.init();
return authStore.methods();
}
async buildPsql(store: PsqlStore, name: string) {
const knex = store.buildSessionKnex(name, 'Session/Auth');
const authStore = new NoWebPsqlAuth(knex);
await authStore.init();
return authStore.methods();
}
protected async buildLocalAuth(store: LocalStore, name: string) {
// Quick fix for
// https://github.com/devlikeapro/waha/issues/347
// We can remove it after some time and the fix
// https://github.com/WhiskeySockets/Baileys/pull/824
await this.makeCredsJsonValid(store, name);
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
return { state, saveCreds, close };
}
private async makeCredsJsonValid(store: LocalStore, name: string) {
try {
await store.init(name);
const authFolder = store.getSessionDirectory(name);
const credsPath = join(authFolder, 'creds.json');
await makeSureJsonFile(credsPath);
} catch (error) {
console.error('Failed to fix "creds.json" file, continue...', error);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import type makeWASocket from '@adiwajshing/baileys';
import type { QueryIds } from '@adiwajshing/baileys';
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
import {
ChannelPagination,
ChannelSearchByText,
ChannelSearchByView,
} from '@waha/structures/channels.dto';
enum NewsletterMexQueryIds {
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
}
enum NewsletterXWAPaths {
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
}
/**
* NowebClient - wrapper around baileys to have more methods
*/
export interface NewsletterSearchResponse {
page: ChannelPagination;
newsletters: NewsletterMetadata[];
}
export class NowebClient {
constructor(private sock: ReturnType<typeof makeWASocket>) {}
async searchChannelsByView(
query: ChannelSearchByView,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
view: query.view,
filters: {
country_codes: query.countries,
categories: query.categories,
},
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
async searchChannelsByText(
query: ChannelSearchByText,
): Promise<NewsletterSearchResponse> {
const variables = {
input: {
search_text: query.text,
categories: query.categories,
limit: query.limit,
start_cursor: query.startCursor,
},
};
const queryId =
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
const response = await this.sock.executeWMexQuery(variables, queryId, path);
return parseNewsletterSearchNode(response);
}
}
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
const pageInfo = response.page_info;
const page: ChannelPagination = {
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
};
const newsletterResult: any[] = response.result;
const newsletters = newsletterResult.map(toNewsletterMetadata);
return {
page: page,
newsletters: newsletters as any,
};
}
+410 -30
View File
@@ -36,6 +36,10 @@ 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';
@@ -58,10 +62,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';
@@ -74,7 +75,12 @@ import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { isJidNewsletter, 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 +124,7 @@ import {
MessageReplyRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -137,7 +144,7 @@ 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,
@@ -163,8 +170,11 @@ import { MeInfo } from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
StatusRequest,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -197,6 +207,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 +226,20 @@ 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';
// 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',
@@ -905,12 +923,20 @@ 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;
}
/**
@@ -1061,32 +1087,209 @@ 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
const { imageMessage } = await esm.b.prepareWAMessageMedia(
{ image: content },
{
upload: this.sock.waUploadToServer,
mediaTypeOverride: 'thumbnail-link',
options: { signal: AbortSignal.timeout(10_000) },
},
);
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 +1307,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 +1844,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);
@@ -1896,6 +2131,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 +2268,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 +2381,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) {
@@ -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)',
];
+329
View File
@@ -0,0 +1,329 @@
import { sleep } from '@waha/utils/promiseTimeout';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import * as path from 'path';
import pino, { Logger } from 'pino';
import { AuthStrategy, Client, Events, Store } from 'whatsapp-web.js';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const prettyBytes = require('pretty-bytes');
/* Require Optional Dependencies */
let fs;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
fs = require('fs-extra');
} catch {
// eslint-disable-next-line @typescript-eslint/no-var-requires
fs = undefined;
}
async function isValidPath(path: string) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
async function getFilesizeInBytes(filename: string) {
const stats = await fs.promises.lstat(filename).catch(() => null);
if (!stats) {
return;
}
return stats.size;
}
export interface Zipper {
compress(path: string, archivePath: string): Promise<void>;
uncompress(archivePath: string, path: string): Promise<void>;
}
/**
* Remote-based authentication
* @param {object} options - options
* @param {object} options.store - Remote database store instance
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
*/
export class RemoteAuth implements AuthStrategy {
// Required Files & Dirs in WWebJS to restore session
private REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
// Initial delay sync required for session to be stable enough to recover
private INITIAL_DELAY_MS = 60000;
private readonly clientId: string;
private readonly dataPath: string;
private readonly tempDir: string;
private readonly store: Store;
private readonly logger: Logger;
private client: any;
private userDataDir: string;
private sessionName: string;
private backupSyncRunner: SinglePeriodicJobRunner;
private zipper: Zipper;
constructor(
{ clientId, dataPath, store, backupSyncIntervalMs, logger, zipper } = {
clientId: 'default',
dataPath: undefined,
store: null,
backupSyncIntervalMs: 60000,
zipper: undefined,
logger: undefined,
},
) {
if (!fs)
throw new Error(
'Optional Dependencies [fs-extra] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
const idRegex = /^[-_\w]+$/i;
if (clientId && !idRegex.test(clientId)) {
throw new Error(
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
);
}
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
throw new Error(
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
);
}
if (!store) throw new Error('Remote database store is required.');
this.store = store;
this.clientId = clientId;
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
this.zipper = zipper;
this.logger = logger || pino({ name: RemoteAuth.name });
this.backupSyncRunner = new SinglePeriodicJobRunner(
'RemoteAuth Backup Sync',
backupSyncIntervalMs,
this.logger,
);
}
get compressedSessionPath() {
return `${this.sessionName}.zip`;
}
setup(client: Client) {
this.client = client;
}
async afterBrowserInitialized() {
return;
}
async onAuthenticationNeeded() {
return {
failed: false,
restart: false,
failureEventPayload: undefined,
};
}
async getAuthEventPayload() {
return;
}
async beforeBrowserInitialized() {
const puppeteerOpts = this.client.options.puppeteer;
const sessionDirName = this.clientId
? `RemoteAuth-${this.clientId}`
: 'RemoteAuth';
const dirPath = path.join(this.dataPath, sessionDirName);
if (puppeteerOpts.userDataDir && puppeteerOpts.userDataDir !== dirPath) {
throw new Error(
'RemoteAuth is not compatible with a user-supplied userDataDir.',
);
}
this.userDataDir = dirPath;
this.logger.debug(`User data dir: ${this.userDataDir}`);
this.sessionName = sessionDirName;
this.logger.debug(`Session name: ${this.sessionName}`);
await this.extractRemoteSession();
await this.removeSingletonFiles(dirPath);
this.client.options.puppeteer = {
...puppeteerOpts,
userDataDir: dirPath,
};
}
/**
* Find in direction Singleton* files and try to remove it
* Fix for SingletonLock and other files
*/
private async removeSingletonFiles(dir: string) {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (file.startsWith('Singleton')) {
const filePath = path.join(dir, file);
try {
await fs.promises.rm(filePath, {
maxRetries: 4,
recursive: true,
force: true,
});
} catch (err) {
this.logger.error(err, `Error deleting: ${filePath}`);
}
}
}
}
async logout() {
await this.disconnect();
await this.destroy();
}
async destroy() {
this.backupSyncRunner.stop();
// @ts-ignore
if (this.store.close) {
// @ts-ignore
await this.store.close();
}
}
async disconnect() {
await this.deleteRemoteSession();
await this.deleteLocalSession();
}
async afterAuthReady() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (!sessionExists) {
/* Initial delay sync required for session to be stable enough to recover */
await sleep(this.INITIAL_DELAY_MS);
await this.storeRemoteSession();
this.client.emit(Events.REMOTE_SESSION_SAVED);
}
this.backupSyncRunner.start(async () => {
await this.storeRemoteSession();
});
}
async storeRemoteSession() {
const pathExists = await isValidPath(this.userDataDir);
if (!pathExists) {
this.logger.warn(
'User data dir does not exist. Skipping session backup.',
);
return;
}
await this.compressSession();
await this.store.save({ session: this.sessionName });
await this.removePathSilently(this.compressedSessionPath);
await this.removePathSilently(this.tempDir);
}
async extractRemoteSession() {
await this.removePathSilently(this.userDataDir);
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (!sessionExists) {
fs.mkdirSync(this.userDataDir, { recursive: true });
return;
}
await this.store.extract({
session: this.sessionName,
path: this.compressedSessionPath,
});
await this.removePathSilently(this.userDataDir);
await this.unCompressSession();
await this.removePathSilently(this.compressedSessionPath);
}
private async deleteRemoteSession() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (sessionExists) await this.store.delete({ session: this.sessionName });
}
private async deleteLocalSession() {
await this.removePathSilently(this.userDataDir);
}
async compressSession() {
// Chrome's Storage Partitioning creates symlinks with numeric names inside the
// profile. fs-extra's copy resolves them and throws "subdirectory of itself"
// when it detects a cycle. We skip symlinks entirely — session-critical data
// (IndexedDB, Local Storage, cookies) lives in regular files only.
const skipSymlinks = async (src: string) => {
try {
const lstat = await fs.promises.lstat(src);
return !lstat.isSymbolicLink();
} catch {
return false;
}
};
await fs.copy(this.userDataDir, this.tempDir, { filter: skipSymlinks });
await this.deleteMetadata();
this.logger.debug('Compressing session...');
await this.zipper.compress(this.tempDir, this.compressedSessionPath);
this.logger.debug('Session compressed.');
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
this.logger.debug(`Session archive size: ${prettyBytes(zipSize)}`);
}
async unCompressSession() {
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
this.logger.debug(`Restored Session archive size: ${prettyBytes(zipSize)}`);
this.logger.debug('Uncompressing session...');
await this.zipper.uncompress(this.compressedSessionPath, this.userDataDir);
this.logger.debug('Session uncompressed.');
}
async deleteMetadata() {
const sessionDirs = [this.tempDir, path.join(this.tempDir, 'Default')];
for (const dir of sessionDirs) {
const sessionFiles = await fs.promises.readdir(dir);
for (const element of sessionFiles) {
if (this.REQUIRED_DIRS.includes(element)) {
continue;
}
const dirElement = path.join(dir, element);
await this.removePathSilently(dirElement);
}
}
}
private async removePathSilently(path: string) {
const exists = await isValidPath(path);
if (!exists) {
return;
}
try {
await fs.promises.rm(path, {
maxRetries: 4,
recursive: true,
force: true,
});
} catch (err) {
this.logger.error(err, `Error deleting: ${path}`);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
let AdmZip;
let archiver;
import * as fs from 'fs';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
AdmZip = require('adm-zip');
// eslint-disable-next-line @typescript-eslint/no-var-requires
archiver = require('archiver');
} catch {
// eslint-disable-next-line @typescript-eslint/no-var-requires
AdmZip = undefined;
// eslint-disable-next-line @typescript-eslint/no-var-requires
archiver = undefined;
}
export class StreamZipper implements Zipper {
constructor() {
if (!archiver && !AdmZip)
throw new Error(
'Optional Dependencies [archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
}
compress(path: string, archivePath: string): Promise<void> {
const archive = archiver('zip');
const stream = fs.createWriteStream(archivePath);
return new Promise((resolve, reject) => {
archive
.directory(path, false)
.on('error', (err) => reject(err))
.pipe(stream);
stream.on('close', () => resolve(null));
archive.finalize();
});
}
async uncompress(archivePath: string, path: string): Promise<void> {
const zip = new AdmZip(archivePath);
await new Promise((resolve, reject) => {
zip.extractAllToAsync(path, false, false, (err) => {
if (err) {
reject(err);
} else {
resolve(null);
}
});
});
}
}
@@ -0,0 +1,91 @@
import { DataStore } from '@waha/core/abc/DataStore';
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { RemoteAuth, Zipper } from '@waha/core/engines/webjs/RemoteAuth';
import { StreamZipper } from '@waha/core/engines/webjs/StreamZipper';
import { WebJSMongoAuth } from '@waha/core/engines/webjs/WebJSMongoAuth';
import { ZipUnzipZipper } from '@waha/core/engines/webjs/ZipUnzipZipper';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { LoggerBuilder } from '@waha/utils/logging';
import { Logger } from 'pino';
import { AuthStrategy } from 'whatsapp-web.js';
import { WebJSPsqlAuth } from './WebJSPsqlAuth';
export class WebJSAuthFactory {
async buildAuth(
store: DataStore,
name: string,
loggerBuilder: LoggerBuilder,
): Promise<AuthStrategy> {
if (store instanceof MongoStore)
return this.buildMongoAuth(store, name, loggerBuilder);
if (store instanceof PsqlStore)
return await this.buildPsql(store, name, loggerBuilder);
if (store instanceof LocalStore)
return this.buildLocalAuth(store, name, loggerBuilder);
throw new Error(`Unsupported store type '${store.constructor.name}'`);
}
buildLocalAuth(
store: LocalStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: LocalAuth.name });
return new LocalAuth({
clientId: name,
dataPath: store.getSessionDirectory(name),
logger: logger,
rmMaxRetries: undefined,
});
}
async buildPsql(
store: PsqlStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: WebJSPsqlAuth.name });
const knex = store.buildSessionKnex(name, 'Session/Auth');
const authStore = new WebJSPsqlAuth(knex, logger);
await authStore.init();
const zipper = this.getAvailableZipper(logger);
return new RemoteAuth({
backupSyncIntervalMs: 60 * 1000,
clientId: name,
dataPath: null,
logger: loggerBuilder.child({ name: RemoteAuth.name }),
store: authStore,
zipper: zipper,
});
}
private buildMongoAuth(
store: MongoStore,
name: string,
loggerBuilder: LoggerBuilder,
) {
const logger = loggerBuilder.child({ name: WebJSMongoAuth.name });
const authStore = new WebJSMongoAuth(name, store, logger);
const zipper = this.getAvailableZipper(logger);
return new RemoteAuth({
backupSyncIntervalMs: 60 * 1000,
clientId: name,
dataPath: null,
logger: loggerBuilder.child({ name: RemoteAuth.name }),
store: authStore,
zipper: zipper,
});
}
private getAvailableZipper(logger: Logger): Zipper {
if (process.env.WAHA_ZIPPER == 'ZIPUNZIP') {
logger.debug('Using ZipUnzipZipper');
return new ZipUnzipZipper();
}
logger.debug('Using StreamZipper');
return new StreamZipper();
}
}
+147
View File
@@ -0,0 +1,147 @@
import { sleep } from '@nestjs/terminus/dist/utils';
import * as fs from 'fs';
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
import { Logger } from 'pino';
import { pipeline } from 'stream/promises';
import { Store } from 'whatsapp-web.js';
import { MongoStore } from '../../storage/mongo/MongoStore';
class WebJSMongoAuth implements Store {
private store: MongoStore;
private logger: Logger;
private sessionName: string;
private db: Db;
private bucket: GridFSBucket;
constructor(sessionName: string, store: MongoStore, logger: Logger) {
this.store = store;
this.logger = logger;
this.sessionName = sessionName;
this.db = this.store.getSessionDb(sessionName);
const bucketName = this.getBucketName();
this.bucket = new GridFSBucket(this.db, {
bucketName: bucketName,
});
}
async sessionExists(options) {
this.checkSessionName(options);
this.logger.info('Checking if session exists...');
const filesCollection = this.getFilesCollectionName();
const multiDeviceCollection = this.db.collection(filesCollection);
const hasExistingSession = await multiDeviceCollection.countDocuments();
const result = !!hasExistingSession;
this.logger.info(`Session exists: ${result}`);
return result;
}
async save(options) {
this.checkSessionName(options);
this.logger.debug('Saving session...');
const filename = this.getAuthFileName(options);
const readStream = fs.createReadStream(filename);
const uploadStream = this.bucket.openUploadStream(filename);
await pipeline(readStream, uploadStream);
this.logger.debug('Session saved.');
await this.#deletePrevious(options, this.bucket);
}
async extract(options) {
this.checkSessionName(options);
this.logger.info('Extracting existing session...');
const filename = this.getAuthFileName(options);
const downloadStream = this.bucket.openDownloadStreamByName(filename);
const writeStream = fs.createWriteStream(options.path);
await pipeline(downloadStream, writeStream);
// Wait a second before giving the zip file to next phase
await sleep(1_000);
this.logger.info('Session has been extracted.');
}
async delete(options) {
this.checkSessionName(options);
this.logger.debug('Deleting session...');
const filename = this.getAuthFileName(options);
const documents = await this.bucket
.find({
filename: filename,
})
.toArray();
documents.map(async (doc) => {
return this.bucket.delete(doc._id);
});
this.logger.debug('Session deleted.');
}
async #deletePrevious(options, bucket) {
const filename = this.getAuthFileName(options);
const documents = await bucket
.find({
filename: filename,
})
.toArray();
if (documents.length > 1) {
this.logger.debug('Deleting old auth files...');
// Got all, but not the last one
const oldDocuments = documents.slice();
// Sort by uploadDate, desc
oldDocuments.sort((a, b) => {
return a.uploadDate - b.uploadDate;
});
const keepDocument = oldDocuments.pop();
this.logger.debug(
`Keeping document - '${keepDocument.uploadDate}', '${keepDocument._id}'`,
);
oldDocuments.map((document: GridFSFile) => {
this.logger.debug(
`Deleting document - '${document.uploadDate}', '${document._id}'`,
);
return bucket.delete(document._id);
});
}
}
/**
* Get session name
*/
private getSessionName(options): string {
const prefix = 'RemoteAuth';
if (!options.session || options.session === prefix) {
return 'default';
}
// Remote prefix
return options.session.replace(`${prefix}-`, '');
}
/**
* Even tho we accept any "session" in options,
* but the store hold a single session database link (bucket)
* This is why we need to check if the session name
* is the same as the store session name
*/
private checkSessionName(options) {
const session = this.getSessionName(options);
if (session !== this.sessionName) {
throw new Error(
`Session name '${session}' does not match the store session name '${this.sessionName}'`,
);
}
}
private getBucketName(): string {
return `auth`;
}
private getFilesCollectionName(): string {
const bucket = this.getBucketName();
return `${bucket}.files`;
}
private getAuthFileName(options): string {
return `${options.session}.zip`;
}
}
export { WebJSMongoAuth };
+75
View File
@@ -0,0 +1,75 @@
import { sleep } from '@nestjs/terminus/dist/utils';
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import Knex from 'knex';
import { Logger } from 'pino';
import { Store } from 'whatsapp-web.js';
interface Options {
session: string;
path?: string;
}
class WebjsFileRepository extends PsqlFileRepository {
get tableName() {
return 'files';
}
}
export class WebJSPsqlAuth implements Store {
private repository: PsqlFileRepository;
constructor(
private knex: Knex.Knex,
private logger: Logger,
) {
this.repository = new WebjsFileRepository(knex, logger);
}
async sessionExists(options: Options): Promise<boolean> {
this.logger.info('Checking if session exists...');
const filename = this.getAuthFileName(options);
const exists = await this.repository.exists(filename);
this.logger.info(`Session exists: ${exists}`);
return exists;
}
async delete(options: Options): Promise<any> {
this.logger.debug('Deleting session...');
const filename = this.getAuthFileName(options);
await this.repository.delete(filename);
this.logger.debug('Session deleted.');
}
async save(options: Options): Promise<any> {
this.logger.debug('Saving session...');
const filename = this.getAuthFileName(options);
await this.repository.saveFromFile(filename, filename);
this.logger.debug('Session saved.');
}
async extract(options: Options) {
this.logger.debug('Extracting existing session...');
const filename = this.getAuthFileName(options);
const found = await this.repository.fetchToFile(filename, options.path);
if (!found) {
this.logger.warn('Session does not exist.');
return;
}
// Wait a second before giving the zip file to next phase
await sleep(1_000);
this.logger.info('Session has been extracted.');
}
async init() {
await this.repository.init();
}
private getAuthFileName(options: Options): string {
return `${options.session}.zip`;
}
async close() {
await this.knex.destroy();
}
}
+225 -82
View File
@@ -1,4 +1,5 @@
import { WebJSPresence } from '@waha/core/engines/webjs/types';
import { GetSerialized } from '@waha/core/utils/serialized';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { Label } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
@@ -8,14 +9,12 @@ import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import * as lodash from 'lodash';
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,6 +29,67 @@ 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;
@@ -65,7 +125,6 @@ export class WebjsClientCore extends Client {
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
}
/**
@@ -110,10 +169,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 +193,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 +204,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 +215,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 +228,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 +259,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 +291,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 +309,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 +384,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 +411,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 +443,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 +470,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 +534,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 +542,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 +559,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 +570,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 +629,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 +645,80 @@ export class WebjsClientCore extends Client {
await sleep(3_000);
return await this.getCurrentPresence(chatId);
}
/**
* Channels methods
*/
async channelFetchMessageByInvite(
inviteCode: string,
limit: number,
): Promise<WebjsChannelMessage[]> {
const response: _GetNewsletterPreviewDataResponse =
await this.pupPage.evaluate(
async (code, limit) => {
// Overwrite the server-side message count so the preview fetches
// exactly `limit` messages
window.require(
'WAWebNewsletterGatingUtils',
).getMaxMsgCountFromServer = () => limit;
const result = await window
.require('WAWebNewsletterPreviewJob')
.getNewsletterPreviewData(code, 'guest');
for (const newsletterReaction of result.newsletterReactions) {
// puppeter doesn't support Map,
// so we need to convert it to object
newsletterReaction.emojiCountMap = Object.fromEntries(
newsletterReaction.emojiCountMap,
);
}
// Fetch one more time to save in database so we can fetch media later
await window
.require('WAWebLoadNewsletterPreviewChatAction')
.loadNewsletterPreviewChat(code);
return result;
},
inviteCode,
limit,
);
const messageInstances = response.newsletterMessages
.filter((msg) => msg.type != 'revoked')
.map((msg) => {
return new MessageInstance(this, msg);
});
const reactions = extractReactionsByMessageKey(
response.newsletterReactions,
);
const messages: WebjsChannelMessage[] = messageInstances.map((msg) => {
return {
message: msg,
reactions: reactions.get(GetSerialized(msg.id)) || [],
viewCount: msg.rawData.viewCount,
};
});
return messages;
}
/**
* Channels Search methods
*/
async searchChannelsView(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectoryList(params);
}, params);
return newsletters;
}
async searchChannelsText(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectorySearchResults(params);
}, params);
return newsletters;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
import { execFile as execFileOrigin } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const execFile = promisify(execFileOrigin);
export class ZipUnzipZipper implements Zipper {
async compress(dirPath: string, archivePath: string): Promise<void> {
// resolve archivePath to full path
const archiveFullPath = path.resolve(archivePath);
const dirFullPath = path.resolve(dirPath);
const opts = {
cwd: dirFullPath,
};
await execFile('zip', ['-rq1', archiveFullPath, '.'], opts);
return;
}
async uncompress(archivePath: string, dirPath: string): Promise<void> {
const archiveFullPath = path.resolve(archivePath);
const dirFullPath = path.resolve(dirPath);
// upsert path, create if not exist
fs.mkdirSync(dirFullPath, { recursive: true });
await execFile('unzip', ['-q', archiveFullPath, '-d', dirFullPath]);
return;
}
}
-30
View File
@@ -1,30 +0,0 @@
exports.LoadWAHA = () => {
window.WAHA = {};
window.WAHA.WAWebBizLabelEditingAction = window.require(
'WAWebBizLabelEditingAction',
);
window.WAHA.getChats = async (pagination, filter) => {
let chats = window.Store.Chat.getModelsArray().slice();
// Filter chats by IDs if filter is provided
if (filter && filter.ids && filter.ids.length > 0) {
chats = chats.filter((chat) => filter.ids.includes(chat.id._serialized));
}
const paginator = new window.Paginator(pagination);
chats = paginator.apply(chats);
const chatPromises = chats.map((chat) => window.WWebJS.getChatModel(chat));
return await Promise.all(chatPromises);
};
// Set push name
window.WAHA.WAWebSetPushnameConnAction = window.require(
'WAWebSetPushnameConnAction',
);
// Get my status
window.WAHA.WAWebTextStatusCollection = window.require(
'WAWebTextStatusCollection',
);
};
+5 -5
View File
@@ -19,6 +19,7 @@ import {
GroupParticipant as WEBJSGroupParticipant,
} from 'whatsapp-web.js';
import { isPnUser } from '@waha/core/utils/jids';
import { GetSerialized } from '@waha/core/utils/serialized';
function ToGroupInfo(
group: GroupChat,
@@ -29,7 +30,7 @@ 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,
@@ -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,
};
});
+342 -53
View File
@@ -1,6 +1,7 @@
import { UnprocessableEntityException } from '@nestjs/common';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
@@ -18,20 +19,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 +49,7 @@ import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelPublicInfo,
ChannelRole,
ChannelSearchByText,
ChannelSearchByView,
@@ -75,11 +78,12 @@ import {
MessageImageRequest,
MessageLocationRequest,
MessagePollRequest,
MessagePollVoteRequest,
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageTextRequest,
MessageVoiceRequest,
MessageVideoRequest,
SendSeenRequest,
WANumberExistResult,
} from '@waha/structures/chatting.dto';
@@ -122,7 +126,14 @@ import {
} 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 {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
PollVote as WAHAPollVote,
@@ -171,8 +182,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,
@@ -201,6 +214,8 @@ export interface WebJSConfig {
export class WhatsappSessionWebJSCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
authFactory = new WebJSAuthFactory();
engine = WAHAEngine.WEBJS;
protected engineConfig?: WebJSConfig;
@@ -284,14 +299,13 @@ 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());
}
@@ -518,7 +532,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
const wid = clientInfo.wid;
return {
id: wid?._serialized,
id: GetSerialized(wid),
lid: GetSerialized(clientInfo.lid),
pushName: clientInfo?.pushname,
};
}
@@ -651,14 +666,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 +740,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
return {
numberExists: true,
chatId: result._serialized,
chatId: GetSerialized(result),
};
}
@@ -746,12 +759,48 @@ 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();
}
/**
* 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 +894,137 @@ 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',
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 +1137,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 +1250,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;
@@ -1460,23 +1626,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 +1715,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 +1908,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 +2214,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 +2304,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 +2313,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 +2399,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 +2476,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 +2515,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 +2554,7 @@ export class WEBJSEngineMediaProcessor
}
getMessageId(message: Message): string {
return message.id._serialized;
return GetSerialized(message.id);
}
getMimetype(message: Message): string {
+114
View File
@@ -0,0 +1,114 @@
import { DataStore } from '@waha/core/abc/DataStore';
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
import { WAHASessionStatus } from '@waha/structures/enums.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { LocalStore } from '@waha/core/storage/LocalStore';
import { Logger } from 'pino';
import { Zipper } from '../webjs/RemoteAuth';
import { StreamZipper } from '../webjs/StreamZipper';
import { ZipUnzipZipper } from '../webjs/ZipUnzipZipper';
import { WPPAuthManager } from './WPPAuthManager';
import { WPPMongoStore } from './WPPMongoStore';
import { WPPPsqlStore } from './WPPPsqlStore';
export class WPPAuthFactory {
async build(
store: DataStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): Promise<IWPPAuthManager | null> {
if (store instanceof LocalStore) {
return null;
}
if (store instanceof MongoStore) {
return this.buildMongo(
store,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
if (store instanceof PsqlStore) {
return this.buildPsql(
store,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
throw new Error(
`WPPAuthFactory: unsupported store type '${store.constructor.name}'`,
);
}
private buildMongo(
store: MongoStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): IWPPAuthManager {
const logger = loggerBuilder.child({ name: WPPMongoStore.name });
const wppStore = new WPPMongoStore(store, sessionName, logger);
return this.buildManager(
wppStore,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
private async buildPsql(
store: PsqlStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): Promise<IWPPAuthManager> {
const logger = loggerBuilder.child({ name: WPPPsqlStore.name });
const wppStore = await WPPPsqlStore.build(store, sessionName, logger);
return this.buildManager(
wppStore,
sessionName,
userDataDir,
loggerBuilder,
getStatus,
);
}
private buildManager(
wppStore: WPPMongoStore | WPPPsqlStore,
sessionName: string,
userDataDir: string,
loggerBuilder: LoggerBuilder,
getStatus: () => WAHASessionStatus,
): WPPAuthManager {
const logger = loggerBuilder.child({ name: WPPAuthManager.name });
const zipper = this.getZipper(logger);
return new WPPAuthManager(
wppStore,
sessionName,
userDataDir,
zipper,
logger,
getStatus,
);
}
private getZipper(logger: Logger): Zipper {
if (process.env.WAHA_ZIPPER === 'ZIPUNZIP') {
logger.debug('Using ZipUnzipZipper');
return new ZipUnzipZipper();
}
logger.debug('Using StreamZipper');
return new StreamZipper();
}
}
+148
View File
@@ -0,0 +1,148 @@
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
import { WAHASessionStatus } from '@waha/structures/enums.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import * as fs from 'fs-extra';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { Logger } from 'pino';
import { Zipper } from '../webjs/RemoteAuth';
import { WPPStore } from './WPPStore';
// Only these dirs need to be backed up from the Chromium profile
const REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
const INITIAL_DELAY_MS = 60_000;
export class WPPAuthManager implements IWPPAuthManager {
private backupRunner: SinglePeriodicJobRunner | null = null;
constructor(
private readonly store: WPPStore,
private readonly sessionName: string,
private readonly userDataDir: string,
private readonly zipper: Zipper,
private readonly logger: Logger,
private readonly getStatus: () => WAHASessionStatus,
private readonly backupIntervalMs: number = INITIAL_DELAY_MS,
) {}
async beforeStart(): Promise<void> {
const sessionExists = await this.store.sessionExists(this.sessionName);
if (!sessionExists) {
this.logger.info('No remote WPP session found, starting fresh');
await this.removePathSilently(this.userDataDir);
return;
}
this.logger.info('Remote WPP session found, restoring...');
const data = await this.store.load(this.sessionName);
if (!data) {
this.logger.warn('Remote WPP session data is empty, starting fresh');
return;
}
const zipPath = this.getZipPath();
await fsp.mkdir(path.dirname(zipPath), { recursive: true });
await fsp.writeFile(zipPath, data);
await this.removePathSilently(this.userDataDir);
await this.zipper.uncompress(zipPath, this.userDataDir);
await this.removePathSilently(zipPath);
await this.removeSingletonFiles(this.userDataDir);
await sleep(2_000);
this.logger.info('Remote WPP session restored successfully');
}
async afterConnected(): Promise<void> {
if (this.backupRunner) {
// Already running from a previous WORKING transition
return;
}
this.backupRunner = new SinglePeriodicJobRunner(
'WPP Auth Backup',
this.backupIntervalMs,
this.logger,
);
this.backupRunner.start(() => this.saveSnapshot());
}
async saveSnapshot(): Promise<void> {
if (this.getStatus() !== WAHASessionStatus.WORKING) {
this.logger.debug('Skipping WPP snapshot - session not WORKING');
return;
}
this.logger.debug('Saving WPP session snapshot...');
const tempDir = this.getTempDir();
const zipPath = this.getZipPath();
try {
await fs.copy(this.userDataDir, tempDir);
// await this.deleteMetadata(tempDir);
await this.zipper.compress(tempDir, zipPath);
const data = await fsp.readFile(zipPath);
await this.store.save(this.sessionName, data);
this.logger.info('WPP session snapshot saved');
} finally {
await this.removePathSilently(tempDir);
await this.removePathSilently(zipPath);
}
}
async stop(): Promise<void> {
this.backupRunner?.stop();
this.backupRunner = null;
}
private async deleteMetadata(tempDir: string): Promise<void> {
const dirsToScan = [tempDir, path.join(tempDir, 'Default')];
for (const dir of dirsToScan) {
const exists = await fs.pathExists(dir);
if (!exists) {
continue;
}
const entries = await fsp.readdir(dir);
for (const entry of entries) {
if (REQUIRED_DIRS.includes(entry)) {
continue;
}
await this.removePathSilently(path.join(dir, entry));
}
}
}
private async removeSingletonFiles(dir: string): Promise<void> {
const exists = await fs.pathExists(dir);
if (!exists) {
return;
}
const files = await fsp.readdir(dir);
for (const file of files) {
if (!file.startsWith('Singleton')) {
continue;
}
await this.removePathSilently(path.join(dir, file));
}
}
private async removePathSilently(targetPath: string): Promise<void> {
try {
await fsp.rm(targetPath, { recursive: true, force: true, maxRetries: 4 });
} catch (err) {
this.logger.error(err, `Failed to remove: ${targetPath}`);
}
}
private getTempDir(): string {
return `/tmp/wpp_temp_${this.sessionName}`;
}
private getZipPath(): string {
return `/tmp/wpp_session_${this.sessionName}.zip`;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
import { Logger } from 'pino';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
import { WPPStore } from './WPPStore';
const BUCKET_NAME = 'auth';
export class WPPMongoStore implements WPPStore {
private readonly db: Db;
private readonly bucket: GridFSBucket;
constructor(
store: MongoStore,
sessionName: string,
private readonly logger: Logger,
) {
this.db = store.getSessionDb(sessionName);
this.bucket = new GridFSBucket(this.db, { bucketName: BUCKET_NAME });
}
async sessionExists(sessionName: string): Promise<boolean> {
const filename = this.getFilename(sessionName);
const count = await this.db
.collection(`${BUCKET_NAME}.files`)
.countDocuments({ filename: filename });
return count > 0;
}
async load(sessionName: string): Promise<Buffer | null> {
const filename = this.getFilename(sessionName);
const files = await this.bucket.find({ filename: filename }).toArray();
if (files.length === 0) {
return null;
}
const chunks: Buffer[] = [];
const downloadStream = this.bucket.openDownloadStreamByName(filename);
for await (const chunk of downloadStream) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
}
async save(sessionName: string, data: Buffer): Promise<void> {
const filename = this.getFilename(sessionName);
const readable = Readable.from(data);
const uploadStream = this.bucket.openUploadStream(filename);
await pipeline(readable, uploadStream);
await this.deletePrevious(filename);
}
async delete(sessionName: string): Promise<void> {
const filename = this.getFilename(sessionName);
const files = await this.bucket.find({ filename: filename }).toArray();
await Promise.all(files.map((f: GridFSFile) => this.bucket.delete(f._id)));
}
private async deletePrevious(filename: string): Promise<void> {
const files = await this.bucket.find({ filename: filename }).toArray();
if (files.length <= 1) {
return;
}
files.sort(
(a: GridFSFile, b: GridFSFile) =>
a.uploadDate.getTime() - b.uploadDate.getTime(),
);
// Keep the last one, delete the rest
const toDelete = files.slice(0, files.length - 1);
await Promise.all(
toDelete.map((f: GridFSFile) => this.bucket.delete(f._id)),
);
}
private getFilename(sessionName: string): string {
return `${sessionName}.zip`;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import Knex from 'knex';
import { Logger } from 'pino';
import { WPPStore } from './WPPStore';
class WPPFileRepository extends PsqlFileRepository {
get tableName() {
return 'files';
}
}
export class WPPPsqlStore implements WPPStore {
private readonly repository: WPPFileRepository;
constructor(
knex: Knex.Knex,
private readonly logger: Logger,
) {
this.repository = new WPPFileRepository(knex, logger);
}
static async build(
store: PsqlStore,
sessionName: string,
logger: Logger,
): Promise<WPPPsqlStore> {
const knex = store.buildSessionKnex(sessionName, 'Session/Auth');
const instance = new WPPPsqlStore(knex, logger);
await instance.init();
return instance;
}
private async init(): Promise<void> {
await this.repository.init();
}
async sessionExists(sessionName: string): Promise<boolean> {
return this.repository.exists(this.getFilename(sessionName));
}
async load(sessionName: string): Promise<Buffer | null> {
const data = await this.repository.fetch(this.getFilename(sessionName));
return data ? data.content : null;
}
async save(sessionName: string, data: Buffer): Promise<void> {
await this.repository.save(this.getFilename(sessionName), data);
}
async delete(sessionName: string): Promise<void> {
await this.repository.delete(this.getFilename(sessionName));
}
private getFilename(sessionName: string): string {
return `${sessionName}.zip`;
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface WPPStore {
sessionExists(sessionName: string): Promise<boolean>;
load(sessionName: string): Promise<Buffer | null>;
save(sessionName: string, data: Buffer): Promise<void>;
delete(sessionName: string): Promise<void>;
}
+235 -14
View File
@@ -47,6 +47,7 @@ import {
MessageFileRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -81,7 +82,13 @@ import {
import { WAHAChatPresences } from '@waha/structures/presence.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { TextStatus } from '@waha/structures/status.dto';
import {
BROADCAST_ID,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
import {
PollVotePayload,
@@ -121,10 +128,7 @@ import {
WppSendTextOptions,
WppSendTextStatusOptions,
} from '@waha/core/engines/wpp/WppTypes';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
@@ -168,6 +172,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 +208,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 +233,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 +646,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()
@@ -0,0 +1,35 @@
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import checkDiskSpace from 'check-disk-space';
export interface CheckFreeDiskSpaceOptions {
path: string;
thresholdBytes: number;
}
export class CheckFreeDiskSpaceIndicator extends HealthIndicator {
public async check(
key: string,
options: CheckFreeDiskSpaceOptions,
): Promise<HealthIndicatorResult> {
const diskSpace = await checkDiskSpace(options.path);
if (diskSpace.free < options.thresholdBytes) {
const result = super.getStatus(key, false, {
path: options.path,
diskPath: diskSpace.diskPath,
free: diskSpace.free,
threshold: options.thresholdBytes,
});
throw new HealthCheckError('Disk space is low', result);
}
return super.getStatus(key, true, {
path: options.path,
diskPath: diskSpace.diskPath,
free: diskSpace.free,
threshold: options.thresholdBytes,
});
}
}
@@ -0,0 +1,36 @@
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { promiseTimeout } from '../../utils/promiseTimeout';
import { MongoStore } from '../storage/mongo/MongoStore';
export interface MongoStoreHealthIndicatorOptions {
timeout: number;
}
export class MongoStoreHealthIndicator extends HealthIndicator {
constructor() {
super();
}
private async ping(mongoStore: MongoStore) {
await mongoStore.command({ ismaster: 1 });
}
public async check(
key: string,
mongo: MongoStore,
options: MongoStoreHealthIndicatorOptions,
): Promise<HealthIndicatorResult> {
try {
await promiseTimeout(options.timeout, this.ping(mongo));
return super.getStatus(key, true, { message: 'Up and running' });
} catch (e) {
const result = super.getStatus(key, false, { error: 'Timeout' });
throw new HealthCheckError('Timeout happened', result);
}
}
}
+53 -3
View File
@@ -1,12 +1,62 @@
import { Injectable } from '@nestjs/common';
import { HealthCheckResult } from '@nestjs/terminus';
import { HealthCheckResult, HealthCheckService } from '@nestjs/terminus';
import type { HealthIndicatorFunction } from '@nestjs/terminus/dist/health-indicator';
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import * as path from 'path';
import { WhatsappConfigService } from '../../config.service';
import { SessionManager } from '../abc/manager.abc';
import { WAHAHealthCheckService } from '../abc/WAHAHealthCheckService';
import { AvailableInPlusVersion } from '../exceptions';
import { LocalStore } from '../storage/LocalStore';
import { MongoStore } from '../storage/mongo/MongoStore';
import { CheckFreeDiskSpaceIndicator } from './CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from './MongoStoreHealthIndicator';
const MB = 1024 * 1024;
@Injectable()
export class WAHAHealthCheckServiceCore extends WAHAHealthCheckService {
constructor(
protected sessionManager: SessionManager,
protected health: HealthCheckService,
protected config: WhatsappConfigService,
protected mongoStoreHealthIndicator: MongoStoreHealthIndicator,
protected checkFreeDiskSpaceIndicator: CheckFreeDiskSpaceIndicator,
protected mediaLocalStorageConfig: MediaLocalStorageConfig,
) {
super(sessionManager, health, config);
}
check(): Promise<HealthCheckResult> {
throw new AvailableInPlusVersion();
const indicators = this.getIndicators();
return this.health.check(indicators);
}
getIndicators(): HealthIndicatorFunction[] {
const indicators = [
() =>
this.checkFreeDiskSpaceIndicator.check('mediaFiles.space', {
path: path.resolve(this.mediaLocalStorageConfig.filesFolder),
thresholdBytes: this.config.getHealthMediaFilesThreshold() * MB,
}),
];
if (this.sessionManager.store instanceof LocalStore) {
indicators.push(() =>
this.checkFreeDiskSpaceIndicator.check('sessionsFiles.space', {
path: path.resolve(this.sessionManager.store.getBaseDirectory()),
thresholdBytes: this.config.getHealthSessionFilesThreshold() * MB,
}),
);
}
if (this.sessionManager.store instanceof MongoStore) {
indicators.push(() =>
this.mongoStoreHealthIndicator.check(
'mongodb',
this.sessionManager.store,
{ timeout: this.config.getHealthMongoTimeout() },
),
);
}
return indicators;
}
}
+389 -199
View File
@@ -2,8 +2,8 @@ import {
Inject,
Injectable,
NotFoundException,
OnApplicationBootstrap,
OnModuleInit,
UnprocessableEntityException,
} from '@nestjs/common';
import {
AppsService,
@@ -14,18 +14,46 @@ import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigServi
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,46 +71,26 @@ 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,
@@ -95,58 +103,182 @@ 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) {
await this.withLock(sessionName, async () => {
const log = this.log.logger.child({ session: sessionName });
log.info(`Restarting STOPPED session...`);
await this.start(sessionName).catch((error) => {
log.error(`Failed to start STOPPED session: ${error}`);
log.error(error.stack);
});
});
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 +293,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 +340,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 +349,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();
@@ -217,14 +359,13 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
sessionConfig.engineConfig = this.gowsConfigService.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 +384,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 +391,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 +430,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 +478,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);
}
}
}
+8 -1
View File
@@ -187,7 +187,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(
+69
View File
@@ -0,0 +1,69 @@
import {
getMetadata,
IMediaStorage,
MediaData,
MediaStorageData,
} from '@waha/core/media/IMediaStorage';
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
import Knex from 'knex';
import { Logger } from 'pino';
class MediaFileRepository extends PsqlFileRepository {
get tableName() {
return 'media';
}
}
export class MediaPsqlStorage implements IMediaStorage {
repository: MediaFileRepository;
constructor(
private knex: Knex.Knex,
private baseUrl: string,
private logger: Logger,
) {
this.repository = new MediaFileRepository(knex, logger);
}
async init(): Promise<void> {
await this.repository.init();
}
private getFilepath(data: MediaData) {
return `${data.message.id}.${data.file.extension}`;
}
async save(buffer: Buffer, data: MediaData): Promise<boolean> {
const filepath = this.getFilepath(data);
const metadata = getMetadata(data);
await this.repository.save(filepath, buffer, metadata);
return true;
}
async exists(data: MediaData): Promise<boolean> {
const filepath = this.getFilepath(data);
return await this.repository.exists(filepath);
}
async fetch(filepath: string) {
return await this.repository.fetch(filepath);
}
async remove(filepath: string) {
return await this.repository.delete(filepath);
}
async getStorageData(data: MediaData): Promise<MediaStorageData> {
const filepath = this.getFilepath(data);
const url = `${this.baseUrl}${data.session}/${filepath}`;
return { url: url };
}
async purge(): Promise<void> {
this.logger.debug('Purging S3 bucket is not supported');
}
async close() {
await this.knex.destroy();
}
}
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
@Injectable()
export class MediaPsqlStorageConfig {
public filesUri = '/api/files';
constructor(private config: WhatsappConfigService) {}
get databaseUrl(): string {
const url = this.config.get('WAHA_MEDIA_POSTGRESQL_URL');
if (!url) {
throw new Error(
'WAHA_MEDIA_POSTGRESQL_URL is required to use the PostgreSQL media storage',
);
}
return url;
}
get filesURL(): string {
return `${this.config.baseUrl}${this.filesUri}/`;
}
}
@@ -0,0 +1,52 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { EngineConfigService } from '@waha/core/config/EngineConfigService';
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
import { MediaPsqlStorage } from '@waha/core/media/psql/MediaPsqlStorage';
import { MediaPsqlStorageConfig } from '@waha/core/media/psql/MediaPsqlStorageConfig';
import { parsePsql } from '@waha/core/storage/psql/PsqlConnectionConfig';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { getNamespace, getSessionNamespace } from '@waha/config';
import { getEngineName, VERSION } from '@waha/version';
import { Logger } from 'pino';
@Injectable()
export class MediaPsqlStorageFactory
implements MediaStorageFactory, OnApplicationShutdown
{
private readonly store: PsqlStore;
private readonly filesURL: string;
constructor(
psqlConfig: MediaPsqlStorageConfig,
private engineConfigService: EngineConfigService,
) {
const config = parsePsql(psqlConfig.databaseUrl);
const engine = getEngineName();
config.application_name = `WAHA(${engine}) ${VERSION.version} - Media`;
this.store = new PsqlStore(config, getNamespace(), getSessionNamespace());
this.filesURL = psqlConfig.filesURL;
}
async build(
name: string,
logger: Logger,
init: boolean = true,
): Promise<MediaPsqlStorage> {
if (init && name !== 'all') {
await this.store.init(name);
}
let knex;
if (name === 'all') {
knex = this.store.knex;
} else {
const suffix = init ? 'Session' : 'Fetch';
knex = this.store.buildSessionKnex(name, suffix);
}
return new MediaPsqlStorage(knex, this.filesURL, logger);
}
async onApplicationShutdown(signal?: string) {
await this.store.close();
}
}
@@ -0,0 +1,107 @@
import {
Controller,
Delete,
Get,
NotFoundException,
Param,
StreamableFile,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { MediaPsqlStorage } from '@waha/core/media/psql/MediaPsqlStorage';
import { MediaPsqlStorageFactory } from '@waha/core/media/psql/MediaPsqlStorageFactory';
import { PsqlCode } from '@waha/core/storage/psql/PsqlCode';
import { PinoLogger } from 'nestjs-pino';
import * as NodeCache from 'node-cache';
@ApiSecurity('api_key')
@Controller('api/files')
@ApiTags('🗄️ Storage')
export class PsqlFilesController {
storages: NodeCache;
constructor(
private factory: MediaPsqlStorageFactory,
private logger: PinoLogger,
) {
this.storages = new NodeCache({
stdTTL: 60, // 1 minute
useClones: false,
});
this.storages.on('del', async (key, value) => {
await value.close();
});
}
private async getStorage(session: string): Promise<MediaPsqlStorage> {
if (!this.storages.has(session)) {
const logger = this.logger.logger.child({ session: session });
const storage = await this.factory.build(session, logger, false);
this.storages.set(session, storage);
}
// Increase TTL back
this.storages.ttl(session);
return this.storages.get(session);
}
@Get(':session/*parts')
@ApiOperation({
summary: 'Get file',
})
async get(
@Param('session') session: string,
@Param('parts') parts: string[],
): Promise<StreamableFile> {
const filepath = parts.join('/');
const storage = await this.getStorage(session);
let data;
try {
data = await storage.fetch(filepath);
} catch (err) {
if (
err.code === PsqlCode.DATABASE_NOT_EXIST ||
err.code === PsqlCode.RELATION_NOT_FOUND
) {
throw new NotFoundException(
`Session database or table not found: ${session}`,
);
}
throw err;
}
if (!data) {
throw new NotFoundException(`File not found: ${filepath}`);
}
const filename = this.getFilename(filepath);
return new StreamableFile(data.content, {
disposition: `attachment; filename="${filename}"`,
});
}
@Delete(':session/*parts')
@ApiOperation({
summary: 'Delete file',
})
async delete(
@Param('session') session: string,
@Param('parts') parts: string[],
) {
const filepath = parts.join('/');
const storage = await this.getStorage(session);
try {
await storage.remove(filepath);
} catch (err) {
if (
err.code === PsqlCode.DATABASE_NOT_EXIST ||
err.code === PsqlCode.RELATION_NOT_FOUND
) {
return;
}
throw err;
}
}
private getFilename(filepath: string) {
return filepath.split('/').pop();
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
import { EngineConfigService } from '@waha/core/config/EngineConfigService';
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
import { PsqlFilesController } from '@waha/core/media/psql/api/psql.files.controller';
import { MediaPsqlStorageConfig } from './MediaPsqlStorageConfig';
import { MediaPsqlStorageFactory } from './MediaPsqlStorageFactory';
@Module({
providers: [
{
provide: MediaStorageFactory,
useExisting: MediaPsqlStorageFactory,
},
MediaPsqlStorageFactory,
WhatsappConfigService,
EngineConfigService,
MediaPsqlStorageConfig,
],
exports: [MediaStorageFactory],
controllers: [PsqlFilesController],
})
export class MediaPsqlStorageModule {}
+122
View File
@@ -0,0 +1,122 @@
import {
CreateBucketCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import {
getMetadata,
IMediaStorage,
MediaData,
MediaStorageData,
} from '@waha/core/media/IMediaStorage';
import { MediaS3UrlResolver } from '@waha/core/media/s3/MediaS3UrlResolver';
import * as lodash from 'lodash';
import { Logger } from 'pino';
export class MediaS3Storage implements IMediaStorage {
constructor(
private client: S3Client,
private mediaS3UrlResolver: MediaS3UrlResolver,
private bucket: string,
protected log: Logger,
) {}
async init() {
await this.createBucketIfNotExist(this.bucket);
}
private getKey(data: MediaData) {
return `${data.session}/${data.message.id}.${data.file.extension}`;
}
async save(buffer: Buffer, data: MediaData): Promise<boolean> {
const key = this.getKey(data);
let metadata = getMetadata(data);
metadata = await this.stringifyMetadata(metadata);
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
Metadata: metadata,
});
await this.client.send(command);
return true;
}
async stringifyMetadata(metadata) {
metadata = lodash.cloneDeep(metadata);
// Convert all metadata values to string because
// underlying S3 SDK will call .trim() on them
for (const key in metadata) {
const value = String(metadata[key]);
// only ascii allowed in value for S3
metadata[key] = value.replace(/[^\x20-\x7E]/g, '');
}
return metadata;
}
async exists(data: MediaData): Promise<boolean> {
const key = this.getKey(data);
try {
await this.client.send(
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
);
return true;
} catch (e) {
if (e.name === 'NotFound') {
return false;
}
throw e;
}
}
async getStorageData(data: MediaData): Promise<MediaStorageData> {
const key = this.getKey(data);
const url = await this.mediaS3UrlResolver.resolve({
bucket: this.bucket,
key: key,
});
return {
url: url,
s3: {
Bucket: this.bucket,
Key: key,
},
};
}
async purge(): Promise<void> {
this.log.debug('Purging S3 bucket is not supported');
}
private async createBucketIfNotExist(bucket: string) {
if (!(await this.bucketExists(bucket))) {
await this.createBucket(bucket);
}
}
private createBucket(bucket: string) {
const command = new CreateBucketCommand({ Bucket: bucket });
return this.client.send(command);
}
private async bucketExists(bucket: string): Promise<boolean> {
const command = new HeadBucketCommand({ Bucket: bucket });
try {
await this.client.send(command);
return true;
} catch (e) {
if (e.name === 'NotFound') {
return false;
}
throw e;
}
}
async close() {
return;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
import { parseBool } from '@waha/helpers';
@Injectable()
export class MediaS3StorageConfig {
constructor(private config: WhatsappConfigService) {}
get region(): string {
const region = this.config.get('WAHA_S3_REGION');
if (!region) {
throw new Error('WAHA_S3_REGION is required');
}
return region;
}
get accessKeyId(): string {
const accessKeyId = this.config.get('WAHA_S3_ACCESS_KEY_ID');
if (!accessKeyId) {
throw new Error('WAHA_S3_ACCESS_KEY_ID is required');
}
return accessKeyId;
}
get secretAccessKey(): string {
const secretAccessKey = this.config.get('WAHA_S3_SECRET_ACCESS_KEY');
if (!secretAccessKey) {
throw new Error('WAHA_S3_SECRET_ACCESS_KEY is required');
}
return secretAccessKey;
}
get endpoint(): string | null {
return this.config.get('WAHA_S3_ENDPOINT', null);
}
get bucket(): string {
const bucket = this.config.get('WAHA_S3_BUCKET');
if (!bucket) {
throw new Error('WAHA_S3_BUCKET is required');
}
return bucket;
}
get forcePathStyle(): boolean {
const value = this.config.get('WAHA_S3_FORCE_PATH_STYLE', 'false');
return parseBool(value);
}
get s3ClientConfig() {
return {
region: this.region,
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
},
endpoint: this.endpoint,
forcePathStyle: this.forcePathStyle,
};
}
get s3ProxyFiles() {
const value = this.config.get('WAHA_S3_PROXY_FILES', 'false');
return parseBool(value);
}
}
@@ -0,0 +1,32 @@
import { S3Client } from '@aws-sdk/client-s3';
import { Injectable } from '@nestjs/common';
import { IMediaStorage } from '@waha/core/media/IMediaStorage';
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
import { MediaS3Storage } from '@waha/core/media/s3/MediaS3Storage';
import { MediaS3StorageConfig } from '@waha/core/media/s3/MediaS3StorageConfig';
import { MediaS3UrlResolver } from '@waha/core/media/s3/MediaS3UrlResolver';
import { Logger } from 'pino';
@Injectable()
export class MediaS3StorageFactory extends MediaStorageFactory {
private readonly defaultBucket: string;
constructor(
private s3client: S3Client,
private s3config: MediaS3StorageConfig,
private s3url: MediaS3UrlResolver,
) {
super();
this.defaultBucket = this.s3config.bucket;
}
async build(name: string, logger: Logger): Promise<IMediaStorage> {
// S3 uses the same buckets for all sessions
return new MediaS3Storage(
this.s3client,
this.s3url,
this.defaultBucket,
logger,
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
export interface S3Object {
bucket: string;
key: string;
}
export abstract class MediaS3UrlResolver {
abstract resolve(file: S3Object): Promise<string>;
}
@Injectable()
export class S3Url extends MediaS3UrlResolver {
PRESIGN_EXPIRES = 3600;
constructor(private client: S3Client) {
super();
}
async resolve(file: S3Object): Promise<string> {
const command = new GetObjectCommand({
Bucket: file.bucket,
Key: file.key,
});
return await getSignedUrl(this.client, command, {
expiresIn: this.PRESIGN_EXPIRES,
});
}
}
@Injectable()
export class S3ProxyUrl extends MediaS3UrlResolver {
private url = 'api/s3';
private readonly prefix: string;
constructor(private config: WhatsappConfigService) {
super();
this.prefix = `${this.config.baseUrl}/${this.url}`;
}
async resolve(file: S3Object): Promise<string> {
return `${this.prefix}/${file.bucket}/${file.key}`;
}
}
@@ -0,0 +1,67 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import {
Controller,
Get,
NotFoundException,
Param,
StreamableFile,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { MediaS3StorageConfig } from '@waha/core/media/s3/MediaS3StorageConfig';
import { Readable } from 'stream';
@ApiSecurity('api_key')
@Controller('api/s3')
@ApiTags('🗄️ Storage')
export class S3ProxyController {
private readonly enabled: boolean;
constructor(
private s3client: S3Client,
s3config: MediaS3StorageConfig,
) {
this.enabled = s3config.s3ProxyFiles;
}
@Get(':bucket/*parts')
@ApiOperation({
summary: 'Get files from S3',
})
async get(
@Param('bucket') bucket: string,
@Param('parts') parts: string[],
): Promise<StreamableFile> {
if (!this.enabled) {
throw new NotFoundException('S3 proxy is disabled');
}
const key = parts.join('/');
const filename = this.getS3Filename(key);
let stream: Readable;
try {
stream = await this.getS3Stream(bucket, key);
} catch (error) {
if (error.name === 'NoSuchKey') {
throw new NotFoundException(`File not found: ${key}`);
}
throw error;
}
return new StreamableFile(stream, {
disposition: `attachment; filename="${filename}"`,
});
}
private async getS3Stream(bucket: string, key: string): Promise<Readable> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const response = await this.s3client.send(command);
return response.Body as Readable;
}
private getS3Filename(key: string) {
return key.split('/').pop();
}
}
@@ -0,0 +1,47 @@
import { S3Client } from '@aws-sdk/client-s3';
import { Module } from '@nestjs/common';
import { WhatsappConfigService } from '@waha/config.service';
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
import { S3ProxyController } from '@waha/core/media/s3/api/s3.proxy.controller';
import { MediaS3StorageConfig } from '@waha/core/media/s3/MediaS3StorageConfig';
import { MediaS3StorageFactory } from '@waha/core/media/s3/MediaS3StorageFactory';
import {
MediaS3UrlResolver,
S3ProxyUrl,
S3Url,
} from '@waha/core/media/s3/MediaS3UrlResolver';
@Module({
providers: [
{
provide: S3Client,
inject: [MediaS3StorageConfig],
useFactory: (s3config: MediaS3StorageConfig) => {
return new S3Client(s3config.s3ClientConfig);
},
},
{
provide: MediaStorageFactory,
useClass: MediaS3StorageFactory,
},
WhatsappConfigService,
MediaS3StorageConfig,
{
provide: MediaS3UrlResolver,
inject: [MediaS3StorageConfig, WhatsappConfigService, S3Client],
useFactory: (
s3config: MediaS3StorageConfig,
config: WhatsappConfigService,
s3client: S3Client,
) => {
if (s3config.s3ProxyFiles) {
return new S3ProxyUrl(config);
}
return new S3Url(s3client);
},
},
],
exports: [MediaStorageFactory],
controllers: [S3ProxyController],
})
export class MediaS3StorageModule {}
+59
View File
@@ -3,6 +3,7 @@ import {
UnprocessableEntityException,
} from '@nestjs/common';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SessionActions } from '@waha/core/auth/casl.types';
import {
ApiKey,
IApiKeyRepository,
@@ -10,6 +11,24 @@ import {
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
const MEDIA_KEY_ACTIONS: SessionActions = {
read: false,
send: false,
control: false,
setting: false,
app: false,
delete: false,
};
const CONTROL_KEY_ACTIONS: SessionActions = {
read: false,
send: false,
control: true,
setting: false,
app: false,
delete: false,
};
function CheckInvariant(
apiKey: Pick<ApiKey, 'isAdmin' | 'session' | 'actions'>,
): void {
@@ -78,6 +97,46 @@ export class ApiKeyService {
return this.toDTO(apikey);
}
private async createOrGetScopedKey(
session: string,
variant: string,
actions: SessionActions,
): Promise<ApiKeyDTO> {
const exists = await this.manager.exists(session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${session}" does not exist`,
);
}
// Deterministic id keeps this idempotent: repeated calls reuse the same
// stored secret instead of minting a new key every time.
const id = `key_id_${variant}_${session}`;
const existing = await this.repository.getById(id);
if (existing) {
return this.toDTO(existing);
}
const apikey: ApiKey = {
id: id,
key: `key_${generateSecret(32)}`,
isActive: true,
isAdmin: false,
session: session,
actions: actions,
app_id: null,
};
CheckInvariant(apikey);
await this.repository.upsert(apikey);
return this.toDTO(apikey);
}
async createOrGetMediaKey(session: string): Promise<ApiKeyDTO> {
return this.createOrGetScopedKey(session, 'media', MEDIA_KEY_ACTIONS);
}
async createOrGetControlKey(session: string): Promise<ApiKeyDTO> {
return this.createOrGetScopedKey(session, 'control', CONTROL_KEY_ACTIONS);
}
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.repository.list();
return keys.map((key) => this.toDTO(key));
+18 -4
View File
@@ -1,22 +1,36 @@
import { Injectable } from '@nestjs/common';
import { AvailableInPlusVersion } from '@waha/core/exceptions';
import { CategoriesByValue } from '@waha/core/services/channels.categories';
import { CountriesByCode } from '@waha/core/services/channels.countries';
import { ViewsByValue } from '@waha/core/services/channels.views';
import {
ChannelCategory,
ChannelCountry,
ChannelView,
} from '@waha/structures/channels.dto';
const COUNTRIES: ChannelCountry[] = Object.entries(CountriesByCode).map(
([key, value]) => ({ code: key, name: value }),
);
const CATEGORIES: ChannelCategory[] = Object.entries(CategoriesByValue).map(
([key, value]) => ({ value: key, name: value }),
);
const VIEWS: ChannelView[] = Object.entries(ViewsByValue).map(
([key, value]) => ({ value: key, name: value }),
);
@Injectable()
export class ChannelsInfoServiceCore {
async getCountries(): Promise<ChannelCountry[]> {
throw new AvailableInPlusVersion();
return COUNTRIES;
}
async getCategories(): Promise<ChannelCategory[]> {
throw new AvailableInPlusVersion();
return CATEGORIES;
}
async getViews(): Promise<ChannelView[]> {
throw new AvailableInPlusVersion();
return VIEWS;
}
}
+14
View File
@@ -0,0 +1,14 @@
export const CategoriesByValue = {
BUSINESS: 'Business',
ENTERTAINMENT: 'Entertainment',
LIFESTYLE: 'Lifestyle',
NEWS: 'News & Information',
ORGANIZATIONS: 'Organizations',
PEOPLE: 'People',
SPORTS: 'Sports',
SPECIAL_EVENTS: 'Special Events',
SPECIAL_EVENTS_2: 'Special Events 2',
SPECIAL_EVENTS_3: 'Special Events 3',
SPECIAL_EVENTS_4: 'Special Events 4',
SPECIAL_EVENTS_5: 'Special Events 5',
};
+265
View File
@@ -0,0 +1,265 @@
export const CountriesByCode = {
AC: 'Ascension Island',
AD: 'Andorra',
AE: 'United Arab Emirates',
AF: 'Afghanistan',
AG: 'Antigua and Barbuda',
AI: 'Anguilla',
AL: 'Albania',
AM: 'Armenia',
AO: 'Angola',
AQ: 'Antarctica',
AR: 'Argentina',
AS: 'American Samoa',
AT: 'Austria',
AU: 'Australia',
AW: 'Aruba',
AX: 'Åland Islands',
AZ: 'Azerbaijan',
BA: 'Bosnia and Herzegovina',
BB: 'Barbados',
BD: 'Bangladesh',
BE: 'Belgium',
BF: 'Burkina Faso',
BG: 'Bulgaria',
BH: 'Bahrain',
BI: 'Burundi',
BJ: 'Benin',
BL: 'Saint Barthélemy',
BM: 'Bermuda',
BN: 'Brunei Darussalam',
BO: 'Bolivia',
BQ: 'Bonaire, Sint Eustatius and Saba',
BR: 'Brazil',
BS: 'Bahamas',
BT: 'Bhutan',
BV: 'Bouvet Island',
BW: 'Botswana',
BY: 'Belarus',
BZ: 'Belize',
CA: 'Canada',
CC: 'Cocos (Keeling) Islands',
CD: 'Congo - Kinshasa',
CF: 'Central African Republic',
CG: 'Congo - Brazzaville',
CH: 'Switzerland',
CI: 'Côte d’Ivoire',
CK: 'Cook Islands',
CL: 'Chile',
CM: 'Cameroon',
CN: 'China',
CO: 'Colombia',
CP: 'Clipperton Island',
CR: 'Costa Rica',
CU: 'Cuba',
CV: 'Cabo Verde',
CW: 'Curaçao',
CX: 'Christmas Island',
CY: 'Cyprus',
CZ: 'Czechia',
DE: 'Germany',
DG: 'Diego Garcia',
DJ: 'Djibouti',
DK: 'Denmark',
DM: 'Dominica',
DO: 'Dominican Republic',
DZ: 'Algeria',
EA: 'Ceuta and Melilla',
EC: 'Ecuador',
EE: 'Estonia',
EG: 'Egypt',
EH: 'Western Sahara',
ER: 'Eritrea',
ES: 'Spain',
ET: 'Ethiopia',
EU: 'European Union',
EZ: 'Eurozone',
FI: 'Finland',
FJ: 'Fiji',
FK: 'Falkland Islands',
FM: 'Federated States of Micronesia',
FO: 'Faroe Islands',
FR: 'France',
GA: 'Gabon',
GB: 'United Kingdom',
GD: 'Grenada',
GE: 'Georgia',
GF: 'French Guiana',
GG: 'Guernsey',
GH: 'Ghana',
GI: 'Gibraltar',
GL: 'Greenland',
GM: 'Gambia',
GN: 'Guinea',
GP: 'Guadeloupe',
GQ: 'Equatorial Guinea',
GR: 'Greece',
GS: 'South Georgia and South Sandwich Islands',
GT: 'Guatemala',
GU: 'Guam',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HK: 'Hong Kong (SAR)',
HM: 'Heard Island and McDonald Islands',
HN: 'Honduras',
HR: 'Croatia',
HT: 'Haiti',
HU: 'Hungary',
IC: 'Canary Islands',
ID: 'Indonesia',
IE: 'Ireland',
IL: 'Israel',
IM: 'Isle of Man',
IN: 'India',
IO: 'British Indian Ocean Territory',
IQ: 'Iraq',
IR: 'Iran',
IS: 'Iceland',
IT: 'Italy',
JE: 'Jersey',
JM: 'Jamaica',
JO: 'Jordan',
JP: 'Japan',
KE: 'Kenya',
KG: 'Kyrgyzstan',
KH: 'Cambodia',
KI: 'Kiribati',
KM: 'Comoros',
KN: 'Saint Kitts and Nevis',
KP: 'North Korea',
KR: 'South Korea',
KW: 'Kuwait',
KY: 'Cayman Islands',
KZ: 'Kazakhstan',
LA: 'Laos',
LB: 'Lebanon',
LC: 'Saint Lucia',
LI: 'Liechtenstein',
LK: 'Sri Lanka',
LR: 'Liberia',
LS: 'Lesotho',
LT: 'Lithuania',
LU: 'Luxembourg',
LV: 'Latvia',
LY: 'Libya',
MA: 'Morocco',
MC: 'Monaco',
MD: 'Moldova',
ME: 'Montenegro',
MF: 'Saint Martin',
MG: 'Madagascar',
MH: 'Marshall Islands',
MK: 'North Macedonia',
ML: 'Mali',
MM: 'Myanmar (Burma)',
MN: 'Mongolia',
MO: 'Macau (SAR)',
MP: 'Northern Mariana Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MS: 'Montserrat',
MT: 'Malta',
MU: 'Mauritius',
MV: 'Maldives',
MW: 'Malawi',
MX: 'Mexico',
MY: 'Malaysia',
MZ: 'Mozambique',
NA: 'Namibia',
NC: 'New Caledonia',
NE: 'Niger',
NF: 'Norfolk Island',
NG: 'Nigeria',
NI: 'Nicaragua',
NL: 'Netherlands',
NO: 'Norway',
NP: 'Nepal',
NR: 'Nauru',
NU: 'Niue',
NZ: 'New Zealand',
OM: 'Oman',
PA: 'Panama',
PE: 'Peru',
PF: 'French Polynesia',
PG: 'Papua New Guinea',
PH: 'Philippines',
PK: 'Pakistan',
PL: 'Poland',
PM: 'Saint Pierre and Miquelon',
PN: 'Pitcairn Islands',
PR: 'Puerto Rico',
PS: 'Palestinian Territories',
PT: 'Portugal',
PW: 'Palau',
PY: 'Paraguay',
QA: 'Qatar',
QO: 'Outlying Oceania',
RE: 'Réunion',
RO: 'Romania',
RS: 'Serbia',
RU: 'Russia',
RW: 'Rwanda',
SA: 'Saudi Arabia',
SB: 'Solomon Islands',
SC: 'Seychelles',
SD: 'Sudan',
SE: 'Sweden',
SG: 'Singapore',
SH: 'Saint Helena',
SI: 'Slovenia',
SJ: 'Svalbard and Jan Mayen',
SK: 'Slovakia',
SL: 'Sierra Leone',
SM: 'San Marino',
SN: 'Senegal',
SO: 'Somalia',
SR: 'Suriname',
SS: 'South Sudan',
ST: 'São Tomé and Príncipe',
SV: 'El Salvador',
SX: 'Sint Maarten',
SY: 'Syria',
SZ: 'Eswatini',
TA: 'Tristan da Cunha',
TC: 'Turks and Caicos Islands',
TD: 'Chad',
TF: 'French Southern Territories',
TG: 'Togo',
TH: 'Thailand',
TJ: 'Tajikistan',
TK: 'Tokelau',
TL: 'Timor-Leste',
TM: 'Turkmenistan',
TN: 'Tunisia',
TO: 'Tonga',
TR: 'Turkey',
TT: 'Trinidad and Tobago',
TV: 'Tuvalu',
TW: 'Taiwan',
TZ: 'Tanzania',
UA: 'Ukraine',
UG: 'Uganda',
US: 'United States',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VA: 'Vatican City',
VN: 'Vietnam',
VU: 'Vanuatu',
YE: 'Yemen',
ZA: 'South Africa',
ZM: 'Zambia',
ZW: 'Zimbabwe',
UM: 'United States Minor Outlying Islands',
UN: 'United Nations',
VC: 'Saint Vincent and the Grenadines',
VE: 'Venezuela',
VG: 'Virgin Islands (UK)',
VI: 'Virgin Islands (US)',
WF: 'Wallis and Futuna',
WS: 'Samoa',
XA: 'Pseudo-accents',
XB: 'Pseudo-Bidi',
XK: 'Kosovo',
YT: 'Mayotte',
ZZ: 'Unknown region',
};
+8
View File
@@ -0,0 +1,8 @@
export const ViewsByValue = {
RECOMMENDED: 'Explore',
TRENDING: 'Most Active',
POPULAR: 'Popular',
NEW: 'New',
// Not sure if this is used
// FEATURED: 'Featured',
};
@@ -0,0 +1,61 @@
import {
ApiKey,
IApiKeyRepository,
} from '@waha/core/storage/IApiKeyRepository';
import { Collection } from 'mongodb';
import { MongoStore } from './MongoStore';
export class MongoApiKeyRepository implements IApiKeyRepository {
private collection: Collection<ApiKey>;
constructor(store: MongoStore) {
this.collection = store.getMainDb().collection('api_key');
}
async init(): Promise<void> {
await this.collection.createIndex({ id: 1 }, { unique: true });
await this.collection.createIndex({ key: 1 }, { unique: true });
await this.collection.createIndex({ session: 1 });
}
async list(): Promise<ApiKey[]> {
const keys = await this.collection.find().toArray();
return keys.map((key) => this.stripId(key));
}
async upsert(key: ApiKey): Promise<ApiKey> {
await this.collection.replaceOne({ id: key.id }, key, { upsert: true });
return key;
}
async getActiveByKey(key: string): Promise<ApiKey | null> {
const data = await this.collection.findOne({ key: key, isActive: true });
return data ? this.stripId(data) : null;
}
async getById(id: string): Promise<ApiKey | null> {
const data = await this.collection.findOne({ id: id });
return data ? this.stripId(data) : null;
}
async getByKey(key: string): Promise<ApiKey | null> {
const data = await this.collection.findOne({ key: key });
return data ? this.stripId(data) : null;
}
async deleteById(id: string): Promise<void> {
await this.collection.deleteOne({ id: id });
}
async deleteBySession(session: string): Promise<void> {
await this.collection.deleteMany({ session: session });
}
private stripId(key: ApiKey): ApiKey {
if ('_id' in key) {
delete (key as { _id?: unknown })._id;
}
return key;
}
}
@@ -0,0 +1,19 @@
import { ISessionAuthRepository } from '../../../core/storage/ISessionAuthRepository';
import { MongoStore } from './MongoStore';
export class MongoSessionAuthRepository implements ISessionAuthRepository {
private store: MongoStore;
constructor(store: MongoStore) {
this.store = store;
}
async init(sessionName?: string): Promise<void> {
return;
}
async clean(sessionName: string): Promise<void> {
const db = this.store.getSessionDb(sessionName);
await db.dropDatabase();
}
}
@@ -0,0 +1,80 @@
import { SessionConfig } from '@waha/structures/sessions.dto';
import { Collection, Db } from 'mongodb';
import { ISessionConfigRepository } from '../../../core/storage/ISessionConfigRepository';
import { MongoStore } from './MongoStore';
class SessionConfigWithName extends SessionConfig {
name: string;
}
export class MongoSessionConfigRepository extends ISessionConfigRepository {
private collection: Collection<SessionConfigWithName>;
constructor(store: MongoStore) {
super();
this.collection = store.getMainDb().collection('sessions');
}
async exists(sessionName: string): Promise<boolean> {
return !!(await this.collection.findOne({ name: sessionName }));
}
async saveConfig(sessionName: string, config: SessionConfig): Promise<void> {
await this.collection.replaceOne(
{ name: sessionName },
{ ...config, name: sessionName },
{ upsert: true },
);
}
async getConfig(sessionName: string): Promise<SessionConfig> {
const result = await this.collection.findOne({
name: sessionName,
});
if (!result) {
return null;
}
delete result._id;
delete result.name;
return result;
}
async getConfigBySessions(
sessionNames: string[],
): Promise<Map<string, SessionConfig | null>> {
const result = new Map<string, SessionConfig | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
for (const sessionName of uniqueNames) {
result.set(sessionName, null);
}
const docs = await this.collection
.find({ name: { $in: uniqueNames } })
.toArray();
for (const doc of docs) {
const { _id, name, ...config } = doc;
result.set(name, config ?? null);
}
return result;
}
async deleteConfig(sessionName: string): Promise<void> {
await this.collection.deleteOne({ name: sessionName });
}
private async getAllWithName(): Promise<SessionConfigWithName[]> {
return await this.collection.find().toArray();
}
async getAllConfigs(): Promise<string[]> {
const sessions = await this.getAllWithName();
return sessions.map((session) => session.name);
}
async init() {
return;
}
}
@@ -0,0 +1,60 @@
import { ISessionMeRepository } from '@waha/core/storage/ISessionMeRepository';
import { MeInfo, SessionConfig } from '@waha/structures/sessions.dto';
import { Collection } from 'mongodb';
import { MongoStore } from './MongoStore';
class SessionMeInfo {
session: string;
me?: MeInfo;
}
export class MongoSessionMeRepository extends ISessionMeRepository {
private collection: Collection<SessionMeInfo>;
constructor(store: MongoStore) {
super();
this.collection = store.getMainDb().collection('me');
}
async upsertMe(sessionName: string, me: MeInfo): Promise<void> {
await this.collection.replaceOne(
{ session: sessionName },
{ me: me, session: sessionName },
{ upsert: true },
);
}
async getMe(sessionName: string): Promise<MeInfo> {
const data = await this.collection.findOne({ session: sessionName });
return data?.me;
}
async getMeBySessions(
sessionNames: string[],
): Promise<Map<string, MeInfo | null>> {
const result = new Map<string, MeInfo | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
for (const sessionName of uniqueNames) {
result.set(sessionName, null);
}
const docs = await this.collection
.find({ session: { $in: uniqueNames } })
.toArray();
for (const doc of docs) {
result.set(doc.session, doc.me ?? null);
}
return result;
}
async removeMe(sessionName: string): Promise<void> {
await this.collection.deleteOne({ session: sessionName });
}
async init(): Promise<void> {
await this.collection.createIndex({ session: 1 }, { unique: true });
}
}
@@ -0,0 +1,44 @@
import {
ISessionWorkerRepository,
SessionWorkerInfo,
} from '@waha/core/storage/ISessionWorkerRepository';
import { Collection } from 'mongodb';
import { MongoStore } from './MongoStore';
export class MongoSessionWorkerRepository implements ISessionWorkerRepository {
private collection: Collection<SessionWorkerInfo>;
constructor(store: MongoStore) {
this.collection = store.getMainDb().collection('session_worker');
}
async assign(session: string, worker: string): Promise<void> {
await this.collection.replaceOne(
{ id: session },
{ worker: worker, id: session },
{ upsert: true },
);
}
async unassign(session: string, worker: string): Promise<void> {
await this.collection.deleteMany({ id: session, worker: worker });
}
async remove(session: string): Promise<void> {
await this.collection.deleteMany({ id: session });
}
async getAll(): Promise<SessionWorkerInfo[]> {
return await this.collection.find().toArray();
}
async getSessionsByWorker(worker: string): Promise<string[]> {
const data = await this.collection.find({ worker: worker }).toArray();
return data.map((d) => d.id);
}
async init(): Promise<void> {
await this.collection.createIndex({ worker: 1 });
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Document } from 'bson';
import Knex from 'knex';
import { MongoClient } from 'mongodb';
import { DataStore } from '../../../core/abc/DataStore';
export class MongoStore extends DataStore {
private mongo: MongoClient;
private namespace: string;
private sessionNamespace: string;
constructor(mongo: MongoClient, namespace: string, sessionNamespace: string) {
super();
if (!mongo)
throw new Error(
'A valid MongoClient instance is required for MongoStore.',
);
this.mongo = mongo;
this.namespace = namespace;
this.sessionNamespace = sessionNamespace;
}
protected getMainDbName() {
return `waha_${this.namespace}`;
}
protected getSessionDbName(name: string) {
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '_');
return `waha_${this.sessionNamespace}_${slug}`;
}
getMainDb() {
return this.mongo.db(this.getMainDbName());
}
getSessionDb(name: string) {
return this.mongo.db(this.getSessionDbName(name));
}
command(command: Document) {
return this.mongo.db().admin().command(command);
}
async init(sessionName?: string): Promise<void> {
if (!sessionName) {
const collection = this.getMainDb().collection('sessions');
await collection.createIndex({ name: 1 }, { unique: true });
}
}
async close() {
await this.mongo?.close();
}
getWAHADatabase(): Knex.Knex {
throw new Error(
'MongoDB is deprecated and will not have all WAHA features. Consider switching to PostgreSQL',
);
}
}
@@ -0,0 +1,64 @@
import {
ApiKey,
IApiKeyRepository,
} from '@waha/core/storage/IApiKeyRepository';
import {
SQLApiKeyMigrations,
SQLApiKeySchema,
} from '@waha/core/storage/sql/schemas';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
export class PsqlApiKeyRepository
extends PsqlKVRepository<ApiKey>
implements IApiKeyRepository
{
get schema() {
return SQLApiKeySchema;
}
get migrations() {
return SQLApiKeyMigrations;
}
get metadata() {
return new Map<string, (entity: ApiKey) => any>([
['key', (entity) => entity.key],
['isActive', (entity) => (entity.isActive ? 1 : 0)],
['session', (entity) => entity.session],
]);
}
constructor(store: PsqlStore) {
super(store.knex);
}
list(): Promise<ApiKey[]> {
return this.getAll();
}
async upsert(key: ApiKey): Promise<ApiKey> {
await this.upsertOne(key);
return key;
}
getActiveByKey(key: string): Promise<ApiKey | null> {
return this.getBy({ key: key, isActive: 1 });
}
getById(id: string): Promise<ApiKey | null> {
return super.getById(id);
}
getByKey(key: string): Promise<ApiKey | null> {
return this.getBy({ key: key });
}
async deleteBySession(session: string): Promise<void> {
await this.deleteBy({ session: session });
}
protected async validateSchema() {
// TODO: Implement
}
}
+4
View File
@@ -0,0 +1,4 @@
export enum PsqlCode {
RELATION_NOT_FOUND = '42P01',
DATABASE_NOT_EXIST = '3D000',
}
@@ -0,0 +1,61 @@
export interface PsqlConnectionConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
application_name?: string;
ssl: boolean | { rejectUnauthorized: boolean };
}
export function parsePsql(url: string): PsqlConnectionConfig {
const urlObj = new URL(url);
if (urlObj.protocol != 'postgresql:' && urlObj.protocol != 'postgres:') {
throw new Error('Invalid postgresql url');
}
const host = urlObj.hostname;
const port = parseInt(urlObj.port, 10) || 5432;
const user = urlObj.username;
const password = urlObj.password;
const database = urlObj.pathname.split('/')[1];
// sslmode=disable
const ssl =
urlObj.searchParams.get('sslmode') !== 'disable'
? { rejectUnauthorized: false }
: false;
return {
host,
port,
user,
password,
database,
ssl,
};
}
export function changeDatabasePsql(config: PsqlConnectionConfig, name: string) {
return {
...config,
password: config.password,
database: name,
};
}
export function addSuffix(config: PsqlConnectionConfig, suffix: string) {
suffix = suffix || 'Unknown';
config.application_name = `${config.application_name}/${suffix}`;
}
export function stringifyPsql(config: PsqlConnectionConfig): string {
const url = new URL(
`postgresql://${config.user}:${config.password}@${config.host}:${config.port}/${config.database}`,
);
if (!config.ssl) {
url.searchParams.set('sslmode', 'disable');
}
return url.toString();
}
+374
View File
@@ -0,0 +1,374 @@
import { createReadStream, createWriteStream } from 'fs';
import { stat } from 'fs/promises';
import { pipeline } from 'stream/promises';
import { Transform, TransformCallback } from 'stream';
import Knex from 'knex';
import { Logger } from 'pino';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { from: copyFrom, to: copyTo } = require('pg-copy-streams');
/**
* Strips PostgreSQL binary COPY framing and emits the raw bytes of the first
* column from every tuple.
*
* Binary COPY layout:
* [11-byte signature][4-byte flags][4-byte ext_len][ext_data?]
* per-tuple: [int16 field_count][int32 field_len][field_bytes]
* trailer: [int16 = -1]
*/
class PgBinaryFirstColumnStream extends Transform {
private state:
| 'signature'
| 'flags'
| 'ext_len'
| 'ext_data'
| 'field_count'
| 'field_len'
| 'field_data'
| 'done' = 'signature';
private needed = 11;
private accumulator = Buffer.alloc(0);
private fieldBytesLeft = 0;
_transform(incoming: Buffer, _enc: string, cb: TransformCallback) {
let pos = 0;
while (pos < incoming.length) {
if (this.state === 'done') break;
if (this.state === 'field_data') {
const take = Math.min(incoming.length - pos, this.fieldBytesLeft);
this.push(incoming.slice(pos, pos + take));
this.fieldBytesLeft -= take;
pos += take;
if (this.fieldBytesLeft === 0) {
this.state = 'field_count';
this.needed = 2;
this.accumulator = Buffer.alloc(0);
}
continue;
}
const take = Math.min(
incoming.length - pos,
this.needed - this.accumulator.length,
);
this.accumulator = Buffer.concat([
this.accumulator,
incoming.slice(pos, pos + take),
]);
pos += take;
if (this.accumulator.length < this.needed) {
continue;
}
const acc = this.accumulator;
this.accumulator = Buffer.alloc(0);
switch (this.state) {
case 'signature':
this.state = 'flags';
this.needed = 4;
break;
case 'flags':
this.state = 'ext_len';
this.needed = 4;
break;
case 'ext_len': {
const extLen = acc.readUInt32BE(0);
if (extLen > 0) {
this.state = 'ext_data';
this.needed = extLen;
} else {
this.state = 'field_count';
this.needed = 2;
}
break;
}
case 'ext_data':
this.state = 'field_count';
this.needed = 2;
break;
case 'field_count': {
const count = acc.readInt16BE(0);
if (count === -1) {
this.state = 'done';
} else {
this.state = 'field_len';
this.needed = 4;
}
break;
}
case 'field_len': {
const len = acc.readInt32BE(0);
if (len === -1) {
this.state = 'field_count';
this.needed = 2;
} else {
this.state = 'field_data';
this.fieldBytesLeft = len;
}
break;
}
}
}
cb();
}
_flush(cb: TransformCallback) {
cb();
}
}
/**
* Wraps a raw binary stream in PostgreSQL binary COPY framing so it can be fed
* to `COPY … FROM STDIN (FORMAT BINARY)`.
*
* Binary COPY layout written:
* [11-byte signature][4-byte flags=0][4-byte ext_len=0]
* [int16 field_count=1][int32 field_len=fileSize][raw bytes…]
* [int16 trailer=-1]
*
* The total field size must be known upfront (pass via constructor).
*/
class PgBinaryWriteFrameStream extends Transform {
private headerSent = false;
private readonly fieldLen: number;
constructor(fieldLen: number) {
super();
this.fieldLen = fieldLen;
}
_transform(chunk: Buffer, _enc: string, cb: TransformCallback) {
if (!this.headerSent) {
this.push(this.buildFrameHeader());
this.headerSent = true;
}
this.push(chunk);
cb();
}
_flush(cb: TransformCallback) {
if (!this.headerSent) {
this.push(this.buildFrameHeader());
}
const trailer = Buffer.alloc(2);
trailer.writeInt16BE(-1, 0);
this.push(trailer);
cb();
}
private buildFrameHeader(): Buffer {
// [11-byte signature][4-byte flags][4-byte ext_len][2-byte field_count][4-byte field_len]
const buf = Buffer.alloc(25);
let offset = 0;
// Signature: PGCOPY\n\xff\r\n\0
buf.write('PGCOPY\n', offset, 'ascii');
offset += 7;
buf[offset++] = 0xff;
buf[offset++] = 0x0d;
buf[offset++] = 0x0a;
buf[offset++] = 0x00;
// flags (int32): 0
offset += 4;
// header extension area length (int32): 0
offset += 4;
// field_count (int16): 1
buf.writeInt16BE(1, offset);
offset += 2;
// field_len (int32): total byte size of the column value
buf.writeInt32BE(this.fieldLen, offset);
return buf;
}
}
function Migrations(table: string): string[] {
return [
`CREATE TABLE IF NOT EXISTS ${table}
(
id SERIAL PRIMARY KEY,
fullpath TEXT NOT NULL,
content BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_accessed_at TIMESTAMP DEFAULT NULL,
metadata JSONB
)`,
// fullpath is unique constraint
`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_fullpath_index ON ${table} (fullpath)`,
// index on created
`CREATE INDEX IF NOT EXISTS ${table}_created_at_index ON ${table} (created_at)`,
// index on last_accessed_at
`CREATE INDEX IF NOT EXISTS ${table}_last_accessed_at_index ON ${table} (last_accessed_at)`,
// index on metadata (JSONB)
`CREATE INDEX IF NOT EXISTS ${table}_metadata_index ON ${table} USING GIN (metadata)`,
];
}
export interface FileData {
fullpath: string;
content: Buffer;
created_at: number;
metadata: any;
}
/**
* General purpose file repository for storing files in a PostgreSQL database.
*/
export class PsqlFileRepository {
get tableName() {
return 'files';
}
constructor(
private knex: Knex.Knex,
private logger: Logger,
) {}
protected table() {
return this.knex(this.tableName);
}
/**
* Stream a local file directly into the database without loading it into
* memory. Uses PostgreSQL binary COPY into a temporary table, then upserts
* into the real table — safe for arbitrarily large files.
*/
async saveFromFile(fullpath: string, inputPath: string, metadata: any = {}) {
const { size } = await stat(inputPath);
const now = new Date().toISOString();
const conn = await (this.knex.client as any).acquireConnection();
try {
await conn.query('BEGIN');
try {
// Reuse the same temp table across pool connections; ON COMMIT DELETE ROWS
// clears it automatically at every successful COMMIT.
await conn.query(
'CREATE TEMP TABLE IF NOT EXISTS _waha_copy_tmp (content BYTEA) ON COMMIT DELETE ROWS',
);
// Guard against leftover rows from a previously rolled-back transaction.
await conn.query('TRUNCATE _waha_copy_tmp');
const copyStream = conn.query(
copyFrom('COPY _waha_copy_tmp FROM STDIN (FORMAT BINARY)'),
);
await pipeline(
createReadStream(inputPath),
new PgBinaryWriteFrameStream(size),
copyStream,
);
await conn.query(
`INSERT INTO ${this.tableName} (fullpath, content, created_at, metadata)
SELECT $1, content, $2, $3 FROM _waha_copy_tmp
ON CONFLICT (fullpath) DO UPDATE SET
content = EXCLUDED.content,
created_at = EXCLUDED.created_at,
metadata = EXCLUDED.metadata`,
[fullpath, now, metadata],
);
await conn.query('COMMIT');
} catch (err) {
await conn.query('ROLLBACK').catch(() => {});
throw err;
}
} finally {
(this.knex.client as any).releaseConnection(conn);
}
}
async save(fullpath: string, content: Buffer, metadata: any = {}) {
const now = new Date().toISOString();
await this.table()
.insert({
fullpath: fullpath,
content: content,
created_at: now,
metadata: metadata,
})
.onConflict('fullpath')
.merge({
content: this.knex.raw('EXCLUDED.content'),
created_at: this.knex.raw('EXCLUDED.created_at'),
metadata: this.knex.raw('EXCLUDED.metadata'),
});
}
async exists(fullpath: string): Promise<boolean> {
const result = await this.table().where('fullpath', fullpath);
return result.length > 0;
}
async delete(fullpath: string) {
await this.table().where('fullpath', fullpath).del();
}
/**
* Stream the content of a stored file directly to disk without materialising
* it in memory. Uses PostgreSQL binary COPY so the data never passes through
* Node's string layer — safe for arbitrarily large blobs.
*
* @returns true when the file was found and written; false when it does not exist.
*/
async fetchToFile(fullpath: string, outputPath: string): Promise<boolean> {
const exists = await this.exists(fullpath);
if (!exists) {
return false;
}
// Single-quote escaping is sufficient: fullpath values are session-derived
// filenames that never contain single quotes, but we escape defensively.
const escaped = fullpath.replace(/'/g, "''");
const sql = `COPY (SELECT content FROM ${this.tableName} WHERE fullpath = '${escaped}') TO STDOUT (FORMAT BINARY)`;
const conn = await (this.knex.client as any).acquireConnection();
try {
const copyStream = conn.query(copyTo(sql));
const writeStream = createWriteStream(outputPath);
await pipeline(copyStream, new PgBinaryFirstColumnStream(), writeStream);
} finally {
(this.knex.client as any).releaseConnection(conn);
}
this.touch(fullpath).catch((err) => {
this.logger.error(`Failed to update last_accessed_at: ${err}`);
});
return true;
}
async fetch(fullpath: string): Promise<FileData | null> {
const result = await this.table().where('fullpath', fullpath);
const data = result.length > 0 ? result[0] : null;
if (!data) {
return null;
}
data.created_at = new Date(data.created_at).getTime();
this.touch(fullpath).catch((err) => {
this.logger.error(`Failed to save last accessed time: ${err}`);
});
return data;
}
async init() {
for (const migration of this.migrations()) await this.knex.raw(migration);
}
protected async touch(fullpath: string) {
const now = new Date().toISOString();
await this.table()
.where('fullpath', fullpath)
.update({ last_accessed_at: now });
}
protected migrations() {
return Migrations(this.tableName);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { IJsonQuery } from '@waha/core/storage/sql/IJsonQuery';
export class PsqlJsonQuery implements IJsonQuery {
filter(field: string, key: string, value: any): [string, string] {
const paths = key.split('.');
key = paths.pop();
const jsonPath = paths.map((k) => `'${k}'`).join('->');
if (jsonPath) {
return [`${field}::json->${jsonPath}->>'${key}' = ? `, value];
}
return [`${field}::json->>'${key}' = ? `, value];
}
sortBy(field: string, sortBy: string, direction: string): string {
const paths = sortBy.split('.');
sortBy = paths.pop();
const jsonPath = paths.map((k) => `'${k}'`).join('->');
if (jsonPath) {
return `${field}::json->${jsonPath}->>'${sortBy}' ${direction}`;
}
return `${field}::json->>'${sortBy}' ${direction}`;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
import { PsqlJsonQuery } from '@waha/core/storage/psql/PsqlJsonQuery';
import Knex from 'knex';
export class PsqlKVRepository<Entity> extends SqlKVRepository<Entity> {
protected knex: Knex.Knex;
protected jsonQuery = new PsqlJsonQuery();
constructor(knex: Knex.Knex) {
super(knex);
}
}
@@ -0,0 +1,14 @@
import { ISessionAuthRepository } from '@waha/core/storage/ISessionAuthRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
export class PsqlSessionAuthRepository implements ISessionAuthRepository {
constructor(private store: PsqlStore) {}
async init(sessionName?: string): Promise<void> {
await this.store.init(sessionName);
}
async clean(sessionName: string): Promise<void> {
await this.store.removeDbForSession(sessionName);
}
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more