Compare commits

...
12 Commits
Author SHA1 Message Date
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
92 changed files with 7635 additions and 661 deletions

No files matched your search

+12 -1
View File
@@ -41,7 +41,7 @@ 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
@@ -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
+2 -2
View File
@@ -61,7 +61,7 @@
"@types/sqlite3": "^5.1.0",
"@types/ws": "^8.5.4",
"@wppconnect-team/wppconnect": "^2.2.1",
"@wppconnect/wa-js": "^4.3.0",
"@wppconnect/wa-js": "^4.3.1",
"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));
});
+4 -5
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,
@@ -205,7 +203,7 @@ export abstract class WhatsappSession {
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter = new CoreMediaConverter();
public mediaConverter: IMediaConverter;
public constructor({
name,
@@ -225,6 +223,7 @@ export abstract class WhatsappSession {
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$) => {
+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,
+447 -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,6 +43,7 @@ import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import {
isJidBroadcast,
isJidGroup,
isJidNewsletter,
normalizeJid,
toCusFormat,
toJID,
@@ -78,6 +76,7 @@ import {
ChatRequest,
CheckNumberStatusQuery,
EditMessageRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
@@ -89,6 +88,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
@@ -136,7 +136,10 @@ import {
import {
BROADCAST_ID,
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
import {
EnginePayload,
@@ -192,10 +195,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 {
@@ -899,12 +909,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 +1052,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 +1217,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 +1611,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 +1911,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 +2074,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 +2958,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 +3075,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();
}
}
+204 -74
View File
@@ -8,14 +8,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 +28,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 = reaction.parentMsgKey._serialized;
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 +124,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 +168,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 +192,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 +203,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 +214,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 +227,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 +258,26 @@ 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) =>
filter.ids.includes(chat.id._serialized),
);
}
// @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 +289,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 +307,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;
}
@@ -321,15 +384,15 @@ export class WebjsClientCore extends Client {
// object has the exact shape msgFindByDirection expects.
const lastReceivedSerialized = chat.lastReceivedKey?._serialized;
if (!lastReceivedSerialized) return [];
// @ts-ignore
let currentAnchorKey = window.Store.MsgKey.fromString(
lastReceivedSerialized,
);
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);
}
@@ -344,13 +407,12 @@ export class WebjsClientCore extends Client {
const toModel = (m) => {
if (m && typeof m.serialize === 'function') return m;
const serializedId = m?.id?._serialized;
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) {
@@ -403,19 +465,17 @@ export class WebjsClientCore extends Client {
const oldestInBatch = batchModels[batchModels.length - 1];
const oldestSerialized = oldestInBatch?.id?._serialized;
if (!oldestSerialized) break;
// @ts-ignore
currentAnchorKey = window.Store.MsgKey.fromString(oldestSerialized);
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 +524,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'];
@@ -490,8 +549,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,10 +560,8 @@ 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);
@@ -516,10 +572,8 @@ export class WebjsClientCore extends Client {
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);
@@ -578,4 +632,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(msg.id._serialized) || [],
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',
);
};
+325 -38
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,
@@ -173,6 +184,7 @@ import {
} from 'whatsapp-web.js/src/structures';
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
import { WebJSAuthFactory } from './WebJSAuthFactory';
import {
isJidCus,
isJidGroup,
@@ -201,6 +213,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 +298,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());
}
@@ -651,14 +664,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;
@@ -746,12 +757,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 +892,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()
@@ -1460,23 +1624,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 {
@@ -1680,6 +1906,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);
@@ -2249,6 +2513,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>
{
+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 {}
+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);
}
}
@@ -0,0 +1,73 @@
import { ISessionConfigRepository } from '@waha/core/storage/ISessionConfigRepository';
import {
SQLSessionConfigMigrations,
SQLSessionConfigSchema,
} from '@waha/core/storage/sql/schemas';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { SessionConfig } from '@waha/structures/sessions.dto';
class SessionConfigInfo {
id: string;
config?: SessionConfig;
}
export class PsqlSessionConfigRepository
extends PsqlKVRepository<SessionConfigInfo>
implements ISessionConfigRepository
{
constructor(store: PsqlStore) {
super(store.knex);
}
get schema() {
return SQLSessionConfigSchema;
}
get migrations() {
return SQLSessionConfigMigrations;
}
saveConfig(sessionName: string, config: SessionConfig): Promise<void> {
return this.upsertOne({ id: sessionName, config: config });
}
async getConfig(sessionName: string): Promise<SessionConfig | null> {
const data = await this.getById(sessionName);
return data?.config ?? null;
}
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;
}
const entities = await this.getEntitiesByIds(uniqueNames);
for (const sessionName of uniqueNames) {
const entity = entities.get(sessionName);
result.set(sessionName, entity?.config ?? null);
}
return result;
}
async exists(sessionName: string): Promise<boolean> {
const data = await this.getById(sessionName);
return data !== null;
}
async deleteConfig(sessionName: string): Promise<void> {
return this.deleteById(sessionName);
}
async getAllConfigs(): Promise<string[]> {
const configs = await this.getAll();
return configs.map((config) => config.id);
}
protected async validateSchema() {
// TODO: Implement
}
}
@@ -0,0 +1,60 @@
import { ISessionMeRepository } from '@waha/core/storage/ISessionMeRepository';
import { SQLMeMigrations, SQLMeSchema } from '@waha/core/storage/sql/schemas';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
import { MeInfo } from '@waha/structures/sessions.dto';
class SessionMeInfo {
id: string;
me?: MeInfo;
}
export class PsqlSessionMeRepository
extends PsqlKVRepository<SessionMeInfo>
implements ISessionMeRepository
{
constructor(store: PsqlStore) {
super(store.knex);
}
get schema() {
return SQLMeSchema;
}
get migrations() {
return SQLMeMigrations;
}
upsertMe(sessionName: string, me: MeInfo): Promise<void> {
return this.upsertOne({ id: sessionName, me: me });
}
async getMe(sessionName: string): Promise<MeInfo | null> {
const data = await this.getById(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;
}
const entities = await this.getEntitiesByIds(uniqueNames);
for (const sessionName of uniqueNames) {
const entity = entities.get(sessionName);
result.set(sessionName, entity?.me ?? null);
}
return result;
}
removeMe(sessionName: string): Promise<void> {
return this.deleteById(sessionName);
}
protected async validateSchema() {
// TODO: Implement
}
}
@@ -0,0 +1,48 @@
import {
ISessionWorkerRepository,
SessionWorkerInfo,
} from '@waha/core/storage/ISessionWorkerRepository';
import {
SQLSessionWorkerMigrations,
SQLSessionWorkerSchema,
} from '@waha/core/storage/sql/schemas';
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
export class PsqlSessionWorkerRepository
extends PsqlKVRepository<SessionWorkerInfo>
implements ISessionWorkerRepository
{
get schema() {
return SQLSessionWorkerSchema;
}
get migrations() {
return SQLSessionWorkerMigrations;
}
constructor(store: PsqlStore) {
super(store.knex);
}
assign(session: string, worker: string): Promise<void> {
return this.upsertOne({ id: session, worker: worker });
}
unassign(session: string, worker: string): Promise<void> {
return this.deleteBy({ id: session, worker: worker });
}
remove(session: string) {
return this.deleteById(session);
}
async getSessionsByWorker(worker: string): Promise<string[]> {
const data = await this.getAllBy({ worker: worker });
return data.map((d) => d.id);
}
protected async validateSchema() {
// TODO: Implement
}
}
+131
View File
@@ -0,0 +1,131 @@
import { DataStore } from '@waha/core/abc/DataStore';
import {
addSuffix,
changeDatabasePsql,
PsqlConnectionConfig,
stringifyPsql,
} from '@waha/core/storage/psql/PsqlConnectionConfig';
import Knex from 'knex';
const PostgreSQLKnexConfig: Knex.Knex.Config = {
client: 'pg',
useNullAsDefault: true,
pool: {
min: 0,
max: 10,
},
acquireConnectionTimeout: 120_000, // by default, it's 60s
};
export class PsqlStore extends DataStore {
// postgres database
// Use to create new databases
private main: Knex.Knex;
// waha database
// Use to store the WAHA session data
public knex: Knex.Knex;
constructor(
private config: PsqlConnectionConfig,
private namespace: string,
private sessionNamespace: string,
) {
super();
this.main = Knex({
connection: config,
...PostgreSQLKnexConfig,
});
}
async init(sessionName?: string): Promise<void> {
if (!sessionName) {
if (this.knex) {
return;
}
const dbName = this.getMainDbName();
await this.upsertDatabase(dbName);
const config = changeDatabasePsql(this.config, dbName);
addSuffix(config, 'Sessions');
this.knex = Knex({
connection: config,
...PostgreSQLKnexConfig,
});
} else {
const dbName = this.getSessionDbName(sessionName);
await this.upsertDatabase(dbName);
}
}
private async upsertDatabase(name: string) {
const exists = await this.databaseExists(name);
if (exists) {
return;
}
await this.createDatabase(name);
}
private async databaseExists(databaseName) {
const result = await this.main
.select(1)
.from('pg_database')
.where('datname', databaseName);
return result.length > 0; // Returns true if database exists
}
private async createDatabase(databaseName) {
await this.main.raw(`CREATE DATABASE ??`, [databaseName]);
}
private async removeDatabase(name: string) {
const exists = await this.databaseExists(name);
if (!exists) {
return;
}
// Force close all connections to the database
await this.main.raw(`DROP DATABASE ?? WITH (FORCE);`, [name]);
}
async close(): Promise<any> {
await this.main.destroy();
await this.knex?.destroy();
}
protected getMainDbName() {
return `waha_${this.namespace}`;
}
protected getSessionDbName(name: string) {
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '_');
return `waha_${this.sessionNamespace}_${slug}`;
}
public async removeDbForSession(name) {
const dbname = this.getSessionDbName(name);
await this.removeDatabase(dbname);
}
public getSessionDbURL(name): string {
const dbname = this.getSessionDbName(name);
const config = changeDatabasePsql(this.config, dbname);
return stringifyPsql(config);
}
public buildSessionKnex(name: string, suffix: string): Knex.Knex {
const dbName = this.getSessionDbName(name);
const config = changeDatabasePsql(this.config, dbName);
addSuffix(config, suffix);
return Knex({
connection: config,
...PostgreSQLKnexConfig,
});
}
getWAHADatabase(): Knex.Knex {
if (!this.knex) {
throw new Error('Knex is not initialized, call LocalStore.init() first');
}
return this.knex;
}
}
@@ -0,0 +1,67 @@
import { Sqlite3SchemaValidation } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3SchemaValidation';
import {
IApiKeyRepository,
ApiKey,
} from '@waha/core/storage/IApiKeyRepository';
import { LocalStore } from '@waha/core/storage/LocalStore';
import {
SQLApiKeyMigrations,
SQLApiKeySchema,
} from '@waha/core/storage/sql/schemas';
import { Sqlite3KVRepository } from '@waha/core/storage/sqlite3/Sqlite3KVRepository';
export class Sqlite3ApiKeyRepository
extends Sqlite3KVRepository<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: LocalStore) {
const knex = store.getWAHADatabase();
super(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() {
const validation = new Sqlite3SchemaValidation(this.schema, this.knex);
await validation.validate();
}
}
+215
View File
@@ -0,0 +1,215 @@
import { TmpDir } from '@waha/utils/tmpdir';
import { spawn } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import { Logger } from 'pino';
import { IMediaConverter } from '@waha/core/media/IConverter';
function IsMP3(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 3) return false;
// ID3 header: 0x49 0x44 0x33 = 'I', 'D', '3'
const b0 = buffer[0]; // 0x49
const b1 = buffer[1]; // 0x44
const b2 = buffer[2]; // 0x33
const isID3 = b0 === 0x49 && b1 === 0x44 && b2 === 0x33;
// MPEG frame sync (frame starts with 0xFF Ex, e.g. FB, F3, F2)
const isMPEGFrame = buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
return isID3 || isMPEGFrame;
}
interface ICommand {
get input(): string | null;
get output(): string | null;
spawn(cwd: string, logger: Logger): Promise<void>;
}
class Command implements ICommand {
private readonly bin: string;
private args: string[];
constructor(
private command: string,
public input: string = null,
public output: string = null,
) {
const parts = command.split(' ');
this.bin = parts[0];
this.args = parts.slice(1);
this.checkArgs();
}
private checkArgs() {
if (this.input && !this.args.includes(this.input)) {
throw new Error(
`Invalid command "${this.command}", must contain input "${this.input}"`,
);
}
if (this.output && !this.args.includes(this.output)) {
throw new Error(
`Invalid command "${this.command}", must contain output "${this.output}"`,
);
}
}
public spawn(cwd: string | undefined, logger: Logger): Promise<void> {
const args = this.args.slice();
logger.debug(`Executing command: '${this.bin} ${args.join(' ')}'...`);
return new Promise((resolve, reject) => {
const command = spawn(this.bin, args, { cwd: cwd });
command.stderr.on('data', (data) => {
// command might output progress information to stderr
logger.debug(`${this.bin}: ${data}`);
});
command.on('close', (code) => {
logger.debug(`${this.bin} exited with code ${code}`);
if (code === 0) {
resolve();
} else {
reject(
new Error(
`${this.bin} process exited with code ${code}. Check logs to find the reason`,
),
);
}
});
command.on('error', (err) => {
reject(
new Error(`Failed to start ${this.bin} process: ${err.message}`),
);
});
});
}
}
class CommandPipe implements ICommand {
constructor(private commands: ICommand[]) {}
get input(): string {
return this.commands[0].input;
}
get output(): string {
return this.commands[this.commands.length - 1].output;
}
async spawn(cwd: string, logger: Logger): Promise<void> {
for (const cmd of this.commands) {
if (!cmd.input) {
new Error(`Command in pipeline is missing input file specification`);
}
if (!cmd.output) {
new Error(`Command in pipeline is missing output file specification`);
}
await cmd.spawn(cwd, logger);
}
}
}
function pipeline(...commands: ICommand[]): ICommand {
return new CommandPipe(commands);
}
class Ffmpeg implements IMediaConverter {
private readonly tmpdir: TmpDir;
// Cleanup mp3 metadata and convert to wav
// https://github.com/devlikeapro/waha/issues/1393
private VoiceCleanupMp3 = new Command(
'ffmpeg -hide_banner -loglevel error -nostdin -y -i input.mp3 -vn -sn -dn -map 0:a:0 -map_metadata -1 -ac 1 -ar 48000 -c:a pcm_s16le input.wav',
'input.mp3',
'input.wav',
);
// Convert wav to ogg opus with WhatsApp settings
// $ ffprobe -hide_banner -v error -show_format -show_streams "output.opus"
private VoiceConvertToOGG = new Command(
'ffmpeg -hide_banner -loglevel error -nostdin -i input.wav -c:a libopus -b:a 32k -ar 48000 -ac 1 output.opus',
'input.wav',
'output.opus',
);
// Clean up opus tags that may reveal the encoder used
// $ mediainfo "output.opus"
private VoiceCleanOpustags = new Command(
'opustags --in-place --delete ENCODER --delete encoder --set-vendor Recorder output.opus',
'output.opus',
'output.opus',
);
private OpustagsHelp = new Command('opustags --help');
private WhatsAppVoice: ICommand;
// Convert video to WhatsApp compatible mp4
// TODO: Do we need to remove metadata as well?
private WhatsAppVideo = new Command(
'ffmpeg -hide_banner -loglevel error -nostdin -i input.mp4 -c:v libx264 -map 0 -pix_fmt yuv420p -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 -movflags +faststart output.mp4',
'input.mp4',
'output.mp4',
);
constructor(
session: string,
private logger: Logger,
) {
this.tmpdir = new TmpDir(logger, `waha-ffmpeg-${session}-`);
}
protected async process(cmd: ICommand, content: Buffer): Promise<Buffer> {
return await this.tmpdir.use(async (dir) => {
const inputFile = path.join(dir, cmd.input);
const outputFile = path.join(dir, cmd.output);
await fs.writeFile(inputFile, content);
await cmd.spawn(dir, this.logger);
return await fs.readFile(outputFile);
});
}
/**
* Process audio content to make it compatible with WhatsApp
* @param content Audio buffer to process
* @returns Processed audio buffer or original buffer if processing fails
*/
public async voice(content: Buffer): Promise<Buffer> {
if (!this.WhatsAppVoice) {
try {
// Test if opustags available
await this.OpustagsHelp.spawn(undefined, this.logger);
this.logger.debug(
'opustags found, voice messages will have metadata cleaned',
);
this.WhatsAppVoice = pipeline(
this.VoiceCleanupMp3,
this.VoiceConvertToOGG,
this.VoiceCleanOpustags,
);
} catch (e) {
this.logger.warn(
`opustags not found, voice messages will not have metadata cleaned: ${e.message}`,
);
this.WhatsAppVoice = pipeline(
this.VoiceCleanupMp3,
this.VoiceConvertToOGG,
);
}
}
return this.process(this.WhatsAppVoice, content);
}
/**
* Process video content to make it compatible with WhatsApp
* @param content Video buffer to process
* @returns Processed video buffer or original buffer if processing fails
*/
public async video(content: Buffer): Promise<Buffer> {
return this.process(this.WhatsAppVideo, content);
}
}
export { Ffmpeg };
+23
View File
@@ -0,0 +1,23 @@
import { safeLoadJson } from './jsonutils';
describe('tryToFixJson', () => {
it('should return null if the input is not a valid JSON string', () => {
expect(safeLoadJson('')).toBeNull();
expect(safeLoadJson('not a json string')).toBeNull();
});
it('should return the parsed object if the input is a valid JSON string', () => {
const obj = { key: 'value' };
expect(safeLoadJson(JSON.stringify(obj))).toEqual({ key: 'value' });
});
it('should parse invalid json', () => {
const value = '{"key":"value"}"lastPropHash":"3H9ELF"}';
expect(safeLoadJson(value)).toEqual({ key: 'value' });
});
it('should parse invalid json', () => {
const value =
'{"noiseKey":{"private":{"type":"Buffer","data":"qFDxKVA87w7+TvcVRfiMGd3O0x53HnvMbXQUl/jP1Xo="},"public":{"type":"Buffer","data":"qszWbQ107oiEOM62Ipv2sXMRhzfPN+ny9rFxdasjiA4="}},"pairingEphemeralKeyPair":{"private":{"type":"Buffer","data":"CIQFzNIBHeENeONBcWLxOZddCD2yTNCFqYQUsczpnlg="},"public":{"type":"Buffer","data":"gB046aofbCKQnbFZl3aWK+rGoqVhYB2cqG4CfTB1NDQ="}},"signedIdentityKey":{"private":{"type":"Buffer","data":"IE1mRO3Ec+h0zeK6XajVZS3fmHpKlVLa6uiLKC97PkI="},"public":{"type":"Buffer","data":"Qi7rIVHdoIiWAuRVYaENj1sWfxK0zqslMoH5xKcHeCc="}},"signedPreKey":{"keyPair":{"private":{"type":"Buffer","data":"AJnylKf9BfgDpk10VIZNOI1S3fb5kyQVqktwwJ/VQ3U="},"public":{"type":"Buffer","data":"b3Sgv+reNHXoCaF6UKqdR5O8y9a2eNtD2BrBSbgeLnY="}},"signature":{"type":"Buffer","data":"dpJc5ZCcgCJEKlPaKTBnzZNzoROoDpYQXU6MW/mmsgQNZoVEa2QSj5uv49L+xjpzz0/sQ48OCkogMUWVuDP2iQ=="},"keyId":1},"registrationId":247,"advSecretKey":"XNABqLVRwwySZk9QA/axtSvJnRqpeGBCx70StzmlS60=","processedHistoryMessages":[{"key":{"remoteJid":"5511937140620@s.whatsapp.net","fromMe":true,"id":"40318500476EC1CD686FD51CC333861A"},"messageTimestamp":1716567185},{"key":{"remoteJid":"5511937140620@s.whatsapp.net","fromMe":true,"id":"C7B8AED6A674B1FDBD6DF12E9B4A8260"},"messageTimestamp":1716567193}],"nextPreKeyId":189,"firstUnuploadedPreKeyId":189,"accountSyncCounter":1,"accountSettings":{"unarchiveChats":false},"deviceId":"5v8mNri1TM-15ujww7VTRA","phoneId":"7a500cf3-cb51-4a44-9903-a97bdf14669b","identityId":{"type":"Buffer","data":"JGYBQBa9r/cFY1ls02pOekV6nlw="},"registered":false,"backupToken":{"type":"Buffer","data":"VVOJJ/2hY0KV/aZ9cRkZFwhsNi8="},"registration":{},"account":{"details":"CNGD2LACEPf4wrIGGA8gACgA","accountSignatureKey":"D0yrhIf0hU1m192AcTVWw4q8WnFwDEcp9fCThkKWHVc=","accountSignature":"xIjOvPJeurMrjboW4C48MTDN6aRC2WFtFwLlrmi8mxXXSMl+QSU/8QP9zpTXmjOcFk90uOtjLT45TXZ9PrzvCw==","deviceSignature":"Qfqcvva8MUfxZBxvuHJw0mAQ1KASKJ85fLeP2sf5EmWPNMVFLvcn9PuIGomCKYduPcx15dWCfDlh2R0oV8mehQ=="},"me":{"id":"5511937140620:89@s.whatsapp.net","name":"Autoescola R3","lid":"61366492770351:89@lid"},"signalIdentities":[{"identifier":{"name":"5511937140620:89@s.whatsapp.net","deviceId":0},"identifierKey":{"type":"Buffer","data":"BQ9Mq4SH9IVNZtfdgHE1VsOKvFpxcAxHKfXwk4ZClh1X"}}],"platform":"smba","routingInfo":{"type":"Buffer","data":"CAIIDA=="},"lastAccountSyncTimestamp":1716572438,"myAppStateKeyId":"AAAAAMik"}"lastPropHash":"3H9ELF"}';
expect(safeLoadJson(value)).toBeTruthy();
});
});
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'fs/promises';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const writeFileAtomic = require('write-file-atomic');
export function safeLoadJson(data: string): any {
try {
return JSON.parse(data);
} catch (error) {
return fixJson(data);
}
}
function fixJson(data: string): any {
const value: string[] = [];
const stack = [];
let ind = 0;
let current = data[0];
if (current != '{') {
return null;
}
stack.push(current);
value.push(current);
while (stack.length > 0 && ind < data.length) {
ind++;
current = data[ind];
if (current == '{') {
stack.push(current);
} else if (current == '}') {
stack.pop();
}
value.push(current);
}
return JSON.parse(value.join(''));
}
export async function makeSureJsonFile(filepath: string) {
let content;
try {
content = await fs.readFile(filepath, { encoding: 'utf-8' });
} catch (error) {
// no file or access, ignore and continue
return;
}
try {
JSON.parse(content);
// valid json
return;
} catch (error) {
console.error(`Invalid '${filepath}' JSON file, fixing it...`);
console.error(`Previous '${filepath}' content: ${content}`);
const data = fixJson(content);
if (!data) {
throw new Error(`Failed to fix '${filepath}' file. Content: ${content}`);
}
const fixedContent = JSON.stringify(data);
await writeFileAtomic(filepath, fixedContent, { encoding: 'utf-8' });
console.error(`Fixed ${filepath} file!`);
console.error(`Fixed '${filepath}' content: ${fixedContent}`);
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.5.1',
version: '2026.6.2',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
+2 -2
View File
@@ -1,8 +1,8 @@
{
"waha": {
"gows": {
"repo": "devlikeapro/gows",
"ref": "v1.0.40"
"repo": "devlikeapro/gows-plus",
"ref": "v1.0.42"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
+162 -226
View File
@@ -7,7 +7,7 @@ __metadata:
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-04-28":
version: 7.0.0-rc13
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=f619b7e841450140e444a0e93ff3f04ab50519b6"
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=e45231f4a2b289ef739d5c4f74f51ca284f17069"
dependencies:
"@cacheable/node-cache": "npm:^1.4.0"
"@hapi/boom": "npm:^9.1.3"
@@ -32,7 +32,7 @@ __metadata:
optional: true
link-preview-js:
optional: true
checksum: 10/535e52bf0b5bad381c985fcead24cb8c262a97662b9aaf3a4c0081c47ba02204e9f015e96dfb043f9d8e75295498e3e7468ee056f82f832af882a321c475d7bd
checksum: 10/b0c7dee01e1b39db8bd1e745fa77aa1e2b3a288fb88ebbe81eb2079e63dfd5d1fa8503b2d28f0eb063d1e6782fd7b97567311a33c6444a7129a86cc4c6ab2d3d
languageName: node
linkType: hard
@@ -4668,6 +4668,13 @@ __metadata:
languageName: node
linkType: hard
"@wppconnect/wa-js@npm:^4.3.1":
version: 4.3.1
resolution: "@wppconnect/wa-js@npm:4.3.1"
checksum: 10/5515e347fadeb9221b02ac92bd00e42f5dd31ad4236a0ca812b34060d993c5384e30e1dabf8bac85016f49075a5374ea87b7e5eb345cfdeabaabd6eb7a0b9ba2
languageName: node
linkType: hard
"@wppconnect/wa-version@npm:^1.5.3941":
version: 1.5.4014
resolution: "@wppconnect/wa-version@npm:1.5.4014"
@@ -4971,54 +4978,33 @@ __metadata:
languageName: node
linkType: hard
"archiver-utils@npm:^2.1.0":
version: 2.1.0
resolution: "archiver-utils@npm:2.1.0"
"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2":
version: 5.0.2
resolution: "archiver-utils@npm:5.0.2"
dependencies:
glob: "npm:^7.1.4"
glob: "npm:^10.0.0"
graceful-fs: "npm:^4.2.0"
is-stream: "npm:^2.0.1"
lazystream: "npm:^1.0.0"
lodash.defaults: "npm:^4.2.0"
lodash.difference: "npm:^4.5.0"
lodash.flatten: "npm:^4.4.0"
lodash.isplainobject: "npm:^4.0.6"
lodash.union: "npm:^4.6.0"
lodash: "npm:^4.17.15"
normalize-path: "npm:^3.0.0"
readable-stream: "npm:^2.0.0"
checksum: 10/4df493c0e6a3a544119b08b350308923500e2c6efee6a283cba4c3202293ce3acb70897e54e24f735e3a38ff43e5a65f66e2e5225fdfc955bf2335491377be2e
readable-stream: "npm:^4.0.0"
checksum: 10/9dde4aa3f0cb1bdfe0b3d4c969f82e6cca9ae76338b7fee6f0071a14a2a38c0cdd1c41ecd3e362466585aa6cc5d07e9e435abea8c94fd9c7ace35f184abef9e4
languageName: node
linkType: hard
"archiver-utils@npm:^3.0.4":
version: 3.0.4
resolution: "archiver-utils@npm:3.0.4"
"archiver@npm:7.0.1":
version: 7.0.1
resolution: "archiver@npm:7.0.1"
dependencies:
glob: "npm:^7.2.3"
graceful-fs: "npm:^4.2.0"
lazystream: "npm:^1.0.0"
lodash.defaults: "npm:^4.2.0"
lodash.difference: "npm:^4.5.0"
lodash.flatten: "npm:^4.4.0"
lodash.isplainobject: "npm:^4.0.6"
lodash.union: "npm:^4.6.0"
normalize-path: "npm:^3.0.0"
readable-stream: "npm:^3.6.0"
checksum: 10/a838c325a1e1d6798c07e6a3af08f480fdce57cba2964bff8761126715aa1b71e9a119442eac19b7ec6313f5298e54a180dc6612ae548825fbc9be6836e50487
languageName: node
linkType: hard
"archiver@npm:^5.3.1":
version: 5.3.2
resolution: "archiver@npm:5.3.2"
dependencies:
archiver-utils: "npm:^2.1.0"
archiver-utils: "npm:^5.0.2"
async: "npm:^3.2.4"
buffer-crc32: "npm:^0.2.1"
readable-stream: "npm:^3.6.0"
buffer-crc32: "npm:^1.0.0"
readable-stream: "npm:^4.0.0"
readdir-glob: "npm:^1.1.2"
tar-stream: "npm:^2.2.0"
zip-stream: "npm:^4.1.0"
checksum: 10/9384b3b20d330f95140c2b7a9b51140d14e9bc7b133be6cf573067ed8fc67a6e9618cfbfe60b1ba78b8034857001fd02c8900f2fba4864514670a2274d36dc9e
tar-stream: "npm:^3.0.0"
zip-stream: "npm:^6.0.1"
checksum: 10/81c6102db99d7ffd5cb2aed02a678f551c6603991a059ca66ef59249942b835a651a3d3b5240af4f8bec4e61e13790357c9d1ad4a99982bd2cc4149575c31d67
languageName: node
linkType: hard
@@ -5309,6 +5295,24 @@ __metadata:
languageName: node
linkType: hard
"bare-fs@npm:^4.5.5":
version: 4.7.2
resolution: "bare-fs@npm:4.7.2"
dependencies:
bare-events: "npm:^2.5.4"
bare-path: "npm:^3.0.0"
bare-stream: "npm:^2.6.4"
bare-url: "npm:^2.2.2"
fast-fifo: "npm:^1.3.2"
peerDependencies:
bare-buffer: "*"
peerDependenciesMeta:
bare-buffer:
optional: true
checksum: 10/a0fafaf46e6a3b77e7d36ff34f1f71e417d548c4ebac90238a0e42376b8efa1698f820133fbf8a09f6b6cb14cbd19f782d9c8bb8263cdf3c8e55e27ad2c1d648
languageName: node
linkType: hard
"bare-os@npm:^3.0.1":
version: 3.6.1
resolution: "bare-os@npm:3.6.1"
@@ -5342,6 +5346,15 @@ __metadata:
languageName: node
linkType: hard
"bare-url@npm:^2.2.2":
version: 2.4.5
resolution: "bare-url@npm:2.4.5"
dependencies:
bare-path: "npm:^3.0.0"
checksum: 10/65d7a906dae5051ba37d8b96fd3cd1ba8b8e1a7f74340a363c6b45123908d707a4d58c4e21c97f6735c4dfcd6c499d353a67cc8a4ff638d4e8b46a1d9984ad60
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -5376,13 +5389,6 @@ __metadata:
languageName: node
linkType: hard
"big-integer@npm:^1.6.17":
version: 1.6.52
resolution: "big-integer@npm:1.6.52"
checksum: 10/4bc6ae152a96edc9f95020f5fc66b13d26a9ad9a021225a9f0213f7e3dc44269f423aa8c42e19d6ac4a63bb2b22140b95d10be8f9ca7a6d9aa1b22b330d1f514
languageName: node
linkType: hard
"big.js@npm:^5.2.2":
version: 5.2.2
resolution: "big.js@npm:5.2.2"
@@ -5397,16 +5403,6 @@ __metadata:
languageName: node
linkType: hard
"binary@npm:~0.3.0":
version: 0.3.0
resolution: "binary@npm:0.3.0"
dependencies:
buffers: "npm:~0.1.1"
chainsaw: "npm:~0.1.0"
checksum: 10/127591ebb7bfca242ec11be9ef874bcde17c520f249d764810045971b6617b659e8af4452f8a1586db7fd47e1b481a75d22c8f207fc1466c0f099b9435e51679
languageName: node
linkType: hard
"bindings@npm:^1.5.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
@@ -5427,10 +5423,10 @@ __metadata:
languageName: node
linkType: hard
"bluebird@npm:~3.4.1":
version: 3.4.7
resolution: "bluebird@npm:3.4.7"
checksum: 10/340e4d11d4b6a26d90371180effb4e500197c2943e5426472d6b6bffca0032a534226ad10255fc0e39c025bea197341c6b2a4258f8c0f18217c7b3a254c76c14
"bluebird@npm:~3.7.2":
version: 3.7.2
resolution: "bluebird@npm:3.7.2"
checksum: 10/007c7bad22c5d799c8dd49c85b47d012a1fe3045be57447721e6afbd1d5be43237af1db62e26cb9b0d9ba812d2e4ca3bac82f6d7e016b6b88de06ee25ceb96e7
languageName: node
linkType: hard
@@ -5565,7 +5561,14 @@ __metadata:
languageName: node
linkType: hard
"buffer-crc32@npm:^0.2.1, buffer-crc32@npm:^0.2.13, buffer-crc32@npm:~0.2.3":
"buffer-crc32@npm:^1.0.0":
version: 1.0.0
resolution: "buffer-crc32@npm:1.0.0"
checksum: 10/ef3b7c07622435085c04300c9a51e850ec34a27b2445f758eef69b859c7827848c2282f3840ca6c1eef3829145a1580ce540cab03ccf4433827a2b95d3b09ca7
languageName: node
linkType: hard
"buffer-crc32@npm:~0.2.3":
version: 0.2.13
resolution: "buffer-crc32@npm:0.2.13"
checksum: 10/06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c
@@ -5579,13 +5582,6 @@ __metadata:
languageName: node
linkType: hard
"buffer-indexof-polyfill@npm:~1.0.0":
version: 1.0.2
resolution: "buffer-indexof-polyfill@npm:1.0.2"
checksum: 10/808c58a3f06cc6ee2231060959eaa31c490248465f2847e8cfebd3e62563521e67346391caad03ce7616fd765374eb53e941bdd22edb2336431171f46fddcd89
languageName: node
linkType: hard
"buffer@npm:^5.5.0":
version: 5.7.1
resolution: "buffer@npm:5.7.1"
@@ -5606,13 +5602,6 @@ __metadata:
languageName: node
linkType: hard
"buffers@npm:~0.1.1":
version: 0.1.1
resolution: "buffers@npm:0.1.1"
checksum: 10/9f0b64bbb8ac4783b1740219ab3532b03ef978fa38e70a0ba8c0695a2f6bc7e2af0ce42f0756b0c1a127070493055adbaf490fb68d95bebd7ccc310c6a483860
languageName: node
linkType: hard
"bufferutil@npm:^4.0.8":
version: 4.0.9
resolution: "bufferutil@npm:4.0.9"
@@ -5782,15 +5771,6 @@ __metadata:
languageName: node
linkType: hard
"chainsaw@npm:~0.1.0":
version: 0.1.0
resolution: "chainsaw@npm:0.1.0"
dependencies:
traverse: "npm:>=0.3.0 <0.4"
checksum: 10/d85627cd3440eb908b9cd72a1ddce4a36bb1ebc9d431a4a2f44b4435cbefdd83625c05114d870381ba765849c34ad05f236c3f590b1581ea03c22897fe6883d0
languageName: node
linkType: hard
"chalk@npm:^2.3.0":
version: 2.4.2
resolution: "chalk@npm:2.4.2"
@@ -6270,15 +6250,16 @@ __metadata:
languageName: node
linkType: hard
"compress-commons@npm:^4.1.2":
version: 4.1.2
resolution: "compress-commons@npm:4.1.2"
"compress-commons@npm:^6.0.2":
version: 6.0.2
resolution: "compress-commons@npm:6.0.2"
dependencies:
buffer-crc32: "npm:^0.2.13"
crc32-stream: "npm:^4.0.2"
crc-32: "npm:^1.2.0"
crc32-stream: "npm:^6.0.0"
is-stream: "npm:^2.0.1"
normalize-path: "npm:^3.0.0"
readable-stream: "npm:^3.6.0"
checksum: 10/76fa281412e4a95f89893dc1e3399e797de20253365cf53102ac4738fa004d3540abb12c26e3a54156f8fb4e4392ef9a9c5eecbe752f3a7d30e28c808b671e1b
readable-stream: "npm:^4.0.0"
checksum: 10/78e3ba10aeef919a1c5bbac21e120f3e1558a31b2defebbfa1635274fc7f7e8a3a0ee748a06249589acd0b33a0d58144b8238ff77afc3220f8d403a96fcc13aa
languageName: node
linkType: hard
@@ -6429,13 +6410,13 @@ __metadata:
languageName: node
linkType: hard
"crc32-stream@npm:^4.0.2":
version: 4.0.3
resolution: "crc32-stream@npm:4.0.3"
"crc32-stream@npm:^6.0.0":
version: 6.0.0
resolution: "crc32-stream@npm:6.0.0"
dependencies:
crc-32: "npm:^1.2.0"
readable-stream: "npm:^3.4.0"
checksum: 10/d44d0ec6f04d8a1bed899ac3e4fbb82111ed567ea6d506be39147362af45c747887fce1032f4beca1646b4824e5a9614cd3332bfa94bbc5577ca5445e7f75ddd
readable-stream: "npm:^4.0.0"
checksum: 10/e6edc2f81bc387daef6d18b2ac18c2ffcb01b554d3b5c7d8d29b177505aafffba574658fdd23922767e8dab1183d1962026c98c17e17fb272794c33293ef607c
languageName: node
linkType: hard
@@ -7734,7 +7715,18 @@ __metadata:
languageName: node
linkType: hard
"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0":
"fs-extra@npm:11.3.4":
version: 11.3.4
resolution: "fs-extra@npm:11.3.4"
dependencies:
graceful-fs: "npm:^4.2.0"
jsonfile: "npm:^6.0.1"
universalify: "npm:^2.0.0"
checksum: 10/1b8deea9c540a2efe63c750bc9e1ba6238115579d1571d67fe8fb58e3fb6df19aba29fd4ebb81217cf0bf5bce0df30ca68dbc3e06f6652b856edd385ce0ff649
languageName: node
linkType: hard
"fs-extra@npm:^10.0.0":
version: 10.1.0
resolution: "fs-extra@npm:10.1.0"
dependencies:
@@ -7807,18 +7799,6 @@ __metadata:
languageName: node
linkType: hard
"fstream@npm:^1.0.12":
version: 1.0.12
resolution: "fstream@npm:1.0.12"
dependencies:
graceful-fs: "npm:^4.1.2"
inherits: "npm:~2.0.0"
mkdirp: "npm:>=0.5 0"
rimraf: "npm:2"
checksum: 10/eadba4375e952f3f7e9d34d822cfa1592134173033bafef42aa23d5f09bf373e4eb77e097883c0a9136ad7e7d3b49bb14f0e8dfaa489abd5139b5a3c961787b6
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
@@ -7997,6 +7977,22 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.0.0":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2"
minimatch: "npm:^9.0.4"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653
languageName: node
linkType: hard
"glob@npm:^10.2.2":
version: 10.4.5
resolution: "glob@npm:10.4.5"
@@ -8013,7 +8009,7 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.2.3":
"glob@npm:^7.1.3, glob@npm:^7.1.4":
version: 7.2.3
resolution: "glob@npm:7.2.3"
dependencies:
@@ -8403,7 +8399,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521
@@ -8601,7 +8597,7 @@ __metadata:
languageName: node
linkType: hard
"is-stream@npm:^2.0.0":
"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1":
version: 2.0.1
resolution: "is-stream@npm:2.0.1"
checksum: 10/b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66
@@ -9559,13 +9555,6 @@ __metadata:
languageName: node
linkType: hard
"listenercount@npm:~1.0.1":
version: 1.0.1
resolution: "listenercount@npm:1.0.1"
checksum: 10/208c6d2b57dc16c22cc71b58a7debb6f4612a79de211b76e251efee8eb03b9f6acd4651399016ef9c15ff6a3dedfd7acc96064acddce0dbe627e2d8478034d3d
languageName: node
linkType: hard
"load-esm@npm:1.0.2":
version: 1.0.2
resolution: "load-esm@npm:1.0.2"
@@ -9621,20 +9610,6 @@ __metadata:
languageName: node
linkType: hard
"lodash.difference@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.difference@npm:4.5.0"
checksum: 10/b22adb1be9c60e5997b8b483f8bab19878cb40eda65437907958e5d27990214716e1b00ebe312a97f47e63d8b891e4ae30947d08e1f0861ccdb9462f56ab9d77
languageName: node
linkType: hard
"lodash.flatten@npm:^4.4.0":
version: 4.4.0
resolution: "lodash.flatten@npm:4.4.0"
checksum: 10/a2b192f220b0b6c78a6c0175e96bad888b9e0f2a887a8e8c1d0c29d03231fbf110bbb9be0d9de5f936537d143eeb9d5b4f44c4a44f5592c195bf2fae6a6b1e3a
languageName: node
linkType: hard
"lodash.isarguments@npm:^3.1.0":
version: 3.1.0
resolution: "lodash.isarguments@npm:3.1.0"
@@ -9642,13 +9617,6 @@ __metadata:
languageName: node
linkType: hard
"lodash.isplainobject@npm:^4.0.6":
version: 4.0.6
resolution: "lodash.isplainobject@npm:4.0.6"
checksum: 10/29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337
languageName: node
linkType: hard
"lodash.memoize@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2"
@@ -9656,13 +9624,6 @@ __metadata:
languageName: node
linkType: hard
"lodash.union@npm:^4.6.0":
version: 4.6.0
resolution: "lodash.union@npm:4.6.0"
checksum: 10/175f5786efc527238c1350ce561c28e5ba527b5957605f9e5b8a804fce78801d09ced7b72de0302325e5b14c711f94690b1a733c13ad3674cc1a76e1172db1f8
languageName: node
linkType: hard
"lodash@npm:4.17.21, lodash@npm:^4.17.11, lodash@npm:^4.17.15, lodash@npm:^4.17.21":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
@@ -10014,6 +9975,15 @@ __metadata:
languageName: node
linkType: hard
"mime@npm:3.0.0":
version: 3.0.0
resolution: "mime@npm:3.0.0"
bin:
mime: cli.js
checksum: 10/b2d31580deb58be89adaa1877cbbf152b7604b980fd7ef8f08b9e96bfedf7d605d9c23a8ba62aa12c8580b910cd7c1d27b7331d0f40f7a14e17d5a0bbec3b49f
languageName: node
linkType: hard
"mime@npm:^1.4.1":
version: 1.6.0
resolution: "mime@npm:1.6.0"
@@ -10023,15 +9993,6 @@ __metadata:
languageName: node
linkType: hard
"mime@npm:^3.0.0":
version: 3.0.0
resolution: "mime@npm:3.0.0"
bin:
mime: cli.js
checksum: 10/b2d31580deb58be89adaa1877cbbf152b7604b980fd7ef8f08b9e96bfedf7d605d9c23a8ba62aa12c8580b910cd7c1d27b7331d0f40f7a14e17d5a0bbec3b49f
languageName: node
linkType: hard
"mimic-fn@npm:^2.1.0":
version: 2.1.0
resolution: "mimic-fn@npm:2.1.0"
@@ -10253,7 +10214,7 @@ __metadata:
languageName: node
linkType: hard
"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.6":
"mkdirp@npm:^0.5.6":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
dependencies:
@@ -10545,7 +10506,7 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0":
"node-fetch@npm:2.7.0, node-fetch@npm:^2.6.7, node-fetch@npm:^2.7.0":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
@@ -10644,14 +10605,7 @@ __metadata:
languageName: node
linkType: hard
"node-webpmux@npm:3.1.7":
version: 3.1.7
resolution: "node-webpmux@npm:3.1.7"
checksum: 10/2699d6fef59b6641112373bfeabffa39085d08b0dc9da16bc44d5a4878ca2c6ef17da1286764dc40f8f9094a9e775c20b5009d7e4b72cf8910037c8fd4495495
languageName: node
linkType: hard
"node-webpmux@npm:^3.2.1":
"node-webpmux@npm:3.2.1, node-webpmux@npm:^3.2.1":
version: 3.2.1
resolution: "node-webpmux@npm:3.2.1"
checksum: 10/8797ebe4846707443629800c70d95c592ab1c18f313b360dd26832b8443efa9b89966269ad6b1e3aa59a18e790406b29a55e19c10212fc3068dc476e48cee6e9
@@ -12005,7 +11959,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.3.5, readable-stream@npm:~2.3.6":
"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.3.5":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
dependencies:
@@ -12268,17 +12222,6 @@ __metadata:
languageName: node
linkType: hard
"rimraf@npm:2":
version: 2.7.1
resolution: "rimraf@npm:2.7.1"
dependencies:
glob: "npm:^7.1.3"
bin:
rimraf: ./bin.js
checksum: 10/4586c296c736483e297da7cffd19475e4a3e41d07b1ae124aad5d687c79e4ffa716bdac8732ed1db942caf65271cee9dd39f8b639611de161a2753e2112ffe1d
languageName: node
linkType: hard
"rimraf@npm:^3.0.2":
version: 3.0.2
resolution: "rimraf@npm:3.0.2"
@@ -12491,13 +12434,6 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:~1.0.4":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10/76e3f5d7f4b581b6100ff819761f04a984fa3f3990e72a6554b57188ded53efce2d3d6c0932c10f810b7c59414f85e2ab3c11521877d1dea1ce0b56dc906f485
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
@@ -13228,7 +13164,7 @@ __metadata:
languageName: node
linkType: hard
"tar-stream@npm:^2.1.4, tar-stream@npm:^2.2.0":
"tar-stream@npm:^2.1.4":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"
dependencies:
@@ -13241,6 +13177,18 @@ __metadata:
languageName: node
linkType: hard
"tar-stream@npm:^3.0.0":
version: 3.2.0
resolution: "tar-stream@npm:3.2.0"
dependencies:
b4a: "npm:^1.6.4"
bare-fs: "npm:^4.5.5"
fast-fifo: "npm:^1.2.0"
streamx: "npm:^2.15.0"
checksum: 10/ce57a81521de73ae7a3b7d55a08da50d6771427c249bfa89a208518e48faf5254c8fa7201a8f5419ab8bde9601a74e6dd512b31a13ec89774aec96178f99a8d3
languageName: node
linkType: hard
"tar-stream@npm:^3.1.5":
version: 3.1.7
resolution: "tar-stream@npm:3.1.7"
@@ -13473,13 +13421,6 @@ __metadata:
languageName: node
linkType: hard
"traverse@npm:>=0.3.0 <0.4":
version: 0.3.9
resolution: "traverse@npm:0.3.9"
checksum: 10/ffbb8460a934f271b7b7ae654e676f740d81037d6c20ab9fd05781cfdf644929f494399b5cb3aa3db4ab69cbfef06ff8f885560d523ca49b7da33763f6c4c9f1
languageName: node
linkType: hard
"tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
@@ -13844,21 +13785,16 @@ __metadata:
languageName: node
linkType: hard
"unzipper@npm:^0.10.11":
version: 0.10.14
resolution: "unzipper@npm:0.10.14"
"unzipper@npm:0.12.3":
version: 0.12.3
resolution: "unzipper@npm:0.12.3"
dependencies:
big-integer: "npm:^1.6.17"
binary: "npm:~0.3.0"
bluebird: "npm:~3.4.1"
buffer-indexof-polyfill: "npm:~1.0.0"
bluebird: "npm:~3.7.2"
duplexer2: "npm:~0.1.4"
fstream: "npm:^1.0.12"
fs-extra: "npm:^11.2.0"
graceful-fs: "npm:^4.2.2"
listenercount: "npm:~1.0.1"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:~1.0.4"
checksum: 10/3f7b44f3c7253bc08da2988baf559f00b261c5340625e6e5206c5d73b4dea409b89caae4048346cf9f215d3cdf930e3bdee98edac5e0abc843eed765c52b398d
node-int64: "npm:^0.4.0"
checksum: 10/b210c421308e1913e01b54faad4ae79e758c674311892414a0697acacba9f82fa0051b677faa77e62fab422eef928c858f2d5cda9ddb47a2f3db95b0e9b36359
languageName: node
linkType: hard
@@ -14023,7 +13959,7 @@ __metadata:
"@types/user-agents": "npm:^1"
"@types/ws": "npm:^8.5.4"
"@wppconnect-team/wppconnect": "npm:^2.2.1"
"@wppconnect/wa-js": "npm:^4.3.0"
"@wppconnect/wa-js": "npm:^4.3.1"
adm-zip: "npm:0.5.10"
agentkeepalive: "npm:^4.5.0"
async-lock: "npm:^1.4.1"
@@ -14094,7 +14030,7 @@ __metadata:
undici: "npm:^7.16.0"
uniqid: "npm:^5.4.0"
user-agents: "npm:^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: "npm:^6.0.0"
yaml: "npm:^2.7.1"
zod: "npm:^4.3.6"
@@ -14203,18 +14139,18 @@ __metadata:
languageName: node
linkType: hard
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18":
version: 1.34.6
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=d9f107887470bd4349cd9f639fb9cb1d63027efc"
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26":
version: 1.34.7
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=c304d30169c739acedfbd770c5b206d91bb18c67"
dependencies:
archiver: "npm:^5.3.1"
archiver: "npm:7.0.1"
fluent-ffmpeg: "npm:2.1.3"
fs-extra: "npm:^10.1.0"
mime: "npm:^3.0.0"
node-fetch: "npm:^2.6.9"
node-webpmux: "npm:3.1.7"
puppeteer: "npm:^24.31.0"
unzipper: "npm:^0.10.11"
fs-extra: "npm:11.3.4"
mime: "npm:3.0.0"
node-fetch: "npm:2.7.0"
node-webpmux: "npm:3.2.1"
puppeteer: "npm:24.38.0"
unzipper: "npm:0.12.3"
dependenciesMeta:
archiver:
optional: true
@@ -14222,7 +14158,7 @@ __metadata:
optional: true
unzipper:
optional: true
checksum: 10/705f16bd7c70afae7d8a76fbfc640fbb2be7d8d99695a694f0953507716c65cbbd44f68dcf5298bc5d61fa8cc55dd5d2ec9bc3d1cfc51281a35723b936c0994c
checksum: 10/f241c03ae23cb9021b40a94f1a2a74dec35ca834c8c9496faa69b6891da3be5c8fe3256793aa2449dff64db382d6891d2162dbd020426ff588613a05c55d88fc
languageName: node
linkType: hard
@@ -14539,14 +14475,14 @@ __metadata:
languageName: node
linkType: hard
"zip-stream@npm:^4.1.0":
version: 4.1.1
resolution: "zip-stream@npm:4.1.1"
"zip-stream@npm:^6.0.1":
version: 6.0.1
resolution: "zip-stream@npm:6.0.1"
dependencies:
archiver-utils: "npm:^3.0.4"
compress-commons: "npm:^4.1.2"
readable-stream: "npm:^3.6.0"
checksum: 10/33bd5ee7017656c2ad728b5d4ba510e15bd65ce1ec180c5bbdc7a5f063256353ec482e6a2bc74de7515219d8494147924b9aae16e63fdaaf37cdf7d1ee8df125
archiver-utils: "npm:^5.0.0"
compress-commons: "npm:^6.0.2"
readable-stream: "npm:^4.0.0"
checksum: 10/aa5abd6a89590eadeba040afbc375f53337f12637e5e98330012a12d9886cde7a3ccc28bd91aafab50576035bbb1de39a9a316eecf2411c8b9009c9f94f0db27
languageName: node
linkType: hard