Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ed34a171 | ||
|
|
1db8ae3423 | ||
|
|
8a06ee3d44 | ||
|
|
9e11312b75 | ||
|
|
1c7ce47dbb | ||
|
|
1d72e2666d | ||
|
|
7ae2f7999e | ||
|
|
68dc4ad642 | ||
|
|
e8f63aeaad | ||
|
|
f4e19c7664 | ||
|
|
6d8ef2e375 | ||
|
|
024cbffb13 | ||
|
|
ad399d9b01 | ||
|
|
6053c162c0 | ||
|
|
6654a7b3d0 | ||
|
|
4ff1c01e38 | ||
|
|
dfe0a3c8c4 | ||
|
|
04121bda52 | ||
|
|
7b6f522f92 |
No files matched your search
+1
-1
@@ -247,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
|
||||
ENV WAHA_ZIPPER=ZIPUNZIP
|
||||
|
||||
# GOWS - use libc DNS resolver
|
||||
ENV GODEBUG netdns=cgo
|
||||
ENV GODEBUG=netdns=cgo
|
||||
|
||||
# Run command, etc
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
|
||||
"assets": [
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
|
||||
+6
-2
@@ -39,7 +39,9 @@
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||
"@liaoliaots/nestjs-redis": "^9",
|
||||
"@lottiefiles/dotlottie-web": "^0.72.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -58,8 +60,8 @@
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@wppconnect-team/wppconnect": "^2.0.2",
|
||||
"@wppconnect/wa-js": "^4.1.0",
|
||||
"@wppconnect-team/wppconnect": "^2.2.1",
|
||||
"@wppconnect/wa-js": "^4.3.0",
|
||||
"adm-zip": "0.5.10",
|
||||
"agentkeepalive": "^4.5.0",
|
||||
"async-lock": "^1.4.1",
|
||||
@@ -94,9 +96,11 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-webpmux": "^3.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-copy-streams": "^6.0.0",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
|
||||
@@ -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$) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3000,6 +3000,8 @@ export namespace messages {
|
||||
#one_of_decls: number[][] = [];
|
||||
constructor(data?: any[] | {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
}) {
|
||||
super();
|
||||
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
|
||||
@@ -3007,6 +3009,12 @@ export namespace messages {
|
||||
if ("duration" in data && data.duration != undefined) {
|
||||
this.duration = data.duration;
|
||||
}
|
||||
if ("gifPlayback" in data && data.gifPlayback != undefined) {
|
||||
this.gifPlayback = data.gifPlayback;
|
||||
}
|
||||
if ("externalShareFullVideoDurationInSeconds" in data && data.externalShareFullVideoDurationInSeconds != undefined) {
|
||||
this.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
get duration() {
|
||||
@@ -3015,22 +3023,50 @@ export namespace messages {
|
||||
set duration(value: number) {
|
||||
pb_1.Message.setField(this, 1, value);
|
||||
}
|
||||
get gifPlayback() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
|
||||
}
|
||||
set gifPlayback(value: boolean) {
|
||||
pb_1.Message.setField(this, 2, value);
|
||||
}
|
||||
get externalShareFullVideoDurationInSeconds() {
|
||||
return pb_1.Message.getFieldWithDefault(this, 3, 0) as number;
|
||||
}
|
||||
set externalShareFullVideoDurationInSeconds(value: number) {
|
||||
pb_1.Message.setField(this, 3, value);
|
||||
}
|
||||
static fromObject(data: {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
}): VideoInfo {
|
||||
const message = new VideoInfo({});
|
||||
if (data.duration != null) {
|
||||
message.duration = data.duration;
|
||||
}
|
||||
if (data.gifPlayback != null) {
|
||||
message.gifPlayback = data.gifPlayback;
|
||||
}
|
||||
if (data.externalShareFullVideoDurationInSeconds != null) {
|
||||
message.externalShareFullVideoDurationInSeconds = data.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
toObject() {
|
||||
const data: {
|
||||
duration?: number;
|
||||
gifPlayback?: boolean;
|
||||
externalShareFullVideoDurationInSeconds?: number;
|
||||
} = {};
|
||||
if (this.duration != null) {
|
||||
data.duration = this.duration;
|
||||
}
|
||||
if (this.gifPlayback != null) {
|
||||
data.gifPlayback = this.gifPlayback;
|
||||
}
|
||||
if (this.externalShareFullVideoDurationInSeconds != null) {
|
||||
data.externalShareFullVideoDurationInSeconds = this.externalShareFullVideoDurationInSeconds;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
serialize(): Uint8Array;
|
||||
@@ -3039,6 +3075,10 @@ export namespace messages {
|
||||
const writer = w || new pb_1.BinaryWriter();
|
||||
if (this.duration != 0)
|
||||
writer.writeFloat(1, this.duration);
|
||||
if (this.gifPlayback != false)
|
||||
writer.writeBool(2, this.gifPlayback);
|
||||
if (this.externalShareFullVideoDurationInSeconds != 0)
|
||||
writer.writeUint32(3, this.externalShareFullVideoDurationInSeconds);
|
||||
if (!w)
|
||||
return writer.getResultBuffer();
|
||||
}
|
||||
@@ -3051,6 +3091,12 @@ export namespace messages {
|
||||
case 1:
|
||||
message.duration = reader.readFloat();
|
||||
break;
|
||||
case 2:
|
||||
message.gifPlayback = reader.readBool();
|
||||
break;
|
||||
case 3:
|
||||
message.externalShareFullVideoDurationInSeconds = reader.readUint32();
|
||||
break;
|
||||
default: reader.skipField();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7749,7 +7749,9 @@ proto.messages.VideoInfo.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.messages.VideoInfo.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0)
|
||||
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0),
|
||||
gifplayback: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
externalsharefullvideodurationinseconds: jspb.Message.getFieldWithDefault(msg, 3, 0)
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -7790,6 +7792,14 @@ proto.messages.VideoInfo.deserializeBinaryFromReader = function(msg, reader) {
|
||||
var value = /** @type {number} */ (reader.readFloat());
|
||||
msg.setDuration(value);
|
||||
break;
|
||||
case 2:
|
||||
var value = /** @type {boolean} */ (reader.readBool());
|
||||
msg.setGifplayback(value);
|
||||
break;
|
||||
case 3:
|
||||
var value = /** @type {number} */ (reader.readUint32());
|
||||
msg.setExternalsharefullvideodurationinseconds(value);
|
||||
break;
|
||||
default:
|
||||
reader.skipField();
|
||||
break;
|
||||
@@ -7826,6 +7836,20 @@ proto.messages.VideoInfo.serializeBinaryToWriter = function(message, writer) {
|
||||
f
|
||||
);
|
||||
}
|
||||
f = message.getGifplayback();
|
||||
if (f) {
|
||||
writer.writeBool(
|
||||
2,
|
||||
f
|
||||
);
|
||||
}
|
||||
f = message.getExternalsharefullvideodurationinseconds();
|
||||
if (f !== 0) {
|
||||
writer.writeUint32(
|
||||
3,
|
||||
f
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7847,6 +7871,42 @@ proto.messages.VideoInfo.prototype.setDuration = function(value) {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional bool gifPlayback = 2;
|
||||
* @return {boolean}
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.getGifplayback = function() {
|
||||
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {boolean} value
|
||||
* @return {!proto.messages.VideoInfo} returns this
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.setGifplayback = function(value) {
|
||||
return jspb.Message.setProto3BooleanField(this, 2, value);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* optional uint32 externalShareFullVideoDurationInSeconds = 3;
|
||||
* @return {number}
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.getExternalsharefullvideodurationinseconds = function() {
|
||||
return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @return {!proto.messages.VideoInfo} returns this
|
||||
*/
|
||||
proto.messages.VideoInfo.prototype.setExternalsharefullvideodurationinseconds = function(value) {
|
||||
return jspb.Message.setProto3IntField(this, 3, value);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,17 +34,16 @@ 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';
|
||||
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import {
|
||||
isJidBroadcast,
|
||||
isJidGroup,
|
||||
isJidNewsletter,
|
||||
normalizeJid,
|
||||
toCusFormat,
|
||||
toJID,
|
||||
@@ -77,6 +76,7 @@ import {
|
||||
ChatRequest,
|
||||
CheckNumberStatusQuery,
|
||||
EditMessageRequest,
|
||||
MessageButtonReply,
|
||||
MessageContactVcardRequest,
|
||||
MessageFileRequest,
|
||||
MessageForwardRequest,
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -135,7 +136,10 @@ import {
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
DeleteStatusRequest,
|
||||
ImageStatus,
|
||||
TextStatus,
|
||||
VideoStatus,
|
||||
VoiceStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
@@ -191,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 {
|
||||
@@ -898,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1007,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()
|
||||
@@ -1131,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()
|
||||
@@ -1338,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({
|
||||
@@ -1611,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 {
|
||||
@@ -1675,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;
|
||||
@@ -2224,7 +2628,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message) {
|
||||
const processor = new GOWSEngineMediaProcessor(this);
|
||||
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
|
||||
this,
|
||||
);
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
const media = await this.mediaManager.processMedia(
|
||||
processor,
|
||||
message,
|
||||
@@ -2551,6 +2958,50 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* that host instead of decrypting via directPath. Real CDN links use hosts
|
||||
* such as mmg.whatsapp.net with a full path.
|
||||
*
|
||||
* GOWS also exposes stickerMessage.URL (uppercase); some paths expect url
|
||||
* (lowercase). We mirror URL -> url only for real HTTP-style URLs.
|
||||
*/
|
||||
function isPlaceholderWhatsAppMediaUrl(url: unknown): boolean {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
if (parsed.hostname !== 'a.whatsapp.net') {
|
||||
return false;
|
||||
}
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
return path === '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGowsStickerUrlForDownload(message: any): any {
|
||||
const sticker = message?.Message?.stickerMessage;
|
||||
if (!sticker) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (isPlaceholderWhatsAppMediaUrl(sticker.URL)) {
|
||||
delete sticker.URL;
|
||||
}
|
||||
if (isPlaceholderWhatsAppMediaUrl(sticker.url)) {
|
||||
delete sticker.url;
|
||||
}
|
||||
|
||||
if (sticker.URL && !sticker.url) {
|
||||
sticker.url = sticker.URL;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
constructor(public session: WhatsappSessionGoWSCore) {}
|
||||
|
||||
@@ -2574,6 +3025,8 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
async getMediaBuffer(message: any): Promise<Buffer | null> {
|
||||
const mediaDownloadTimeoutMs = 600_000; // 10 minutes
|
||||
|
||||
message = normalizeGowsStickerUrlForDownload(message);
|
||||
|
||||
const data = JSON.stringify(message.Message);
|
||||
const tmpdir = new TmpDir(
|
||||
this.session.logger,
|
||||
|
||||
@@ -3,15 +3,23 @@ import * as path from 'node:path';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { GowsAuth } from './GowsAuth';
|
||||
|
||||
export class GowsAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
|
||||
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
protected async buildPsql(store: PsqlStore, name: string): Promise<GowsAuth> {
|
||||
await store.init(name);
|
||||
const connection = store.getSessionDbURL(name);
|
||||
return new GowsAuthSimple(connection, 'postgres');
|
||||
}
|
||||
|
||||
protected async buildSqlite3(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { DataStore } from '../../abc/DataStore';
|
||||
import { LocalStore } from '../../storage/LocalStore';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { makeSureJsonFile } from '@waha/core/utils/jsonutils';
|
||||
import { join } from 'path';
|
||||
|
||||
import { NoWebMongoDbAuth } from './store/mongodb/NoWebMongoDbAuth';
|
||||
import { NoWebPsqlAuth } from './store/psql/NoWebPsqlAuth';
|
||||
import { useMultiFileAuthState } from './useMultiFileAuthState';
|
||||
|
||||
export class NowebAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string) {
|
||||
if (store instanceof MongoStore) return this.buildMongoAuth(store, name);
|
||||
if (store instanceof PsqlStore) return this.buildPsql(store, name);
|
||||
if (store instanceof LocalStore) return this.buildLocalAuth(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private async buildMongoAuth(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
const authStore = new NoWebMongoDbAuth(db);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
async buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Auth');
|
||||
const authStore = new NoWebPsqlAuth(knex);
|
||||
await authStore.init();
|
||||
return authStore.methods();
|
||||
}
|
||||
|
||||
protected async buildLocalAuth(store: LocalStore, name: string) {
|
||||
// Quick fix for
|
||||
// https://github.com/devlikeapro/waha/issues/347
|
||||
// We can remove it after some time and the fix
|
||||
// https://github.com/WhiskeySockets/Baileys/pull/824
|
||||
await this.makeCredsJsonValid(store, name);
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
|
||||
return { state, saveCreds, close };
|
||||
}
|
||||
|
||||
private async makeCredsJsonValid(store: LocalStore, name: string) {
|
||||
try {
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const credsPath = join(authFolder, 'creds.json');
|
||||
await makeSureJsonFile(credsPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to fix "creds.json" file, continue...', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type makeWASocket from '@adiwajshing/baileys';
|
||||
import type { QueryIds } from '@adiwajshing/baileys';
|
||||
import type { NewsletterMetadata } from '@adiwajshing/baileys/lib/Types';
|
||||
import { toNewsletterMetadata } from '@waha/core/engines/noweb/noweb.newsletter';
|
||||
import {
|
||||
ChannelPagination,
|
||||
ChannelSearchByText,
|
||||
ChannelSearchByView,
|
||||
} from '@waha/structures/channels.dto';
|
||||
|
||||
enum NewsletterMexQueryIds {
|
||||
NEWSLETTERS_DIRECTORY_LIST = '6190824427689257',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = '6802402206520139',
|
||||
}
|
||||
|
||||
enum NewsletterXWAPaths {
|
||||
NEWSLETTERS_DIRECTORY_LIST = 'xwa2_newsletters_directory_list',
|
||||
NEWSLETTERS_DIRECTORY_SEARCH = 'xwa2_newsletters_directory_search',
|
||||
}
|
||||
|
||||
/**
|
||||
* NowebClient - wrapper around baileys to have more methods
|
||||
*/
|
||||
|
||||
export interface NewsletterSearchResponse {
|
||||
page: ChannelPagination;
|
||||
newsletters: NewsletterMetadata[];
|
||||
}
|
||||
|
||||
export class NowebClient {
|
||||
constructor(private sock: ReturnType<typeof makeWASocket>) {}
|
||||
|
||||
async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
view: query.view,
|
||||
filters: {
|
||||
country_codes: query.countries,
|
||||
categories: query.categories,
|
||||
},
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_LIST as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_LIST;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
|
||||
async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<NewsletterSearchResponse> {
|
||||
const variables = {
|
||||
input: {
|
||||
search_text: query.text,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
start_cursor: query.startCursor,
|
||||
},
|
||||
};
|
||||
const queryId =
|
||||
NewsletterMexQueryIds.NEWSLETTERS_DIRECTORY_SEARCH as unknown as QueryIds;
|
||||
const path = NewsletterXWAPaths.NEWSLETTERS_DIRECTORY_SEARCH;
|
||||
const response = await this.sock.executeWMexQuery(variables, queryId, path);
|
||||
return parseNewsletterSearchNode(response);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewsletterSearchNode(response: any): NewsletterSearchResponse {
|
||||
const pageInfo = response.page_info;
|
||||
const page: ChannelPagination = {
|
||||
startCursor: pageInfo.startCursor,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
hasPreviousPage: pageInfo.hasPreviousPage,
|
||||
};
|
||||
const newsletterResult: any[] = response.result;
|
||||
const newsletters = newsletterResult.map(toNewsletterMetadata);
|
||||
|
||||
return {
|
||||
page: page,
|
||||
newsletters: newsletters as any,
|
||||
};
|
||||
}
|
||||
@@ -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,14 +62,12 @@ 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';
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
|
||||
import { QR } from '@waha/core/QR';
|
||||
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
|
||||
import { pairs } from '@waha/utils/pairs';
|
||||
@@ -73,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 {
|
||||
@@ -117,6 +124,7 @@ import {
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
@@ -136,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,
|
||||
@@ -162,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,
|
||||
@@ -196,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';
|
||||
@@ -204,17 +216,30 @@ import { Agents } from '@waha/core/engines/noweb/types';
|
||||
import {
|
||||
IsEditedMessage,
|
||||
IsHistorySyncNotification,
|
||||
IsSecretEncryptedMessageEdit,
|
||||
} from '@waha/core/utils/pwa';
|
||||
import {
|
||||
decryptSecretEncryptedMessageEditProto,
|
||||
getOrigSenderJidForMsgSecret,
|
||||
jidToNonAD,
|
||||
} from '@waha/core/utils/secretEncryptedMessageEdit';
|
||||
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',
|
||||
@@ -898,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1054,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()
|
||||
@@ -1097,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()
|
||||
@@ -1618,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);
|
||||
@@ -1889,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);
|
||||
@@ -1948,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 {
|
||||
@@ -2010,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) {
|
||||
@@ -2116,14 +2503,25 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
|
||||
// Handle edited messages
|
||||
const messagesEdited$ = messagesUpsert$.pipe(
|
||||
filter((message) => IsEditedMessage(message.message)),
|
||||
filter(
|
||||
(message) =>
|
||||
IsEditedMessage(message.message) ||
|
||||
IsSecretEncryptedMessageEdit(message.message),
|
||||
),
|
||||
mergeMap(async (message): Promise<WAMessageEditedBody> => {
|
||||
const waMessage = this.toWAMessage(message);
|
||||
// Extract the body from editedMessage using extractBody function
|
||||
const content = normalizeMessageContent(message.message);
|
||||
const body = extractBody(content.protocolMessage.editedMessage) || '';
|
||||
// Extract the original message ID from protocolMessage.key
|
||||
const editedMessageId = content.protocolMessage.key?.id;
|
||||
let body = '';
|
||||
let editedMessageId: string | undefined;
|
||||
if (IsEditedMessage(message.message)) {
|
||||
const content = normalizeMessageContent(message.message);
|
||||
body = extractBody(content.protocolMessage.editedMessage) || '';
|
||||
editedMessageId = content.protocolMessage.key?.id;
|
||||
} else if (IsSecretEncryptedMessageEdit(message.message)) {
|
||||
const sem = message.message.secretEncryptedMessage;
|
||||
editedMessageId = sem.targetMessageKey?.id;
|
||||
body =
|
||||
(await this.tryDecryptNOWEBSecretMessageEdit(message, sem)) || '';
|
||||
}
|
||||
return {
|
||||
...waMessage,
|
||||
body: body,
|
||||
@@ -2443,6 +2841,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return;
|
||||
// Ignore edit, we have a dedicated event for that
|
||||
if (IsEditedMessage(message.message)) return;
|
||||
// Ignore secret-encrypted message edits (mobile app format), dedicated handler routes them
|
||||
if (IsSecretEncryptedMessageEdit(message.message)) return;
|
||||
|
||||
// Ignore history sync notifications
|
||||
if (IsHistorySyncNotification(message.message)) return;
|
||||
@@ -2473,6 +2873,99 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async tryDecryptNOWEBSecretMessageEdit(
|
||||
editMessage: proto.IWebMessageInfo,
|
||||
sem: proto.Message.ISecretEncryptedMessage,
|
||||
): Promise<string> {
|
||||
const targetKey = sem.targetMessageKey;
|
||||
const origMsgId = targetKey?.id;
|
||||
if (!origMsgId) {
|
||||
return '';
|
||||
}
|
||||
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
|
||||
Boolean,
|
||||
);
|
||||
let stored: proto.IWebMessageInfo | undefined;
|
||||
for (const jid of jidsToTry) {
|
||||
stored = await this.store?.loadMessage(jid, origMsgId);
|
||||
if (stored) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stored) {
|
||||
this.logger.debug(
|
||||
{ origMsgId: origMsgId },
|
||||
'NOWEB message edit decrypt: original message not found in store',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
const secretBytes =
|
||||
normalizeMessageContent(stored.message)?.messageContextInfo
|
||||
?.messageSecret ?? stored.message?.messageContextInfo?.messageSecret;
|
||||
if (!secretBytes || secretBytes.length !== 32) {
|
||||
this.logger.debug(
|
||||
{ origMsgId: origMsgId },
|
||||
'NOWEB message edit decrypt: missing messageSecret on original',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
const origSecret = Buffer.from(secretBytes);
|
||||
const encPayload = sem.encPayload ? Buffer.from(sem.encPayload) : null;
|
||||
const encIv = sem.encIv ? Buffer.from(sem.encIv) : null;
|
||||
if (!encPayload || !encIv) {
|
||||
return '';
|
||||
}
|
||||
const editInfo = {
|
||||
Chat: editMessage.key?.remoteJid,
|
||||
Sender:
|
||||
editMessage.key?.participant ||
|
||||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
|
||||
};
|
||||
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
|
||||
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
|
||||
fromMe: targetKey.fromMe,
|
||||
remoteJID: targetKey.remoteJid,
|
||||
participant: targetKey.participant,
|
||||
});
|
||||
const candidates: string[] = [primaryOrig];
|
||||
const remoteNonAD = targetKey.remoteJid
|
||||
? jidToNonAD(targetKey.remoteJid)
|
||||
: '';
|
||||
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
|
||||
candidates.push(remoteNonAD);
|
||||
}
|
||||
const participantNonAD = targetKey.participant
|
||||
? jidToNonAD(targetKey.participant)
|
||||
: '';
|
||||
if (participantNonAD && !candidates.includes(participantNonAD)) {
|
||||
candidates.push(participantNonAD);
|
||||
}
|
||||
let lastErr: unknown;
|
||||
for (const origSenderJid of candidates) {
|
||||
try {
|
||||
const decoded = decryptSecretEncryptedMessageEditProto({
|
||||
encPayload: encPayload,
|
||||
encIv: encIv,
|
||||
origMsgId: origMsgId,
|
||||
origSenderJid: origSenderJid,
|
||||
modificationSenderJid: modificationSenderJid,
|
||||
origMsgSecret: origSecret,
|
||||
});
|
||||
const text = extractBody(decoded) || '';
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
this.logger.debug(
|
||||
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
|
||||
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
|
||||
);
|
||||
return '';
|
||||
}
|
||||
|
||||
protected async processIncomingMessage(
|
||||
message,
|
||||
downloadMedia: boolean,
|
||||
@@ -2770,7 +3263,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message): Promise<WAMedia | null> {
|
||||
const processor = new NOWEBEngineMediaProcessor(this, this.loggerBuilder);
|
||||
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
|
||||
this,
|
||||
this.loggerBuilder,
|
||||
);
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
return this.mediaManager.processMedia(processor, message, this.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
|
||||
import { DataStore } from '../../../abc/DataStore';
|
||||
import { MongoStorage } from './mongodb/MongoStorage';
|
||||
import { INowebStorage } from './INowebStorage';
|
||||
import { PsqlStorage } from './psql/PsqlStorage';
|
||||
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
|
||||
|
||||
export class NowebStorageFactoryCore {
|
||||
createStorage(store: DataStore, name: string): INowebStorage {
|
||||
if (store instanceof MongoStore) {
|
||||
return this.buildStorageMongo(store, name);
|
||||
}
|
||||
if (store instanceof PsqlStore) {
|
||||
return this.buildPsql(store, name);
|
||||
}
|
||||
if (store instanceof LocalStore) {
|
||||
return this.buildStorageSqlite3(store, name);
|
||||
}
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private buildStorageMongo(store: MongoStore, name: string) {
|
||||
const db = store.getSessionDb(name);
|
||||
return new MongoStorage(db);
|
||||
}
|
||||
|
||||
private buildPsql(store: PsqlStore, name: string) {
|
||||
const knex = store.buildSessionKnex(name, 'Session/Storage');
|
||||
return new PsqlStorage(knex);
|
||||
}
|
||||
|
||||
private buildStorageSqlite3(store: LocalStore, name: string) {
|
||||
const filePath = store.getFilePath(name, 'store.sqlite3');
|
||||
return new Sqlite3Storage(filePath);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Chat } from '@adiwajshing/baileys';
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { OverviewFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoChatRepository
|
||||
extends MongoRepository<Chat>
|
||||
implements IChatRepository
|
||||
{
|
||||
async getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
filter?: OverviewFilter,
|
||||
merge?: boolean,
|
||||
): Promise<Chat[]> {
|
||||
// Get chats with conversationTimestamp is not Null
|
||||
const mongoFilter: any = { conversationTimestamp: { $ne: NaN } };
|
||||
|
||||
// Build the ID filter conditions
|
||||
const conditions = [];
|
||||
if (!broadcast) {
|
||||
// Filter out chat by id if it ends at @newsletter or @broadcast
|
||||
conditions.push({ id: { $not: { $regex: /@broadcast|@newsletter/ } } });
|
||||
}
|
||||
if (filter?.ids && filter.ids.length > 0) {
|
||||
// Filter by IDs if provided
|
||||
conditions.push({ id: { $in: filter.ids } });
|
||||
}
|
||||
|
||||
if (conditions.length != 0) {
|
||||
// Combine ID conditions with $and if there are multiple conditions
|
||||
mongoFilter.$and = conditions;
|
||||
}
|
||||
|
||||
let query = this.collection.find(mongoFilter);
|
||||
// Sort by conversationTimestamp in descending order
|
||||
query = this.pagination(query, pagination);
|
||||
const rows = await query.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Contact } from '@adiwajshing/baileys';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoContactRepository
|
||||
extends MongoRepository<Contact>
|
||||
implements IContactRepository {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoGroupRepository
|
||||
extends MongoRepository<GroupMetadata>
|
||||
implements IGroupRepository {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { MongoRepository } from '@waha/core/engines/noweb/store/mongodb/MongoRepository';
|
||||
import { LabelAssociationType } from '@waha/core/engines/noweb/labels/LabelAssociationType';
|
||||
|
||||
export class MongoLabelAssociationsRepository
|
||||
extends MongoRepository<LabelAssociation>
|
||||
implements ILabelAssociationRepository
|
||||
{
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
await this.deleteBy({
|
||||
type: association.type,
|
||||
chatId: association.chatId,
|
||||
labelId: association.labelId,
|
||||
// @ts-ignore: messageId doesn't existing in ChatLabelAssociation
|
||||
messageId: association.messageId || null,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
await this.deleteBy({ labelId: labelId });
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({
|
||||
type: type,
|
||||
labelId: labelId,
|
||||
});
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({ chatId: chatId, type: LabelAssociationType.Chat });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoLabelsRepository
|
||||
extends MongoRepository<Label>
|
||||
implements ILabelsRepository {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
INowebLidPNRepository,
|
||||
LidToPN,
|
||||
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoLidPNRepository
|
||||
extends MongoRepository<LidToPN>
|
||||
implements INowebLidPNRepository
|
||||
{
|
||||
saveLids(lids: LidToPN[]): Promise<void> {
|
||||
return this.upsertMany(lids);
|
||||
}
|
||||
|
||||
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
|
||||
return this.getAll(pagination);
|
||||
}
|
||||
|
||||
getLidsCount(): Promise<number> {
|
||||
return this.getCount();
|
||||
}
|
||||
|
||||
async findLidByPN(pn: string): Promise<string | null> {
|
||||
const value = await this.getBy({ pn: pn });
|
||||
return value?.id || null;
|
||||
}
|
||||
|
||||
async findPNByLid(lid: string): Promise<string | null> {
|
||||
const value = await this.getBy({ id: lid });
|
||||
return value?.pn || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { AckToStatus } from '@waha/core/utils/acks';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
import { FindCursor } from 'mongodb';
|
||||
|
||||
import { IMessagesRepository } from '../../../../../core/engines/noweb/store/IMessagesRepository';
|
||||
import { MongoRepository } from './MongoRepository';
|
||||
|
||||
export class MongoMessagesRepository
|
||||
extends MongoRepository<any>
|
||||
implements IMessagesRepository
|
||||
{
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.upsertMany(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
jid: string,
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
merge?: boolean,
|
||||
): Promise<any[]> {
|
||||
const query: any = {};
|
||||
if (jid !== ALL_JID) {
|
||||
query['jid'] = jid;
|
||||
}
|
||||
if (
|
||||
filter['filter.timestamp.lte'] != null ||
|
||||
filter['filter.timestamp.gte'] != null
|
||||
) {
|
||||
const filters: any = {};
|
||||
if (filter['filter.timestamp.lte'] != null) {
|
||||
filters['$lte'] = filter['filter.timestamp.lte'];
|
||||
}
|
||||
if (filter['filter.timestamp.gte'] != null) {
|
||||
filters['$gte'] = filter['filter.timestamp.gte'];
|
||||
}
|
||||
query['messageTimestamp'] = filters;
|
||||
}
|
||||
if (filter['filter.fromMe'] != null) {
|
||||
query['data.key.fromMe'] = filter['filter.fromMe'];
|
||||
}
|
||||
if (filter['filter.ack'] != null) {
|
||||
const status = AckToStatus(filter['filter.ack']);
|
||||
query['data.status'] = status;
|
||||
}
|
||||
|
||||
let cursor = this.collection.find(query);
|
||||
cursor = this.pagination(cursor, pagination);
|
||||
const rows = await cursor.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
|
||||
if (jid === ALL_JID) {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
return this.getBy({ jid: jid, id: id });
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
jid: string,
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
const entity = await this.getByJidById(jid, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(entity, update);
|
||||
await this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
await this.collection.deleteMany({ jid: jid, id: { $in: ids } });
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.deleteBy({ jid: jid });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
convertProtobufToPlainObject,
|
||||
replaceLongsWithNumber,
|
||||
} from '@waha/core/engines/noweb/utils';
|
||||
import { Field, Schema } from '@waha/core/storage/Schema';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { MongoPaginator } from '@waha/utils/Paginator';
|
||||
import { Collection, Db } from 'mongodb';
|
||||
import esm from '@waha/vendor/esm';
|
||||
|
||||
/**
|
||||
* Key value repository with extra metadata
|
||||
*/
|
||||
export class MongoRepository<Entity> {
|
||||
private UPSERT_BATCH_SIZE = 1000;
|
||||
protected collection: Collection<any>;
|
||||
|
||||
private readonly metadata: Map<string, (entity: Entity) => any>;
|
||||
private readonly columns: Field[];
|
||||
|
||||
static replace(data: any): any {
|
||||
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
|
||||
}
|
||||
|
||||
static revive(row: any): any {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(row.data), esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
constructor(
|
||||
db: Db,
|
||||
schema: Schema,
|
||||
metadata: Map<string, (entity: Entity) => any> | null = null,
|
||||
) {
|
||||
this.collection = db.collection(schema.name);
|
||||
this.columns = schema.columns;
|
||||
this.metadata = metadata || new Map();
|
||||
}
|
||||
|
||||
async getAll(pagination?: PaginationParams) {
|
||||
let query = this.collection.find();
|
||||
query = this.pagination(query, pagination);
|
||||
const rows = await query.toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getCount() {
|
||||
return await this.collection.countDocuments();
|
||||
}
|
||||
|
||||
async getAllBy(filters: any) {
|
||||
const rows = await this.collection.find(filters).toArray();
|
||||
return rows.map(MongoRepository.revive);
|
||||
}
|
||||
|
||||
async getAllByIds(ids: string[]) {
|
||||
const entitiesMap = await this.getEntitiesByIds(ids);
|
||||
return Array.from(entitiesMap.values()).filter(
|
||||
(entity) => entity !== null,
|
||||
) as Entity[];
|
||||
}
|
||||
|
||||
async getEntitiesByIds(ids: string[]): Promise<Map<string, Entity | null>> {
|
||||
if (ids.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await this.collection.find({ id: { $in: ids } }).toArray();
|
||||
const entitiesMap = new Map<string, Entity | null>();
|
||||
|
||||
// Initialize a map with null values for all requested IDs
|
||||
for (const id of ids) {
|
||||
entitiesMap.set(id, null);
|
||||
}
|
||||
|
||||
// Fill in the map with found entities
|
||||
for (const row of rows) {
|
||||
const entity = MongoRepository.revive(row);
|
||||
if (entity && row.id) {
|
||||
entitiesMap.set(row.id, entity);
|
||||
}
|
||||
}
|
||||
|
||||
return entitiesMap;
|
||||
}
|
||||
|
||||
protected async getBy(filters: any) {
|
||||
return await this.collection.findOne(filters).then(MongoRepository.revive);
|
||||
}
|
||||
|
||||
private dump(entity: Entity): any {
|
||||
const raw = convertProtobufToPlainObject(entity);
|
||||
replaceLongsWithNumber(raw);
|
||||
const data = {};
|
||||
for (const field of this.columns) {
|
||||
const fn = this.metadata.get(field.fieldName);
|
||||
if (fn) {
|
||||
data[field.fieldName] = fn(raw);
|
||||
} else if (field.fieldName == 'data') {
|
||||
data['data'] = MongoRepository.replace(raw);
|
||||
} else {
|
||||
data[field.fieldName] = raw[field.fieldName];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
save(entity: Entity) {
|
||||
return this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Entity | null> {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
|
||||
async upsertOne(entity: Entity): Promise<void> {
|
||||
const row = this.dump(entity);
|
||||
await this.collection.replaceOne({ id: row.id }, row, { upsert: true });
|
||||
}
|
||||
|
||||
async upsertMany(entities: Entity[]): Promise<void> {
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batchSize = this.UPSERT_BATCH_SIZE;
|
||||
for (let i = 0; i < entities.length; i += batchSize) {
|
||||
const batch = entities.slice(i, i + batchSize);
|
||||
await this.upsertBatch(batch);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBatch(entities: Entity[]): Promise<void> {
|
||||
// Update using bulk write
|
||||
const bulk = this.collection.initializeUnorderedBulkOp();
|
||||
for (const entity of entities) {
|
||||
const row = this.dump(entity);
|
||||
bulk.find({ id: row.id }).upsert().replaceOne(row);
|
||||
}
|
||||
await bulk.execute();
|
||||
}
|
||||
|
||||
protected async deleteBy(filters: any) {
|
||||
await this.collection.deleteMany(filters);
|
||||
}
|
||||
|
||||
async deleteAll() {
|
||||
await this.deleteBy({});
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
await this.deleteBy({ id: id });
|
||||
}
|
||||
|
||||
protected pagination(query: any, pagination?: PaginationParams) {
|
||||
if (pagination?.sortBy) {
|
||||
// check if it's in 'columns'
|
||||
const column = this.columns.find(
|
||||
(column) => column.fieldName === pagination.sortBy,
|
||||
);
|
||||
if (!column) {
|
||||
// add "data"
|
||||
pagination.sortBy = `data.${pagination.sortBy}`;
|
||||
}
|
||||
}
|
||||
const paginator = new MongoPaginator(pagination);
|
||||
return paginator.apply(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
|
||||
import {
|
||||
NowebLabelAssociationsMetadata,
|
||||
NowebMessagesMetadata,
|
||||
} from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NOWEB_STORE_SCHEMA } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { Schema } from '@waha/core/storage/Schema';
|
||||
import { MongoGroupRepository } from '@waha/core/engines/noweb/store/mongodb/MongoGroupRepository';
|
||||
import { MongoLabelAssociationsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelAssociationsRepository';
|
||||
import { MongoLabelsRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLabelsRepository';
|
||||
import { MongoLidPNRepository } from '@waha/core/engines/noweb/store/mongodb/MongoLidPNRepository';
|
||||
import { Db } from 'mongodb';
|
||||
|
||||
import { MongoChatRepository } from './MongoChatRepository';
|
||||
import { MongoContactRepository } from './MongoContactRepository';
|
||||
import { MongoMessagesRepository } from './MongoMessagesRepository';
|
||||
|
||||
enum Order {
|
||||
ASC = 1,
|
||||
DESC = -1,
|
||||
}
|
||||
|
||||
export class MongoStorage extends INowebStorage {
|
||||
private readonly tables: Schema[];
|
||||
|
||||
constructor(private db: Db) {
|
||||
super();
|
||||
this.tables = NOWEB_STORE_SCHEMA;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.upsertIndexes();
|
||||
return;
|
||||
}
|
||||
|
||||
async upsertIndexes() {
|
||||
// Contacts
|
||||
await this.db
|
||||
.collection('contacts')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
// Chats
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ conversationTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('chats')
|
||||
.createIndex({ conversationTimestamp: Order.DESC });
|
||||
// Groups
|
||||
await this.db
|
||||
.collection('groups')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
// Messages
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, id: Order.ASC }, { unique: true });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, messageTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ jid: Order.ASC, messageTimestamp: Order.DESC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ messageTimestamp: Order.ASC });
|
||||
await this.db
|
||||
.collection('messages')
|
||||
.createIndex({ messageTimestamp: Order.DESC });
|
||||
|
||||
//
|
||||
// Labels
|
||||
//
|
||||
await this.db.collection('labels').createIndex({ id: 1 }, { unique: true });
|
||||
// Label associations
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ id: 1 }, { unique: true });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, labelId: 1 });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, chatId: 1 });
|
||||
await this.db
|
||||
.collection('labelAssociations')
|
||||
.createIndex({ type: 1, messageId: 1 });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async close() {
|
||||
return;
|
||||
}
|
||||
|
||||
getContactsRepository() {
|
||||
return new MongoContactRepository(this.db, this.getSchema('contacts'));
|
||||
}
|
||||
|
||||
getChatRepository() {
|
||||
return new MongoChatRepository(this.db, this.getSchema('chats'));
|
||||
}
|
||||
|
||||
getGroupRepository(): IGroupRepository {
|
||||
return new MongoGroupRepository(this.db, this.getSchema('groups'));
|
||||
}
|
||||
|
||||
getMessagesRepository() {
|
||||
return new MongoMessagesRepository(
|
||||
this.db,
|
||||
this.getSchema('messages'),
|
||||
NowebMessagesMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
getLabelsRepository(): ILabelsRepository {
|
||||
return new MongoLabelsRepository(this.db, this.getSchema('labels'));
|
||||
}
|
||||
|
||||
getLabelAssociationRepository(): ILabelAssociationRepository {
|
||||
return new MongoLabelAssociationsRepository(
|
||||
this.db,
|
||||
this.getSchema('labelAssociations'),
|
||||
NowebLabelAssociationsMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
getSchema(name: string) {
|
||||
const schema = this.tables.find((table) => table.name === name);
|
||||
if (!schema) {
|
||||
throw new Error(`Schema not found: ${name}`);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
getLidPNRepository(): INowebLidPNRepository {
|
||||
return new MongoLidPNRepository(this.db, this.getSchema('lid_map'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import esm from '@waha/vendor/esm';
|
||||
import { Collection, Db, Document } from 'mongodb';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
function mongoKey(id: string) {
|
||||
// Replace . to -
|
||||
return id.replace(/\./g, '-');
|
||||
}
|
||||
|
||||
type AuthDocument = Document & {
|
||||
key?: string;
|
||||
data?: any;
|
||||
type?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export class NoWebMongoDbAuth {
|
||||
private collection: Collection<AuthDocument>;
|
||||
// Keep legacy aggregated document reference for gradual migration.
|
||||
private document: AuthDocument | null;
|
||||
private creds: any;
|
||||
|
||||
private db: Db;
|
||||
private lock: any;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
this.collection = this.db.collection('auth');
|
||||
this.lock = new AsyncLock({
|
||||
timeout: 5_000,
|
||||
maxPending: Infinity,
|
||||
maxExecutionTime: 60_000,
|
||||
});
|
||||
this.document = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.ensureIndexes();
|
||||
this.document = await this.loadLegacyDocument();
|
||||
const creds = await this.readData('creds');
|
||||
// @ts-ignore:next-line
|
||||
this.creds = creds || (0, esm.b.initAuthCreds)();
|
||||
}
|
||||
|
||||
private async ensureIndexes() {
|
||||
// Separate docs are keyed by "key", keep collection indexable for new format.
|
||||
await this.collection.createIndex({ key: 1 }, { unique: true });
|
||||
}
|
||||
|
||||
private serialize(data: any) {
|
||||
return JSON.parse(JSON.stringify(data, esm.b.BufferJSON.replacer));
|
||||
}
|
||||
|
||||
private deserialize(data: any) {
|
||||
if (data === null || data === undefined) {
|
||||
return null;
|
||||
}
|
||||
const json = JSON.stringify(data);
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(json, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
private async loadLegacyDocument(): Promise<AuthDocument | null> {
|
||||
// The old format stored everything in one doc; keep it for reads and migrate lazily.
|
||||
return this.collection.findOne({ data: { $exists: false } });
|
||||
}
|
||||
|
||||
private async readFromLegacy(field: string, key: string) {
|
||||
if (!this.document) {
|
||||
return null;
|
||||
}
|
||||
if (!(key in this.document)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = this.deserialize(this.document[key]);
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
await this.writeData(value, field);
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeData(data: any, field: string) {
|
||||
const key = mongoKey(field);
|
||||
await this.lock.acquire(key, async () => {
|
||||
await this.collection.updateOne(
|
||||
{ key: key },
|
||||
{
|
||||
$set: {
|
||||
key: key,
|
||||
data: this.serialize(data),
|
||||
type: 'kv',
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async readData(field: string) {
|
||||
const key = mongoKey(field);
|
||||
try {
|
||||
const document = await this.collection.findOne({ key: key });
|
||||
if (document && 'data' in document) {
|
||||
return this.deserialize(document.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return this.readFromLegacy(field, key);
|
||||
}
|
||||
|
||||
async removeData(field: string) {
|
||||
const key = mongoKey(field);
|
||||
try {
|
||||
await this.collection.deleteOne({ key: key });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
isMyMainSession(id: string) {
|
||||
// Decode the jid
|
||||
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
|
||||
return id == `${meId}.0`;
|
||||
}
|
||||
|
||||
methods() {
|
||||
const creds = this.creds;
|
||||
return {
|
||||
state: {
|
||||
creds: creds,
|
||||
keys: {
|
||||
get: async (type, ids) => {
|
||||
const data = {};
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
// Always reset my session and setup new session
|
||||
if (type === 'session' && this.isMyMainSession(id)) {
|
||||
return;
|
||||
}
|
||||
let value = await this.readData(`${type}-${id}`);
|
||||
if (type === 'app-state-sync-key' && value) {
|
||||
value =
|
||||
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
|
||||
}
|
||||
data[id] = value;
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
set: async (data) => {
|
||||
const tasks = [];
|
||||
for (const category of Object.keys(data)) {
|
||||
for (const id of Object.keys(data[category])) {
|
||||
const value = data[category][id];
|
||||
const key = `${category}-${id}`;
|
||||
const hasValue = !!value || value?.length === 0;
|
||||
if (hasValue) {
|
||||
tasks.push(this.writeData(value, key));
|
||||
} else {
|
||||
// Do not remove any keys for now
|
||||
// Avoid removing pre-keys
|
||||
// tasks.push(this.removeData(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
},
|
||||
},
|
||||
},
|
||||
saveCreds: () => {
|
||||
return this.writeData(this.creds, 'creds');
|
||||
},
|
||||
close: async () => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
convertProtobufToPlainObject,
|
||||
replaceLongsWithNumber,
|
||||
} from '@waha/core/engines/noweb/utils';
|
||||
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
|
||||
import esm from '@waha/vendor/esm';
|
||||
|
||||
// PostgreSQL TEXT or JSONB columns do not allow null bytes (c-style strings)
|
||||
// oxlint-disable-next-line no-control-regex
|
||||
const invalidCharsRegex = /\u0000/g;
|
||||
|
||||
export function sanitizeJsonUnicode(str: string): string {
|
||||
return str.replace(invalidCharsRegex, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Key value repository with extra metadata
|
||||
* Add support for converting protobuf to plain object
|
||||
*/
|
||||
export class NOWEBPsqlKVRepository<Entity> extends PsqlKVRepository<Entity> {
|
||||
protected stringify(data: any): string {
|
||||
let value = JSON.stringify(data, esm.b.BufferJSON.replacer);
|
||||
value = sanitizeJsonUnicode(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public parse(row: any): any {
|
||||
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
|
||||
protected dump(entity: Entity) {
|
||||
const raw = convertProtobufToPlainObject(entity);
|
||||
replaceLongsWithNumber(raw);
|
||||
return super.dump(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import esm from '@waha/vendor/esm';
|
||||
import { PsqlNowebAuthRepository } from '@waha/core/engines/noweb/store/psql/PsqlAuthRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
export class NoWebPsqlAuth {
|
||||
private creds: any;
|
||||
private repository: PsqlNowebAuthRepository;
|
||||
private lock: any;
|
||||
|
||||
constructor(private knex: Knex.Knex) {
|
||||
this.repository = new PsqlNowebAuthRepository(knex);
|
||||
this.lock = new AsyncLock({
|
||||
timeout: 5_000,
|
||||
maxPending: Infinity,
|
||||
maxExecutionTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.repository.init();
|
||||
this.creds = await this.repository.getCreds();
|
||||
if (!this.creds) {
|
||||
this.creds = esm.b.initAuthCreds();
|
||||
}
|
||||
}
|
||||
|
||||
methods() {
|
||||
return {
|
||||
state: {
|
||||
creds: this.creds,
|
||||
keys: {
|
||||
get: async (type, ids) => {
|
||||
const data = {};
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
// Always reset my session and setup new session
|
||||
if (type === 'session' && this.isMyMainSession(id)) {
|
||||
return;
|
||||
}
|
||||
let value = await this.repository.getCategory(type, id);
|
||||
if (type === 'app-state-sync-key' && value) {
|
||||
value =
|
||||
esm.b.WAProto.Message.AppStateSyncKeyData.create(value);
|
||||
}
|
||||
data[id] = value;
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
set: async (data) => {
|
||||
const tasks = [];
|
||||
for (const category of Object.keys(data)) {
|
||||
for (const id of Object.keys(data[category])) {
|
||||
const value = data[category][id];
|
||||
const hasValue = !!value || value?.length === 0;
|
||||
if (hasValue) {
|
||||
tasks.push(this.repository.saveCategory(category, id, value));
|
||||
} else {
|
||||
// Do not remove any keys for now
|
||||
// Avoid removing pre-keys
|
||||
// tasks.push(this.removeData(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return;
|
||||
},
|
||||
},
|
||||
},
|
||||
saveCreds: () => {
|
||||
return this.lock.acquire('creds', async () => {
|
||||
await this.repository.saveCreds(this.creds);
|
||||
});
|
||||
},
|
||||
close: async () => {
|
||||
return await this.knex.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
isMyMainSession(id: string) {
|
||||
// Decode the jid
|
||||
const { user: meId } = esm.b.jidDecode(this.creds?.me?.id);
|
||||
return id == `${meId}.0`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
INowebLidPNRepository,
|
||||
LidToPN,
|
||||
} from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { NowebLidMapSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { LidPaginator } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LidPNRepository';
|
||||
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLidPNRepository
|
||||
extends NOWEBPsqlKVRepository<LidToPN>
|
||||
implements INowebLidPNRepository
|
||||
{
|
||||
protected Paginator = LidPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebLidMapSchema;
|
||||
}
|
||||
|
||||
saveLids(lids: LidToPN[]): Promise<void> {
|
||||
return this.upsertMany(lids);
|
||||
}
|
||||
|
||||
getAllLids(pagination?: LimitOffsetParams): Promise<LidToPN[]> {
|
||||
return this.getAll(pagination);
|
||||
}
|
||||
|
||||
getLidsCount(): Promise<number> {
|
||||
return this.getCount();
|
||||
}
|
||||
|
||||
async findLidByPN(pn: string): Promise<string | null> {
|
||||
const value = await this.getBy({ pn: pn });
|
||||
return value?.id || null;
|
||||
}
|
||||
|
||||
async findPNByLid(lid: string): Promise<string | null> {
|
||||
const value = await this.getBy({ id: lid });
|
||||
return value?.pn || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
SqlAuthMigrations,
|
||||
SqlAuthSchema,
|
||||
} from '@waha/core/engines/noweb/store/psql/schemas';
|
||||
import { PsqlKVRepository } from '@waha/core/storage/psql/PsqlKVRepository';
|
||||
import esm from '@waha/vendor/esm';
|
||||
import Knex from 'knex';
|
||||
|
||||
class AuthData {
|
||||
id: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class PsqlNowebAuthRepository extends PsqlKVRepository<AuthData> {
|
||||
constructor(knex: Knex.Knex) {
|
||||
super(knex);
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return SqlAuthSchema;
|
||||
}
|
||||
|
||||
get migrations() {
|
||||
return SqlAuthMigrations;
|
||||
}
|
||||
|
||||
saveCreds(data) {
|
||||
return this.saveCategory('creds', '', data);
|
||||
}
|
||||
|
||||
getCreds() {
|
||||
return this.getCategory('creds', '');
|
||||
}
|
||||
|
||||
private getDbId(category: string, id: string) {
|
||||
return id ? `${category}-${id}` : category;
|
||||
}
|
||||
|
||||
async saveCategory(category: string, id: string, data) {
|
||||
const dbId = this.getDbId(category, id);
|
||||
await this.upsertOne({ id: dbId, data: data });
|
||||
}
|
||||
|
||||
async getCategory(category: string, id: string): Promise<any> {
|
||||
const dbId = this.getDbId(category, id);
|
||||
const data = await this.getById(dbId);
|
||||
return data?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON helpers
|
||||
*/
|
||||
protected stringify(data: any): string {
|
||||
return JSON.stringify(data, esm.b.BufferJSON.replacer);
|
||||
}
|
||||
|
||||
public parse(row: any) {
|
||||
return JSON.parse(row.data, esm.b.BufferJSON.reviver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Chat } from '@adiwajshing/baileys';
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { NowebChatSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlChatMethods } from '@waha/core/engines/noweb/store/sql/SqlChatMethods';
|
||||
import { NOWEBPsqlKVRepository } from '@waha/core/engines/noweb/store/psql/NOWEBPsqlKVRepository';
|
||||
import { OverviewFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
class ChatPaginator extends KnexPaginator {
|
||||
indexes = ['id', 'conversationTimestamp'];
|
||||
}
|
||||
|
||||
export class PsqlChatRepository
|
||||
extends NOWEBPsqlKVRepository<Chat>
|
||||
implements IChatRepository
|
||||
{
|
||||
protected Paginator = ChatPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebChatSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlChatMethods(this);
|
||||
}
|
||||
|
||||
getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
filter?: OverviewFilter,
|
||||
merge?: boolean,
|
||||
): Promise<Chat[]> {
|
||||
return this.methods.getAllWithMessages(
|
||||
pagination,
|
||||
broadcast,
|
||||
filter,
|
||||
merge,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Contact } from '@adiwajshing/baileys';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
import { NowebContactSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
class ContactPaginator extends KnexPaginator {
|
||||
indexes = ['id'];
|
||||
}
|
||||
|
||||
export class PsqlContactRepository
|
||||
extends NOWEBPsqlKVRepository<Contact>
|
||||
implements IContactRepository
|
||||
{
|
||||
protected Paginator = ContactPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebContactSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { NowebGroupsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
class Paginator extends KnexPaginator {
|
||||
indexes = ['id'];
|
||||
}
|
||||
|
||||
export class PsqlGroupRepository
|
||||
extends NOWEBPsqlKVRepository<GroupMetadata>
|
||||
implements IGroupRepository
|
||||
{
|
||||
protected Paginator = Paginator;
|
||||
|
||||
get schema() {
|
||||
return NowebGroupsSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
LabelAssociation,
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { NowebLabelAssociationsMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebLabelAssociationsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlLabelAssociationsMethods } from '@waha/core/engines/noweb/store/sql/SqlLabelAssociationsMethods';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLabelAssociationsRepository
|
||||
extends NOWEBPsqlKVRepository<LabelAssociation>
|
||||
implements ILabelAssociationRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelAssociationsSchema;
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebLabelAssociationsMetadata;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlLabelAssociationsMethods(this);
|
||||
}
|
||||
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
return this.methods.deleteOne(association);
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
return this.methods.deleteByLabelId(labelId);
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.methods.getAssociationsByLabelId(labelId, type);
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.methods.getAssociationsByChatId(chatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { NowebLabelsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
|
||||
export class PsqlLabelsRepository
|
||||
extends NOWEBPsqlKVRepository<Label>
|
||||
implements ILabelsRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelsSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
|
||||
import { NowebMessagesMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebMessagesSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlMessagesMethods } from '@waha/core/engines/noweb/store/sql/SqlMessagesMethods';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { NOWEBPsqlKVRepository } from './NOWEBPsqlKVRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
export class PsqlMessagesRepository
|
||||
extends NOWEBPsqlKVRepository<any>
|
||||
implements IMessagesRepository
|
||||
{
|
||||
constructor(
|
||||
knex: Knex.Knex,
|
||||
private readonly lidRepository: INowebLidPNRepository,
|
||||
) {
|
||||
super(knex);
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return NowebMessagesSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlMessagesMethods(this, this.lidRepository);
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebMessagesMetadata;
|
||||
}
|
||||
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.methods.upsert(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
jid: string,
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
merge?: boolean,
|
||||
): Promise<any[]> {
|
||||
return this.methods.getAllByJid(jid, filter, pagination, merge);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string, merge?: boolean): Promise<any> {
|
||||
return this.methods.getByJidById(jid, id, merge);
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
jid: string,
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
return this.methods.updateByJidAndId(jid, id, update);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
return this.methods.deleteByJidByIds(jid, ids);
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.methods.deleteAllByJid(jid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { IChatRepository } from '@waha/core/engines/noweb/store/IChatRepository';
|
||||
import { IContactRepository } from '@waha/core/engines/noweb/store/IContactRepository';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { IMessagesRepository } from '@waha/core/engines/noweb/store/IMessagesRepository';
|
||||
import { INowebLidPNRepository } from '@waha/core/engines/noweb/store/INowebLidPNRepository';
|
||||
import { INowebStorage } from '@waha/core/engines/noweb/store/INowebStorage';
|
||||
import { Migrations } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { PsqlChatRepository } from '@waha/core/engines/noweb/store/psql/PsqlChatRepository';
|
||||
import { PsqlContactRepository } from '@waha/core/engines/noweb/store/psql/PsqlContactRepository';
|
||||
import { PsqlGroupRepository } from '@waha/core/engines/noweb/store/psql/PsqlGroupRepository';
|
||||
import { PsqlLidPNRepository } from '@waha/core/engines/noweb/store/psql/PsqLidPNRepository';
|
||||
import { PsqlLabelAssociationsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelAssociationsRepository';
|
||||
import { PsqlLabelsRepository } from '@waha/core/engines/noweb/store/psql/PsqlLabelsRepository';
|
||||
import { PsqlMessagesRepository } from '@waha/core/engines/noweb/store/psql/PsqlMessagesRepository';
|
||||
import Knex from 'knex';
|
||||
|
||||
export class PsqlStorage extends INowebStorage {
|
||||
private lidRepository: INowebLidPNRepository | null = null;
|
||||
|
||||
constructor(private knex: Knex.Knex) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.migrate();
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
return this.migration0001init();
|
||||
}
|
||||
|
||||
private async migration0001init() {
|
||||
for (const migration of Migrations) {
|
||||
await this.knex.raw(migration);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
return this.knex.destroy();
|
||||
}
|
||||
|
||||
getContactsRepository(): IContactRepository {
|
||||
return new PsqlContactRepository(this.knex);
|
||||
}
|
||||
|
||||
getChatRepository(): IChatRepository {
|
||||
return new PsqlChatRepository(this.knex);
|
||||
}
|
||||
|
||||
getGroupRepository(): IGroupRepository {
|
||||
return new PsqlGroupRepository(this.knex);
|
||||
}
|
||||
|
||||
getMessagesRepository(): IMessagesRepository {
|
||||
return new PsqlMessagesRepository(this.knex, this.getLidRepository());
|
||||
}
|
||||
|
||||
getLabelsRepository(): ILabelsRepository {
|
||||
return new PsqlLabelsRepository(this.knex);
|
||||
}
|
||||
|
||||
getLabelAssociationRepository(): ILabelAssociationRepository {
|
||||
return new PsqlLabelAssociationsRepository(this.knex);
|
||||
}
|
||||
|
||||
getLidPNRepository(): INowebLidPNRepository {
|
||||
return this.getLidRepository();
|
||||
}
|
||||
|
||||
private getLidRepository(): INowebLidPNRepository {
|
||||
if (!this.lidRepository) {
|
||||
this.lidRepository = new PsqlLidPNRepository(this.knex);
|
||||
}
|
||||
return this.lidRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Field, Index, Schema } from '@waha/core/storage/Schema';
|
||||
import { Migration } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
|
||||
export const SqlAuthSchema = new Schema(
|
||||
'auth',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('auth_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const SqlAuthMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS auth (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS auth_id_index ON auth (id)',
|
||||
];
|
||||
@@ -0,0 +1,329 @@
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
import * as path from 'path';
|
||||
import pino, { Logger } from 'pino';
|
||||
import { AuthStrategy, Client, Events, Store } from 'whatsapp-web.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
|
||||
/* Require Optional Dependencies */
|
||||
let fs;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
fs = require('fs-extra');
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
fs = undefined;
|
||||
}
|
||||
|
||||
async function isValidPath(path: string) {
|
||||
try {
|
||||
await fs.promises.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesizeInBytes(filename: string) {
|
||||
const stats = await fs.promises.lstat(filename).catch(() => null);
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
export interface Zipper {
|
||||
compress(path: string, archivePath: string): Promise<void>;
|
||||
|
||||
uncompress(archivePath: string, path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote-based authentication
|
||||
* @param {object} options - options
|
||||
* @param {object} options.store - Remote database store instance
|
||||
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
|
||||
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
|
||||
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
|
||||
*/
|
||||
export class RemoteAuth implements AuthStrategy {
|
||||
// Required Files & Dirs in WWebJS to restore session
|
||||
private REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
|
||||
// Initial delay sync required for session to be stable enough to recover
|
||||
private INITIAL_DELAY_MS = 60000;
|
||||
|
||||
private readonly clientId: string;
|
||||
private readonly dataPath: string;
|
||||
private readonly tempDir: string;
|
||||
private readonly store: Store;
|
||||
private readonly logger: Logger;
|
||||
|
||||
private client: any;
|
||||
private userDataDir: string;
|
||||
private sessionName: string;
|
||||
private backupSyncRunner: SinglePeriodicJobRunner;
|
||||
private zipper: Zipper;
|
||||
|
||||
constructor(
|
||||
{ clientId, dataPath, store, backupSyncIntervalMs, logger, zipper } = {
|
||||
clientId: 'default',
|
||||
dataPath: undefined,
|
||||
store: null,
|
||||
backupSyncIntervalMs: 60000,
|
||||
zipper: undefined,
|
||||
logger: undefined,
|
||||
},
|
||||
) {
|
||||
if (!fs)
|
||||
throw new Error(
|
||||
'Optional Dependencies [fs-extra] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
|
||||
);
|
||||
|
||||
const idRegex = /^[-_\w]+$/i;
|
||||
if (clientId && !idRegex.test(clientId)) {
|
||||
throw new Error(
|
||||
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
|
||||
);
|
||||
}
|
||||
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
|
||||
throw new Error(
|
||||
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
|
||||
);
|
||||
}
|
||||
if (!store) throw new Error('Remote database store is required.');
|
||||
|
||||
this.store = store;
|
||||
this.clientId = clientId;
|
||||
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
|
||||
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
|
||||
this.zipper = zipper;
|
||||
this.logger = logger || pino({ name: RemoteAuth.name });
|
||||
this.backupSyncRunner = new SinglePeriodicJobRunner(
|
||||
'RemoteAuth Backup Sync',
|
||||
backupSyncIntervalMs,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
get compressedSessionPath() {
|
||||
return `${this.sessionName}.zip`;
|
||||
}
|
||||
|
||||
setup(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async afterBrowserInitialized() {
|
||||
return;
|
||||
}
|
||||
|
||||
async onAuthenticationNeeded() {
|
||||
return {
|
||||
failed: false,
|
||||
restart: false,
|
||||
failureEventPayload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getAuthEventPayload() {
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeBrowserInitialized() {
|
||||
const puppeteerOpts = this.client.options.puppeteer;
|
||||
const sessionDirName = this.clientId
|
||||
? `RemoteAuth-${this.clientId}`
|
||||
: 'RemoteAuth';
|
||||
const dirPath = path.join(this.dataPath, sessionDirName);
|
||||
|
||||
if (puppeteerOpts.userDataDir && puppeteerOpts.userDataDir !== dirPath) {
|
||||
throw new Error(
|
||||
'RemoteAuth is not compatible with a user-supplied userDataDir.',
|
||||
);
|
||||
}
|
||||
|
||||
this.userDataDir = dirPath;
|
||||
this.logger.debug(`User data dir: ${this.userDataDir}`);
|
||||
this.sessionName = sessionDirName;
|
||||
this.logger.debug(`Session name: ${this.sessionName}`);
|
||||
|
||||
await this.extractRemoteSession();
|
||||
await this.removeSingletonFiles(dirPath);
|
||||
|
||||
this.client.options.puppeteer = {
|
||||
...puppeteerOpts,
|
||||
userDataDir: dirPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find in direction Singleton* files and try to remove it
|
||||
* Fix for SingletonLock and other files
|
||||
*/
|
||||
private async removeSingletonFiles(dir: string) {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('Singleton')) {
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
await fs.promises.rm(filePath, {
|
||||
maxRetries: 4,
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Error deleting: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.disconnect();
|
||||
await this.destroy();
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.backupSyncRunner.stop();
|
||||
// @ts-ignore
|
||||
if (this.store.close) {
|
||||
// @ts-ignore
|
||||
await this.store.close();
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.deleteRemoteSession();
|
||||
await this.deleteLocalSession();
|
||||
}
|
||||
|
||||
async afterAuthReady() {
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (!sessionExists) {
|
||||
/* Initial delay sync required for session to be stable enough to recover */
|
||||
await sleep(this.INITIAL_DELAY_MS);
|
||||
await this.storeRemoteSession();
|
||||
this.client.emit(Events.REMOTE_SESSION_SAVED);
|
||||
}
|
||||
|
||||
this.backupSyncRunner.start(async () => {
|
||||
await this.storeRemoteSession();
|
||||
});
|
||||
}
|
||||
|
||||
async storeRemoteSession() {
|
||||
const pathExists = await isValidPath(this.userDataDir);
|
||||
if (!pathExists) {
|
||||
this.logger.warn(
|
||||
'User data dir does not exist. Skipping session backup.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.compressSession();
|
||||
await this.store.save({ session: this.sessionName });
|
||||
await this.removePathSilently(this.compressedSessionPath);
|
||||
await this.removePathSilently(this.tempDir);
|
||||
}
|
||||
|
||||
async extractRemoteSession() {
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (!sessionExists) {
|
||||
fs.mkdirSync(this.userDataDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.store.extract({
|
||||
session: this.sessionName,
|
||||
path: this.compressedSessionPath,
|
||||
});
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
await this.unCompressSession();
|
||||
await this.removePathSilently(this.compressedSessionPath);
|
||||
}
|
||||
|
||||
private async deleteRemoteSession() {
|
||||
const sessionExists = await this.store.sessionExists({
|
||||
session: this.sessionName,
|
||||
});
|
||||
if (sessionExists) await this.store.delete({ session: this.sessionName });
|
||||
}
|
||||
|
||||
private async deleteLocalSession() {
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
}
|
||||
|
||||
async compressSession() {
|
||||
// Chrome's Storage Partitioning creates symlinks with numeric names inside the
|
||||
// profile. fs-extra's copy resolves them and throws "subdirectory of itself"
|
||||
// when it detects a cycle. We skip symlinks entirely — session-critical data
|
||||
// (IndexedDB, Local Storage, cookies) lives in regular files only.
|
||||
const skipSymlinks = async (src: string) => {
|
||||
try {
|
||||
const lstat = await fs.promises.lstat(src);
|
||||
return !lstat.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
await fs.copy(this.userDataDir, this.tempDir, { filter: skipSymlinks });
|
||||
await this.deleteMetadata();
|
||||
|
||||
this.logger.debug('Compressing session...');
|
||||
await this.zipper.compress(this.tempDir, this.compressedSessionPath);
|
||||
this.logger.debug('Session compressed.');
|
||||
|
||||
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
|
||||
this.logger.debug(`Session archive size: ${prettyBytes(zipSize)}`);
|
||||
}
|
||||
|
||||
async unCompressSession() {
|
||||
const zipSize = await getFilesizeInBytes(this.compressedSessionPath);
|
||||
this.logger.debug(`Restored Session archive size: ${prettyBytes(zipSize)}`);
|
||||
|
||||
this.logger.debug('Uncompressing session...');
|
||||
await this.zipper.uncompress(this.compressedSessionPath, this.userDataDir);
|
||||
this.logger.debug('Session uncompressed.');
|
||||
}
|
||||
|
||||
async deleteMetadata() {
|
||||
const sessionDirs = [this.tempDir, path.join(this.tempDir, 'Default')];
|
||||
for (const dir of sessionDirs) {
|
||||
const sessionFiles = await fs.promises.readdir(dir);
|
||||
for (const element of sessionFiles) {
|
||||
if (this.REQUIRED_DIRS.includes(element)) {
|
||||
continue;
|
||||
}
|
||||
const dirElement = path.join(dir, element);
|
||||
await this.removePathSilently(dirElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removePathSilently(path: string) {
|
||||
const exists = await isValidPath(path);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(path, {
|
||||
maxRetries: 4,
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Error deleting: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Zipper } from '@waha/core/engines/webjs/RemoteAuth';
|
||||
|
||||
let AdmZip;
|
||||
let archiver;
|
||||
import * as fs from 'fs';
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AdmZip = require('adm-zip');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
archiver = require('archiver');
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AdmZip = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
archiver = undefined;
|
||||
}
|
||||
|
||||
export class StreamZipper implements Zipper {
|
||||
constructor() {
|
||||
if (!archiver && !AdmZip)
|
||||
throw new Error(
|
||||
'Optional Dependencies [archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
|
||||
);
|
||||
}
|
||||
|
||||
compress(path: string, archivePath: string): Promise<void> {
|
||||
const archive = archiver('zip');
|
||||
const stream = fs.createWriteStream(archivePath);
|
||||
return new Promise((resolve, reject) => {
|
||||
archive
|
||||
.directory(path, false)
|
||||
.on('error', (err) => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
stream.on('close', () => resolve(null));
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
async uncompress(archivePath: string, path: string): Promise<void> {
|
||||
const zip = new AdmZip(archivePath);
|
||||
await new Promise((resolve, reject) => {
|
||||
zip.extractAllToAsync(path, false, false, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { RemoteAuth, Zipper } from '@waha/core/engines/webjs/RemoteAuth';
|
||||
import { StreamZipper } from '@waha/core/engines/webjs/StreamZipper';
|
||||
import { WebJSMongoAuth } from '@waha/core/engines/webjs/WebJSMongoAuth';
|
||||
import { ZipUnzipZipper } from '@waha/core/engines/webjs/ZipUnzipZipper';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { LoggerBuilder } from '@waha/utils/logging';
|
||||
import { Logger } from 'pino';
|
||||
import { AuthStrategy } from 'whatsapp-web.js';
|
||||
|
||||
import { WebJSPsqlAuth } from './WebJSPsqlAuth';
|
||||
|
||||
export class WebJSAuthFactory {
|
||||
async buildAuth(
|
||||
store: DataStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
): Promise<AuthStrategy> {
|
||||
if (store instanceof MongoStore)
|
||||
return this.buildMongoAuth(store, name, loggerBuilder);
|
||||
if (store instanceof PsqlStore)
|
||||
return await this.buildPsql(store, name, loggerBuilder);
|
||||
if (store instanceof LocalStore)
|
||||
return this.buildLocalAuth(store, name, loggerBuilder);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
buildLocalAuth(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: LocalAuth.name });
|
||||
return new LocalAuth({
|
||||
clientId: name,
|
||||
dataPath: store.getSessionDirectory(name),
|
||||
logger: logger,
|
||||
rmMaxRetries: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async buildPsql(
|
||||
store: PsqlStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: WebJSPsqlAuth.name });
|
||||
const knex = store.buildSessionKnex(name, 'Session/Auth');
|
||||
const authStore = new WebJSPsqlAuth(knex, logger);
|
||||
await authStore.init();
|
||||
const zipper = this.getAvailableZipper(logger);
|
||||
return new RemoteAuth({
|
||||
backupSyncIntervalMs: 60 * 1000,
|
||||
clientId: name,
|
||||
dataPath: null,
|
||||
logger: loggerBuilder.child({ name: RemoteAuth.name }),
|
||||
store: authStore,
|
||||
zipper: zipper,
|
||||
});
|
||||
}
|
||||
|
||||
private buildMongoAuth(
|
||||
store: MongoStore,
|
||||
name: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
) {
|
||||
const logger = loggerBuilder.child({ name: WebJSMongoAuth.name });
|
||||
const authStore = new WebJSMongoAuth(name, store, logger);
|
||||
const zipper = this.getAvailableZipper(logger);
|
||||
return new RemoteAuth({
|
||||
backupSyncIntervalMs: 60 * 1000,
|
||||
clientId: name,
|
||||
dataPath: null,
|
||||
logger: loggerBuilder.child({ name: RemoteAuth.name }),
|
||||
store: authStore,
|
||||
zipper: zipper,
|
||||
});
|
||||
}
|
||||
|
||||
private getAvailableZipper(logger: Logger): Zipper {
|
||||
if (process.env.WAHA_ZIPPER == 'ZIPUNZIP') {
|
||||
logger.debug('Using ZipUnzipZipper');
|
||||
return new ZipUnzipZipper();
|
||||
}
|
||||
logger.debug('Using StreamZipper');
|
||||
return new StreamZipper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { sleep } from '@nestjs/terminus/dist/utils';
|
||||
import * as fs from 'fs';
|
||||
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
|
||||
import { Logger } from 'pino';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Store } from 'whatsapp-web.js';
|
||||
|
||||
import { MongoStore } from '../../storage/mongo/MongoStore';
|
||||
|
||||
class WebJSMongoAuth implements Store {
|
||||
private store: MongoStore;
|
||||
private logger: Logger;
|
||||
private sessionName: string;
|
||||
private db: Db;
|
||||
private bucket: GridFSBucket;
|
||||
|
||||
constructor(sessionName: string, store: MongoStore, logger: Logger) {
|
||||
this.store = store;
|
||||
this.logger = logger;
|
||||
this.sessionName = sessionName;
|
||||
this.db = this.store.getSessionDb(sessionName);
|
||||
const bucketName = this.getBucketName();
|
||||
this.bucket = new GridFSBucket(this.db, {
|
||||
bucketName: bucketName,
|
||||
});
|
||||
}
|
||||
|
||||
async sessionExists(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.info('Checking if session exists...');
|
||||
const filesCollection = this.getFilesCollectionName();
|
||||
const multiDeviceCollection = this.db.collection(filesCollection);
|
||||
const hasExistingSession = await multiDeviceCollection.countDocuments();
|
||||
const result = !!hasExistingSession;
|
||||
this.logger.info(`Session exists: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async save(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.debug('Saving session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const readStream = fs.createReadStream(filename);
|
||||
const uploadStream = this.bucket.openUploadStream(filename);
|
||||
await pipeline(readStream, uploadStream);
|
||||
this.logger.debug('Session saved.');
|
||||
await this.#deletePrevious(options, this.bucket);
|
||||
}
|
||||
|
||||
async extract(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.info('Extracting existing session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const downloadStream = this.bucket.openDownloadStreamByName(filename);
|
||||
const writeStream = fs.createWriteStream(options.path);
|
||||
await pipeline(downloadStream, writeStream);
|
||||
// Wait a second before giving the zip file to next phase
|
||||
await sleep(1_000);
|
||||
this.logger.info('Session has been extracted.');
|
||||
}
|
||||
|
||||
async delete(options) {
|
||||
this.checkSessionName(options);
|
||||
this.logger.debug('Deleting session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const documents = await this.bucket
|
||||
.find({
|
||||
filename: filename,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
documents.map(async (doc) => {
|
||||
return this.bucket.delete(doc._id);
|
||||
});
|
||||
this.logger.debug('Session deleted.');
|
||||
}
|
||||
|
||||
async #deletePrevious(options, bucket) {
|
||||
const filename = this.getAuthFileName(options);
|
||||
const documents = await bucket
|
||||
.find({
|
||||
filename: filename,
|
||||
})
|
||||
.toArray();
|
||||
if (documents.length > 1) {
|
||||
this.logger.debug('Deleting old auth files...');
|
||||
// Got all, but not the last one
|
||||
const oldDocuments = documents.slice();
|
||||
// Sort by uploadDate, desc
|
||||
oldDocuments.sort((a, b) => {
|
||||
return a.uploadDate - b.uploadDate;
|
||||
});
|
||||
const keepDocument = oldDocuments.pop();
|
||||
this.logger.debug(
|
||||
`Keeping document - '${keepDocument.uploadDate}', '${keepDocument._id}'`,
|
||||
);
|
||||
oldDocuments.map((document: GridFSFile) => {
|
||||
this.logger.debug(
|
||||
`Deleting document - '${document.uploadDate}', '${document._id}'`,
|
||||
);
|
||||
return bucket.delete(document._id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session name
|
||||
*/
|
||||
private getSessionName(options): string {
|
||||
const prefix = 'RemoteAuth';
|
||||
if (!options.session || options.session === prefix) {
|
||||
return 'default';
|
||||
}
|
||||
// Remote prefix
|
||||
return options.session.replace(`${prefix}-`, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Even tho we accept any "session" in options,
|
||||
* but the store hold a single session database link (bucket)
|
||||
* This is why we need to check if the session name
|
||||
* is the same as the store session name
|
||||
*/
|
||||
private checkSessionName(options) {
|
||||
const session = this.getSessionName(options);
|
||||
if (session !== this.sessionName) {
|
||||
throw new Error(
|
||||
`Session name '${session}' does not match the store session name '${this.sessionName}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getBucketName(): string {
|
||||
return `auth`;
|
||||
}
|
||||
|
||||
private getFilesCollectionName(): string {
|
||||
const bucket = this.getBucketName();
|
||||
return `${bucket}.files`;
|
||||
}
|
||||
|
||||
private getAuthFileName(options): string {
|
||||
return `${options.session}.zip`;
|
||||
}
|
||||
}
|
||||
|
||||
export { WebJSMongoAuth };
|
||||
@@ -0,0 +1,75 @@
|
||||
import { sleep } from '@nestjs/terminus/dist/utils';
|
||||
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'pino';
|
||||
import { Store } from 'whatsapp-web.js';
|
||||
|
||||
interface Options {
|
||||
session: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
class WebjsFileRepository extends PsqlFileRepository {
|
||||
get tableName() {
|
||||
return 'files';
|
||||
}
|
||||
}
|
||||
|
||||
export class WebJSPsqlAuth implements Store {
|
||||
private repository: PsqlFileRepository;
|
||||
|
||||
constructor(
|
||||
private knex: Knex.Knex,
|
||||
private logger: Logger,
|
||||
) {
|
||||
this.repository = new WebjsFileRepository(knex, logger);
|
||||
}
|
||||
|
||||
async sessionExists(options: Options): Promise<boolean> {
|
||||
this.logger.info('Checking if session exists...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const exists = await this.repository.exists(filename);
|
||||
this.logger.info(`Session exists: ${exists}`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
async delete(options: Options): Promise<any> {
|
||||
this.logger.debug('Deleting session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
await this.repository.delete(filename);
|
||||
this.logger.debug('Session deleted.');
|
||||
}
|
||||
|
||||
async save(options: Options): Promise<any> {
|
||||
this.logger.debug('Saving session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
await this.repository.saveFromFile(filename, filename);
|
||||
this.logger.debug('Session saved.');
|
||||
}
|
||||
|
||||
async extract(options: Options) {
|
||||
this.logger.debug('Extracting existing session...');
|
||||
const filename = this.getAuthFileName(options);
|
||||
const found = await this.repository.fetchToFile(filename, options.path);
|
||||
if (!found) {
|
||||
this.logger.warn('Session does not exist.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a second before giving the zip file to next phase
|
||||
await sleep(1_000);
|
||||
this.logger.info('Session has been extracted.');
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
private getAuthFileName(options: Options): string {
|
||||
return `${options.session}.zip`;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.knex.destroy();
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ 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';
|
||||
|
||||
@@ -22,6 +23,9 @@ const { LoadLodash } = require('./_lodash.js');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { LoadPaginator } = require('./_Paginator.js');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { LoadWAHACore } = require('./_WAHACore.js');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
|
||||
|
||||
@@ -30,6 +34,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;
|
||||
@@ -66,6 +131,7 @@ export class WebjsClientCore extends Client {
|
||||
await this.pupPage.evaluate(LoadLodash);
|
||||
await this.pupPage.evaluate(LoadPaginator);
|
||||
await this.pupPage.evaluate(LoadWAHA);
|
||||
await this.pupPage.evaluate(LoadWAHACore);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,4 +644,100 @@ export class WebjsClientCore extends Client {
|
||||
await sleep(3_000);
|
||||
return await this.getCurrentPresence(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
async channelFetchMessageByInvite(
|
||||
inviteCode: string,
|
||||
limit: number,
|
||||
): Promise<WebjsChannelMessage[]> {
|
||||
await this.ensureWahaExtraInjected();
|
||||
const response: _GetNewsletterPreviewDataResponse =
|
||||
await this.pupPage.evaluate(
|
||||
async (code, limit) => {
|
||||
// Overwrite limit
|
||||
window['WAHA'].WAWebNewsletterGatingUtils = () => limit;
|
||||
|
||||
const result = await window[
|
||||
'WAHA'
|
||||
].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[
|
||||
'WAHA'
|
||||
].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> {
|
||||
await this.ensureWahaExtraInjected();
|
||||
const newsletters: any = await this.pupPage.evaluate(async (params) => {
|
||||
return await window[
|
||||
'WAHA'
|
||||
].WAWebNewsletterDirectorySearchJob.getNewsletterDirectoryList(params);
|
||||
}, params);
|
||||
return newsletters;
|
||||
}
|
||||
|
||||
async searchChannelsText(params: any): Promise<any> {
|
||||
await this.ensureWahaExtraInjected();
|
||||
const newsletters: any = await this.pupPage.evaluate(async (params) => {
|
||||
return await window[
|
||||
'WAHA'
|
||||
].WAWebNewsletterDirectorySearchJob.getNewsletterDirectorySearchResults(
|
||||
params,
|
||||
);
|
||||
}, params);
|
||||
return newsletters;
|
||||
}
|
||||
|
||||
protected async ensureWahaExtraInjected() {
|
||||
const hasWahaExtra = await this.pupPage.evaluate(() => {
|
||||
return Boolean(
|
||||
// @ts-ignore
|
||||
window.WAHA &&
|
||||
// @ts-ignore
|
||||
window.WAHA.WAWebNewsletterDirectorySearchJob &&
|
||||
// @ts-ignore
|
||||
window.WAHA.WAWebNewsletterPreviewJob,
|
||||
);
|
||||
});
|
||||
if (!hasWahaExtra) {
|
||||
await this.injectWaha();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
exports.LoadWAHACore = () => {
|
||||
// Channels Search
|
||||
window.WAHA.WAWebNewsletterDirectorySearchJob = window.require(
|
||||
'WAWebNewsletterDirectorySearchJob',
|
||||
);
|
||||
// Channels load "preview" - fetch messages by invite code
|
||||
window.WAHA.WAWebNewsletterPreviewJob = window.require(
|
||||
'WAWebNewsletterPreviewJob',
|
||||
);
|
||||
window.WAHA.WAWebLoadNewsletterPreviewChatAction = window.require(
|
||||
'WAWebLoadNewsletterPreviewChatAction',
|
||||
);
|
||||
|
||||
// Rewrite newsletter count to different number
|
||||
window.WAHA.WAWebNewsletterGatingUtils = window.require(
|
||||
'WAWebNewsletterGatingUtils',
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import {
|
||||
getChannelInviteLink,
|
||||
getPublicUrlFromDirectPath,
|
||||
WhatsappSession,
|
||||
} from '@waha/core/abc/session.abc';
|
||||
import {
|
||||
@@ -18,21 +19,23 @@ 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';
|
||||
import { StatusToAck } from '@waha/core/utils/acks';
|
||||
import {
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
Channel,
|
||||
ChannelListResult,
|
||||
ChannelMessage,
|
||||
ChannelPublicInfo,
|
||||
ChannelRole,
|
||||
ChannelSearchByText,
|
||||
ChannelSearchByView,
|
||||
@@ -74,11 +78,12 @@ import {
|
||||
MessageImageRequest,
|
||||
MessageLocationRequest,
|
||||
MessagePollRequest,
|
||||
MessagePollVoteRequest,
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
MessageTextRequest,
|
||||
MessageVoiceRequest,
|
||||
MessageVideoRequest,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
@@ -121,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,
|
||||
@@ -172,6 +184,7 @@ import {
|
||||
} from 'whatsapp-web.js/src/structures';
|
||||
|
||||
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
|
||||
import { WebJSAuthFactory } from './WebJSAuthFactory';
|
||||
import {
|
||||
isJidCus,
|
||||
isJidGroup,
|
||||
@@ -200,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;
|
||||
|
||||
@@ -283,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());
|
||||
}
|
||||
@@ -745,12 +759,48 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
private async fileToMedia(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<MessageMedia> {
|
||||
if ('url' in file) {
|
||||
const mediaOptions = { unsafeMime: true };
|
||||
const media = await MessageMedia.fromUrl(file.url, mediaOptions);
|
||||
media.mimetype = file.mimetype || media.mimetype;
|
||||
media.filename = file.filename || media.filename;
|
||||
return media;
|
||||
}
|
||||
return new MessageMedia(file.mimetype, file.data, file.filename);
|
||||
}
|
||||
|
||||
protected deleteProfilePicture(): Promise<boolean> {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
protected async setProfilePicture(
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
return await this.whatsapp.setProfilePicture(media);
|
||||
}
|
||||
|
||||
protected async deleteProfilePicture(): Promise<boolean> {
|
||||
return await this.whatsapp.deleteProfilePicture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups methods
|
||||
*/
|
||||
@Activity()
|
||||
protected async setGroupPicture(
|
||||
id: string,
|
||||
file: BinaryFile | RemoteFile,
|
||||
): Promise<boolean> {
|
||||
const media = await this.fileToMedia(file);
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return await groupChat.setPicture(media);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
protected async deleteGroupPicture(id: string): Promise<boolean> {
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return await groupChat.deletePicture();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -844,20 +894,137 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
sendImage(request: MessageImageRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendFile(request: MessageFileRequest) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
if (!media.mimetype) {
|
||||
media.mimetype = await detectMimetype(Buffer.from(media.data, 'base64'));
|
||||
}
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
sendMediaAsDocument: true,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendFile(request: MessageFileRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendImage(request: MessageImageRequest) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.mimetype = media.mimetype || WAMimeType.IMAGE;
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendVoice(request: MessageVoiceRequest) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVoice(request) {
|
||||
const media = await this.fileToMedia(request.file);
|
||||
if (request.convert) {
|
||||
await this.convertVoice(media);
|
||||
}
|
||||
media.mimetype = request.file.mimetype || WAMimeType.VOICE;
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
sendAudioAsVoice: true,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
sendButtonsReply(request: MessageButtonReply) {
|
||||
throw new AvailableInPlusVersion();
|
||||
@Activity()
|
||||
async sendVideo(request: MessageVideoRequest) {
|
||||
this.checkBrowserIsChrome();
|
||||
const media = await this.fileToMedia(request.file);
|
||||
media.mimetype = media.mimetype || WAMimeType.VIDEO;
|
||||
if (request.convert) {
|
||||
await this.convertVideo(media);
|
||||
}
|
||||
|
||||
let options = this.getMessageOptions(request);
|
||||
options = {
|
||||
...options,
|
||||
caption: request.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
media,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private async convertVideo(media: MessageMedia) {
|
||||
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
|
||||
content = await this.mediaConverter.video(content);
|
||||
media.data = content.toString('base64');
|
||||
media.mimetype = WAMimeType.VIDEO;
|
||||
media.filename = null;
|
||||
media.filesize = null;
|
||||
}
|
||||
|
||||
private async convertVoice(media: MessageMedia) {
|
||||
let content: Buffer<ArrayBufferLike> = Buffer.from(media.data, 'base64');
|
||||
content = await this.mediaConverter.voice(content);
|
||||
media.data = content.toString('base64');
|
||||
media.mimetype = WAMimeType.VOICE;
|
||||
media.filename = null;
|
||||
media.filesize = null;
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendPollVote(request: MessagePollVoteRequest) {
|
||||
const message = await this.whatsapp.getMessageById(request.pollMessageId);
|
||||
if (!message) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Poll message not found: ${request.pollMessageId}`,
|
||||
);
|
||||
}
|
||||
return message.vote(request.votes);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
async sendButtonsReply(request: MessageButtonReply) {
|
||||
const options = this.getMessageOptions(request);
|
||||
const extra: any = {
|
||||
type: 'buttons_response',
|
||||
kind: 'buttonsResponse',
|
||||
buttonsResponse: {
|
||||
selectedButtonId: request.selectedButtonID,
|
||||
selectedDisplayText: request.selectedDisplayText,
|
||||
type: 1,
|
||||
},
|
||||
viewMode: 'VISIBLE',
|
||||
};
|
||||
options.extra = extra;
|
||||
return this.whatsapp.sendMessage(
|
||||
this.ensureSuffix(request.chatId),
|
||||
request.selectedDisplayText,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private checkBrowserIsChrome() {
|
||||
if (!IsChrome) {
|
||||
const msg =
|
||||
'Use "devlikeapro/waha:chrome" docker image to send video in WEBJS';
|
||||
throw new UnprocessableEntityException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@Activity()
|
||||
@@ -1459,23 +1626,85 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
/**
|
||||
* Channels methods
|
||||
*/
|
||||
public searchChannelsByView(
|
||||
@Activity()
|
||||
public async searchChannelsByView(
|
||||
query: ChannelSearchByView,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const params = {
|
||||
view: query.view || 'TRENDING',
|
||||
countryCodes: query.countries,
|
||||
cursorToken: query.startCursor,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
};
|
||||
const data = await this.whatsapp.searchChannelsView(params);
|
||||
return this.channelsRawDataToResponse(data);
|
||||
}
|
||||
|
||||
public searchChannelsByText(
|
||||
@Activity()
|
||||
public async searchChannelsByText(
|
||||
query: ChannelSearchByText,
|
||||
): Promise<ChannelListResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const params = {
|
||||
searchText: query.text,
|
||||
cursorToken: query.startCursor,
|
||||
categories: query.categories,
|
||||
limit: query.limit,
|
||||
};
|
||||
const data = await this.whatsapp.searchChannelsText(params);
|
||||
return this.channelsRawDataToResponse(data);
|
||||
}
|
||||
|
||||
private channelsRawDataToResponse(data: any): ChannelListResult {
|
||||
const pageInfo = data.pageInfo;
|
||||
const newsletters = data.newsletters;
|
||||
return {
|
||||
page: {
|
||||
startCursor: pageInfo.startCursor,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
hasPreviousPage: pageInfo.hasPreviousPage,
|
||||
},
|
||||
channels: newsletters.map(NewsletterMetadataToChannel),
|
||||
};
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async previewChannelMessages(
|
||||
inviteCode: string,
|
||||
query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const channelMessages = await this.whatsapp.channelFetchMessageByInvite(
|
||||
inviteCode,
|
||||
query.limit,
|
||||
);
|
||||
const promises = [];
|
||||
for (const msg of channelMessages) {
|
||||
promises.push(
|
||||
this.WebjsChannelMessageToChannelMessage(msg, query.downloadMedia),
|
||||
);
|
||||
}
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
|
||||
private async WebjsChannelMessageToChannelMessage(
|
||||
channelMessage: WebjsChannelMessage,
|
||||
downloadMedia: boolean,
|
||||
): Promise<ChannelMessage> {
|
||||
const message = await this.processIncomingMessage(
|
||||
channelMessage.message,
|
||||
downloadMedia,
|
||||
);
|
||||
const reactions = {};
|
||||
for (const reaction of channelMessage.reactions.sort((x) => -x.count)) {
|
||||
reactions[reaction.reaction] = reaction.count;
|
||||
}
|
||||
|
||||
return {
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
viewCount: channelMessage.viewCount,
|
||||
};
|
||||
}
|
||||
|
||||
protected ChatToChannel(chat: WEBJSChannel): Channel {
|
||||
@@ -1679,6 +1908,44 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendImageStatus(status: ImageStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
const options = {
|
||||
caption: status.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVoiceStatus(status: VoiceStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
if (status.convert) {
|
||||
await this.convertVoice(media);
|
||||
}
|
||||
media.mimetype = status.file.mimetype || WAMimeType.VOICE;
|
||||
const options = {
|
||||
sendAudioAsVoice: true,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public async sendVideoStatus(status: VideoStatus) {
|
||||
this.checkBrowserIsChrome();
|
||||
this.checkStatusRequest(status);
|
||||
const media = await this.fileToMedia(status.file);
|
||||
if (status.convert) {
|
||||
await this.convertVideo(media);
|
||||
}
|
||||
const options = {
|
||||
caption: status.caption,
|
||||
};
|
||||
return this.whatsapp.sendMessage(BROADCAST_ID, media, options);
|
||||
}
|
||||
|
||||
@Activity()
|
||||
public sendTextStatus(status: TextStatus) {
|
||||
this.checkStatusRequest(status);
|
||||
@@ -2224,7 +2491,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message: Message) {
|
||||
const processor = new WEBJSEngineMediaProcessor();
|
||||
let processor = new WEBJSEngineMediaProcessor();
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
const media = await this.mediaManager.processMedia(
|
||||
processor,
|
||||
message,
|
||||
@@ -2247,6 +2515,29 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
}
|
||||
}
|
||||
|
||||
function NewsletterMetadataToChannel(data: any): ChannelPublicInfo {
|
||||
const pictureDirectPath =
|
||||
data.newsletterPictureMetadataMixin.picture?.[0]
|
||||
.queryPictureDirectPathOrEmptyResponseMixinGroup?.value?.directPath;
|
||||
const pictureUrl = getPublicUrlFromDirectPath(pictureDirectPath);
|
||||
return {
|
||||
id: data.idJid,
|
||||
name: data.newsletterNameMetadataMixin.nameElementValue,
|
||||
picture: pictureUrl,
|
||||
description:
|
||||
data.newsletterDescriptionMetadataMixin
|
||||
.descriptionQueryDescriptionResponseMixin.elementValue,
|
||||
invite: getChannelInviteLink(
|
||||
data.newsletterInviteLinkMetadataMixin.inviteCode,
|
||||
),
|
||||
subscribersCount: Number(
|
||||
data.newsletterSubscribersMetadataMixin.subscribersCount,
|
||||
),
|
||||
verified:
|
||||
data.newsletterVerificationMetadataMixin.verificationState === 'verified',
|
||||
};
|
||||
}
|
||||
|
||||
export class WEBJSEngineMediaProcessor
|
||||
implements IMediaEngineProcessor<Message>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
|
||||
import { WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import { LoggerBuilder } from '@waha/utils/logging';
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { Zipper } from '../webjs/RemoteAuth';
|
||||
import { StreamZipper } from '../webjs/StreamZipper';
|
||||
import { ZipUnzipZipper } from '../webjs/ZipUnzipZipper';
|
||||
import { WPPAuthManager } from './WPPAuthManager';
|
||||
import { WPPMongoStore } from './WPPMongoStore';
|
||||
import { WPPPsqlStore } from './WPPPsqlStore';
|
||||
|
||||
export class WPPAuthFactory {
|
||||
async build(
|
||||
store: DataStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): Promise<IWPPAuthManager | null> {
|
||||
if (store instanceof LocalStore) {
|
||||
return null;
|
||||
}
|
||||
if (store instanceof MongoStore) {
|
||||
return this.buildMongo(
|
||||
store,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
if (store instanceof PsqlStore) {
|
||||
return this.buildPsql(
|
||||
store,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`WPPAuthFactory: unsupported store type '${store.constructor.name}'`,
|
||||
);
|
||||
}
|
||||
|
||||
private buildMongo(
|
||||
store: MongoStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): IWPPAuthManager {
|
||||
const logger = loggerBuilder.child({ name: WPPMongoStore.name });
|
||||
const wppStore = new WPPMongoStore(store, sessionName, logger);
|
||||
return this.buildManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private async buildPsql(
|
||||
store: PsqlStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): Promise<IWPPAuthManager> {
|
||||
const logger = loggerBuilder.child({ name: WPPPsqlStore.name });
|
||||
const wppStore = await WPPPsqlStore.build(store, sessionName, logger);
|
||||
return this.buildManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
loggerBuilder,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private buildManager(
|
||||
wppStore: WPPMongoStore | WPPPsqlStore,
|
||||
sessionName: string,
|
||||
userDataDir: string,
|
||||
loggerBuilder: LoggerBuilder,
|
||||
getStatus: () => WAHASessionStatus,
|
||||
): WPPAuthManager {
|
||||
const logger = loggerBuilder.child({ name: WPPAuthManager.name });
|
||||
const zipper = this.getZipper(logger);
|
||||
return new WPPAuthManager(
|
||||
wppStore,
|
||||
sessionName,
|
||||
userDataDir,
|
||||
zipper,
|
||||
logger,
|
||||
getStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private getZipper(logger: Logger): Zipper {
|
||||
if (process.env.WAHA_ZIPPER === 'ZIPUNZIP') {
|
||||
logger.debug('Using ZipUnzipZipper');
|
||||
return new ZipUnzipZipper();
|
||||
}
|
||||
logger.debug('Using StreamZipper');
|
||||
return new StreamZipper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager';
|
||||
import { WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { Zipper } from '../webjs/RemoteAuth';
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
// Only these dirs need to be backed up from the Chromium profile
|
||||
const REQUIRED_DIRS = ['Default', 'IndexedDB', 'Local Storage'];
|
||||
const INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export class WPPAuthManager implements IWPPAuthManager {
|
||||
private backupRunner: SinglePeriodicJobRunner | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly store: WPPStore,
|
||||
private readonly sessionName: string,
|
||||
private readonly userDataDir: string,
|
||||
private readonly zipper: Zipper,
|
||||
private readonly logger: Logger,
|
||||
private readonly getStatus: () => WAHASessionStatus,
|
||||
private readonly backupIntervalMs: number = INITIAL_DELAY_MS,
|
||||
) {}
|
||||
|
||||
async beforeStart(): Promise<void> {
|
||||
const sessionExists = await this.store.sessionExists(this.sessionName);
|
||||
if (!sessionExists) {
|
||||
this.logger.info('No remote WPP session found, starting fresh');
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Remote WPP session found, restoring...');
|
||||
const data = await this.store.load(this.sessionName);
|
||||
if (!data) {
|
||||
this.logger.warn('Remote WPP session data is empty, starting fresh');
|
||||
return;
|
||||
}
|
||||
|
||||
const zipPath = this.getZipPath();
|
||||
await fsp.mkdir(path.dirname(zipPath), { recursive: true });
|
||||
await fsp.writeFile(zipPath, data);
|
||||
|
||||
await this.removePathSilently(this.userDataDir);
|
||||
await this.zipper.uncompress(zipPath, this.userDataDir);
|
||||
await this.removePathSilently(zipPath);
|
||||
|
||||
await this.removeSingletonFiles(this.userDataDir);
|
||||
await sleep(2_000);
|
||||
this.logger.info('Remote WPP session restored successfully');
|
||||
}
|
||||
|
||||
async afterConnected(): Promise<void> {
|
||||
if (this.backupRunner) {
|
||||
// Already running from a previous WORKING transition
|
||||
return;
|
||||
}
|
||||
|
||||
this.backupRunner = new SinglePeriodicJobRunner(
|
||||
'WPP Auth Backup',
|
||||
this.backupIntervalMs,
|
||||
this.logger,
|
||||
);
|
||||
this.backupRunner.start(() => this.saveSnapshot());
|
||||
}
|
||||
|
||||
async saveSnapshot(): Promise<void> {
|
||||
if (this.getStatus() !== WAHASessionStatus.WORKING) {
|
||||
this.logger.debug('Skipping WPP snapshot - session not WORKING');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug('Saving WPP session snapshot...');
|
||||
const tempDir = this.getTempDir();
|
||||
const zipPath = this.getZipPath();
|
||||
|
||||
try {
|
||||
await fs.copy(this.userDataDir, tempDir);
|
||||
// await this.deleteMetadata(tempDir);
|
||||
|
||||
await this.zipper.compress(tempDir, zipPath);
|
||||
|
||||
const data = await fsp.readFile(zipPath);
|
||||
await this.store.save(this.sessionName, data);
|
||||
this.logger.info('WPP session snapshot saved');
|
||||
} finally {
|
||||
await this.removePathSilently(tempDir);
|
||||
await this.removePathSilently(zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.backupRunner?.stop();
|
||||
this.backupRunner = null;
|
||||
}
|
||||
|
||||
private async deleteMetadata(tempDir: string): Promise<void> {
|
||||
const dirsToScan = [tempDir, path.join(tempDir, 'Default')];
|
||||
for (const dir of dirsToScan) {
|
||||
const exists = await fs.pathExists(dir);
|
||||
if (!exists) {
|
||||
continue;
|
||||
}
|
||||
const entries = await fsp.readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (REQUIRED_DIRS.includes(entry)) {
|
||||
continue;
|
||||
}
|
||||
await this.removePathSilently(path.join(dir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSingletonFiles(dir: string): Promise<void> {
|
||||
const exists = await fs.pathExists(dir);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
const files = await fsp.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('Singleton')) {
|
||||
continue;
|
||||
}
|
||||
await this.removePathSilently(path.join(dir, file));
|
||||
}
|
||||
}
|
||||
|
||||
private async removePathSilently(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.rm(targetPath, { recursive: true, force: true, maxRetries: 4 });
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to remove: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTempDir(): string {
|
||||
return `/tmp/wpp_temp_${this.sessionName}`;
|
||||
}
|
||||
|
||||
private getZipPath(): string {
|
||||
return `/tmp/wpp_session_${this.sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MongoStore } from '@waha/core/storage/mongo/MongoStore';
|
||||
import { Db, GridFSBucket, GridFSFile } from 'mongodb';
|
||||
import { Logger } from 'pino';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
const BUCKET_NAME = 'auth';
|
||||
|
||||
export class WPPMongoStore implements WPPStore {
|
||||
private readonly db: Db;
|
||||
private readonly bucket: GridFSBucket;
|
||||
|
||||
constructor(
|
||||
store: MongoStore,
|
||||
sessionName: string,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.db = store.getSessionDb(sessionName);
|
||||
this.bucket = new GridFSBucket(this.db, { bucketName: BUCKET_NAME });
|
||||
}
|
||||
|
||||
async sessionExists(sessionName: string): Promise<boolean> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const count = await this.db
|
||||
.collection(`${BUCKET_NAME}.files`)
|
||||
.countDocuments({ filename: filename });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async load(sessionName: string): Promise<Buffer | null> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const downloadStream = this.bucket.openDownloadStreamByName(filename);
|
||||
for await (const chunk of downloadStream) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async save(sessionName: string, data: Buffer): Promise<void> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const readable = Readable.from(data);
|
||||
const uploadStream = this.bucket.openUploadStream(filename);
|
||||
await pipeline(readable, uploadStream);
|
||||
await this.deletePrevious(filename);
|
||||
}
|
||||
|
||||
async delete(sessionName: string): Promise<void> {
|
||||
const filename = this.getFilename(sessionName);
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
await Promise.all(files.map((f: GridFSFile) => this.bucket.delete(f._id)));
|
||||
}
|
||||
|
||||
private async deletePrevious(filename: string): Promise<void> {
|
||||
const files = await this.bucket.find({ filename: filename }).toArray();
|
||||
if (files.length <= 1) {
|
||||
return;
|
||||
}
|
||||
files.sort(
|
||||
(a: GridFSFile, b: GridFSFile) =>
|
||||
a.uploadDate.getTime() - b.uploadDate.getTime(),
|
||||
);
|
||||
// Keep the last one, delete the rest
|
||||
const toDelete = files.slice(0, files.length - 1);
|
||||
await Promise.all(
|
||||
toDelete.map((f: GridFSFile) => this.bucket.delete(f._id)),
|
||||
);
|
||||
}
|
||||
|
||||
private getFilename(sessionName: string): string {
|
||||
return `${sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PsqlFileRepository } from '@waha/core/storage/psql/PsqlFileRepository';
|
||||
import { PsqlStore } from '@waha/core/storage/psql/PsqlStore';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { WPPStore } from './WPPStore';
|
||||
|
||||
class WPPFileRepository extends PsqlFileRepository {
|
||||
get tableName() {
|
||||
return 'files';
|
||||
}
|
||||
}
|
||||
|
||||
export class WPPPsqlStore implements WPPStore {
|
||||
private readonly repository: WPPFileRepository;
|
||||
|
||||
constructor(
|
||||
knex: Knex.Knex,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.repository = new WPPFileRepository(knex, logger);
|
||||
}
|
||||
|
||||
static async build(
|
||||
store: PsqlStore,
|
||||
sessionName: string,
|
||||
logger: Logger,
|
||||
): Promise<WPPPsqlStore> {
|
||||
const knex = store.buildSessionKnex(sessionName, 'Session/Auth');
|
||||
const instance = new WPPPsqlStore(knex, logger);
|
||||
await instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
async sessionExists(sessionName: string): Promise<boolean> {
|
||||
return this.repository.exists(this.getFilename(sessionName));
|
||||
}
|
||||
|
||||
async load(sessionName: string): Promise<Buffer | null> {
|
||||
const data = await this.repository.fetch(this.getFilename(sessionName));
|
||||
return data ? data.content : null;
|
||||
}
|
||||
|
||||
async save(sessionName: string, data: Buffer): Promise<void> {
|
||||
await this.repository.save(this.getFilename(sessionName), data);
|
||||
}
|
||||
|
||||
async delete(sessionName: string): Promise<void> {
|
||||
await this.repository.delete(this.getFilename(sessionName));
|
||||
}
|
||||
|
||||
private getFilename(sessionName: string): string {
|
||||
return `${sessionName}.zip`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface WPPStore {
|
||||
sessionExists(sessionName: string): Promise<boolean>;
|
||||
|
||||
load(sessionName: string): Promise<Buffer | null>;
|
||||
|
||||
save(sessionName: string, data: Buffer): Promise<void>;
|
||||
|
||||
delete(sessionName: string): Promise<void>;
|
||||
}
|
||||
@@ -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,11 +128,9 @@ 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';
|
||||
import { QR } from '@waha/core/QR';
|
||||
import { removeSingletonFiles } from '@waha/core/utils/chrome';
|
||||
@@ -167,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 {
|
||||
@@ -193,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>();
|
||||
@@ -217,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;
|
||||
@@ -623,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()
|
||||
@@ -2118,7 +2340,8 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
|
||||
}
|
||||
|
||||
protected async downloadMedia(message: any): Promise<WAMedia | null> {
|
||||
const processor = new WPPEngineMediaProcessor(this.wpp);
|
||||
let processor = new WPPEngineMediaProcessor(this.wpp);
|
||||
processor = new LottieMediaProcessorWrapper(processor, this.logger);
|
||||
return this.mediaManager.processMedia(processor, message, this.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
HealthCheckError,
|
||||
HealthIndicator,
|
||||
HealthIndicatorResult,
|
||||
} from '@nestjs/terminus';
|
||||
import checkDiskSpace from 'check-disk-space';
|
||||
|
||||
export interface CheckFreeDiskSpaceOptions {
|
||||
path: string;
|
||||
thresholdBytes: number;
|
||||
}
|
||||
|
||||
export class CheckFreeDiskSpaceIndicator extends HealthIndicator {
|
||||
public async check(
|
||||
key: string,
|
||||
options: CheckFreeDiskSpaceOptions,
|
||||
): Promise<HealthIndicatorResult> {
|
||||
const diskSpace = await checkDiskSpace(options.path);
|
||||
if (diskSpace.free < options.thresholdBytes) {
|
||||
const result = super.getStatus(key, false, {
|
||||
path: options.path,
|
||||
diskPath: diskSpace.diskPath,
|
||||
free: diskSpace.free,
|
||||
threshold: options.thresholdBytes,
|
||||
});
|
||||
throw new HealthCheckError('Disk space is low', result);
|
||||
}
|
||||
return super.getStatus(key, true, {
|
||||
path: options.path,
|
||||
diskPath: diskSpace.diskPath,
|
||||
free: diskSpace.free,
|
||||
threshold: options.thresholdBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
HealthCheckError,
|
||||
HealthIndicator,
|
||||
HealthIndicatorResult,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
import { promiseTimeout } from '../../utils/promiseTimeout';
|
||||
import { MongoStore } from '../storage/mongo/MongoStore';
|
||||
|
||||
export interface MongoStoreHealthIndicatorOptions {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export class MongoStoreHealthIndicator extends HealthIndicator {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private async ping(mongoStore: MongoStore) {
|
||||
await mongoStore.command({ ismaster: 1 });
|
||||
}
|
||||
|
||||
public async check(
|
||||
key: string,
|
||||
mongo: MongoStore,
|
||||
options: MongoStoreHealthIndicatorOptions,
|
||||
): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
await promiseTimeout(options.timeout, this.ping(mongo));
|
||||
return super.getStatus(key, true, { message: 'Up and running' });
|
||||
} catch (e) {
|
||||
const result = super.getStatus(key, false, { error: 'Timeout' });
|
||||
throw new HealthCheckError('Timeout happened', result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HealthCheckResult } from '@nestjs/terminus';
|
||||
import { HealthCheckResult, HealthCheckService } from '@nestjs/terminus';
|
||||
import type { HealthIndicatorFunction } from '@nestjs/terminus/dist/health-indicator';
|
||||
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
|
||||
import * as path from 'path';
|
||||
|
||||
import { WhatsappConfigService } from '../../config.service';
|
||||
import { SessionManager } from '../abc/manager.abc';
|
||||
import { WAHAHealthCheckService } from '../abc/WAHAHealthCheckService';
|
||||
import { AvailableInPlusVersion } from '../exceptions';
|
||||
import { LocalStore } from '../storage/LocalStore';
|
||||
import { MongoStore } from '../storage/mongo/MongoStore';
|
||||
import { CheckFreeDiskSpaceIndicator } from './CheckFreeDiskSpaceIndicator';
|
||||
import { MongoStoreHealthIndicator } from './MongoStoreHealthIndicator';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class WAHAHealthCheckServiceCore extends WAHAHealthCheckService {
|
||||
constructor(
|
||||
protected sessionManager: SessionManager,
|
||||
protected health: HealthCheckService,
|
||||
protected config: WhatsappConfigService,
|
||||
protected mongoStoreHealthIndicator: MongoStoreHealthIndicator,
|
||||
protected checkFreeDiskSpaceIndicator: CheckFreeDiskSpaceIndicator,
|
||||
protected mediaLocalStorageConfig: MediaLocalStorageConfig,
|
||||
) {
|
||||
super(sessionManager, health, config);
|
||||
}
|
||||
|
||||
check(): Promise<HealthCheckResult> {
|
||||
throw new AvailableInPlusVersion();
|
||||
const indicators = this.getIndicators();
|
||||
return this.health.check(indicators);
|
||||
}
|
||||
|
||||
getIndicators(): HealthIndicatorFunction[] {
|
||||
const indicators = [
|
||||
() =>
|
||||
this.checkFreeDiskSpaceIndicator.check('mediaFiles.space', {
|
||||
path: path.resolve(this.mediaLocalStorageConfig.filesFolder),
|
||||
thresholdBytes: this.config.getHealthMediaFilesThreshold() * MB,
|
||||
}),
|
||||
];
|
||||
if (this.sessionManager.store instanceof LocalStore) {
|
||||
indicators.push(() =>
|
||||
this.checkFreeDiskSpaceIndicator.check('sessionsFiles.space', {
|
||||
path: path.resolve(this.sessionManager.store.getBaseDirectory()),
|
||||
thresholdBytes: this.config.getHealthSessionFilesThreshold() * MB,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.sessionManager.store instanceof MongoStore) {
|
||||
indicators.push(() =>
|
||||
this.mongoStoreHealthIndicator.check(
|
||||
'mongodb',
|
||||
this.sessionManager.store,
|
||||
{ timeout: this.config.getHealthMongoTimeout() },
|
||||
),
|
||||
);
|
||||
}
|
||||
return indicators;
|
||||
}
|
||||
}
|
||||
+389
-199
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { convertLottieZipToWebp } from '@waha/core/utils/lottie-converter';
|
||||
|
||||
const LOTTIE_MIMETYPE = 'application/was';
|
||||
|
||||
/**
|
||||
* Wraps any engine media processor to transparently convert Lottie stickers
|
||||
* (mimetype `application/was`, delivered as a ZIP archive) into animated WebP.
|
||||
* The inner processor downloads the raw ZIP; this wrapper converts it before
|
||||
* the result reaches the media storage layer, so callers see `image/webp`.
|
||||
*/
|
||||
export class LottieMediaProcessorWrapper implements IMediaEngineProcessor<any> {
|
||||
constructor(
|
||||
private inner: IMediaEngineProcessor<any>,
|
||||
private logger: any,
|
||||
) {}
|
||||
|
||||
hasMedia(msg: any): boolean {
|
||||
return this.inner.hasMedia(msg);
|
||||
}
|
||||
|
||||
getChatId(msg: any): string {
|
||||
return this.inner.getChatId(msg);
|
||||
}
|
||||
|
||||
getMessageId(msg: any): string {
|
||||
return this.inner.getMessageId(msg);
|
||||
}
|
||||
|
||||
getMimetype(msg: any): string {
|
||||
const mime = this.inner.getMimetype(msg);
|
||||
return mime === LOTTIE_MIMETYPE ? 'image/webp' : mime;
|
||||
}
|
||||
|
||||
getFilename(msg: any): string | null {
|
||||
if (this.inner.getMimetype(msg) !== LOTTIE_MIMETYPE) {
|
||||
return this.inner.getFilename(msg);
|
||||
}
|
||||
return `${this.inner.getMessageId(msg)}.webp`;
|
||||
}
|
||||
|
||||
async getMediaBuffer(msg: any): Promise<Buffer | null> {
|
||||
const buffer = await this.inner.getMediaBuffer(msg);
|
||||
if (!buffer || !buffer.length) {
|
||||
return null;
|
||||
}
|
||||
if (this.inner.getMimetype(msg) !== LOTTIE_MIMETYPE) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const id = this.inner.getMessageId(msg);
|
||||
this.logger.info(
|
||||
`Converting Lottie sticker '${id}' to WebP (input ${buffer.length} bytes)...`,
|
||||
);
|
||||
try {
|
||||
const webp = await convertLottieZipToWebp(buffer);
|
||||
this.logger.info(
|
||||
`Lottie sticker '${id}' converted to WebP (${webp.byteLength} bytes)`,
|
||||
);
|
||||
return webp;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
{ err: err },
|
||||
`Lottie conversion failed for '${id}', returning original buffer`,
|
||||
);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createCanvas, Canvas } from '@napi-rs/canvas';
|
||||
import { DotLottie } from '@lottiefiles/dotlottie-web';
|
||||
// sharp, adm-zip and node-webpmux are CJS modules whose module.exports is the
|
||||
// callable/constructor itself — static default imports resolve to undefined at
|
||||
// runtime because there is no .default property. require() is the correct interop.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const sharp = require('sharp');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const AdmZip = require('adm-zip');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Image: WebpImage } = require('node-webpmux');
|
||||
|
||||
// DotLottie._draw() guards canvas rendering behind:
|
||||
// canvas instanceof HTMLCanvasElement || canvas instanceof OffscreenCanvas
|
||||
// Both are undefined in Node.js, so the guard fails and _draw() returns without
|
||||
// calling putImageData – producing a fully black canvas. Registering the
|
||||
// @napi-rs/canvas Canvas constructor as HTMLCanvasElement makes the instanceof
|
||||
// check pass so DotLottie acquires the 2D context and paints frames normally.
|
||||
//
|
||||
// ImageData must NOT be registered globally. When it is, DotLottie uses:
|
||||
// new ImageData(Uint8ClampedArray_view_into_WASM_memory, w, h)
|
||||
// @napi-rs/canvas copies the bytes once at construction and never re-reads the
|
||||
// WASM buffer, so every frame produces the same cached pixels. Without a global
|
||||
// ImageData, DotLottie falls back to:
|
||||
// ctx.createImageData(w, h) + imageData.data.set(currentPixelBuffer)
|
||||
// which copies fresh pixel data from the WASM buffer on every frame.
|
||||
const globalAny = global as Record<string, unknown>;
|
||||
if (!globalAny['HTMLCanvasElement']) {
|
||||
globalAny['HTMLCanvasElement'] = Canvas;
|
||||
}
|
||||
|
||||
export class LottieConvertError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message);
|
||||
this.name = 'LottieConvertError';
|
||||
if (options?.cause !== undefined) {
|
||||
(this as any).cause = options.cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STICKER_SIZE = 256;
|
||||
const envDelayMs = parseInt(
|
||||
process.env['WAHA_LOTTIE_DEFAULT_DELAY_MS'] ?? '',
|
||||
10,
|
||||
);
|
||||
const DEFAULT_DELAY_MS = envDelayMs > 0 ? envDelayMs : 100;
|
||||
|
||||
function isZipBuffer(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 4 &&
|
||||
buffer[0] === 0x50 &&
|
||||
buffer[1] === 0x4b &&
|
||||
buffer[2] === 0x03 &&
|
||||
buffer[3] === 0x04
|
||||
);
|
||||
}
|
||||
|
||||
function extractAnimationJson(zipBuffer: Buffer): string | null {
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
const entry =
|
||||
zip.getEntry('animation/animation.json') ??
|
||||
zip.getEntries().find((e) => e.entryName.endsWith('.json'));
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return zip.readAsText(entry);
|
||||
}
|
||||
|
||||
export async function convertLottieZipToWebp(
|
||||
zipBuffer: Buffer,
|
||||
): Promise<Buffer> {
|
||||
if (!isZipBuffer(zipBuffer)) {
|
||||
throw new LottieConvertError('Lottie buffer is not a valid ZIP');
|
||||
}
|
||||
|
||||
let animationJson: string | null;
|
||||
try {
|
||||
animationJson = extractAnimationJson(zipBuffer);
|
||||
} catch (err) {
|
||||
throw new LottieConvertError(`Failed to unzip Lottie file: ${err}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if (!animationJson) {
|
||||
throw new LottieConvertError('Lottie ZIP has no animation.json entry');
|
||||
}
|
||||
|
||||
let animationData: Record<string, unknown>;
|
||||
try {
|
||||
animationData = JSON.parse(animationJson);
|
||||
} catch (err) {
|
||||
throw new LottieConvertError(
|
||||
`Lottie animation.json is not valid JSON: ${err}`,
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return renderLottieToWebp(animationData);
|
||||
}
|
||||
|
||||
function renderLottieToWebp(
|
||||
animationData: Record<string, unknown>,
|
||||
): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
function finish(result: Buffer) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function fail(reason: unknown) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
reason instanceof LottieConvertError
|
||||
? reason
|
||||
: new LottieConvertError(String(reason), { cause: reason }),
|
||||
);
|
||||
}
|
||||
|
||||
let canvas: Canvas;
|
||||
let dotLottie: DotLottie;
|
||||
|
||||
try {
|
||||
canvas = createCanvas(STICKER_SIZE, STICKER_SIZE);
|
||||
} catch (err) {
|
||||
return fail(err);
|
||||
}
|
||||
|
||||
// DotLottie dispatches 'load' via setTimeout. Instead of relying on the
|
||||
// setImmediate-based animation loop (timing-dependent, unreliable for
|
||||
// frame capture), we drive frames manually with setFrame(i) after load.
|
||||
// setFrame() renders synchronously: set_frame → render → putImageData.
|
||||
try {
|
||||
dotLottie = new DotLottie({
|
||||
loop: false,
|
||||
useFrameInterpolation: false,
|
||||
autoplay: false,
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
data: animationData,
|
||||
renderConfig: {
|
||||
autoResize: false,
|
||||
devicePixelRatio: 1,
|
||||
freezeOnOffscreen: false,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return fail(err);
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
if (settled) return;
|
||||
|
||||
const totalFrames = dotLottie.totalFrames;
|
||||
const duration = dotLottie.duration;
|
||||
let delayMs: number;
|
||||
let framePixels: Buffer[];
|
||||
|
||||
try {
|
||||
if (totalFrames <= 0) {
|
||||
throw new Error(
|
||||
`Animation has no frames (totalFrames=${totalFrames})`,
|
||||
);
|
||||
}
|
||||
|
||||
let rawDelayMs = DEFAULT_DELAY_MS;
|
||||
if (duration > 0 && isFinite(duration)) {
|
||||
const fps = totalFrames / duration;
|
||||
const computed = Math.round(1000 / fps);
|
||||
if (computed > 0 && isFinite(computed)) {
|
||||
rawDelayMs = computed;
|
||||
}
|
||||
}
|
||||
// WebP delay is in milliseconds. Clamp to at least 30ms to stay above
|
||||
// the ~20ms threshold where some players impose their own minimum.
|
||||
// When clamping, subsample frames proportionally so total duration is
|
||||
// preserved — e.g. a 60fps animation must not play 1.8× too slowly
|
||||
// because all 180 frames are kept at 30ms each.
|
||||
delayMs = Math.max(30, rawDelayMs);
|
||||
const frameStep = delayMs / rawDelayMs;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('canvas.getContext("2d") returned null');
|
||||
}
|
||||
|
||||
// Collect raw RGBA pixels. When frameStep > 1 we subsample frames to
|
||||
// keep playback speed correct despite the minimum-delay clamp.
|
||||
// Pass the float position to setFrame so the renderer interpolates
|
||||
// smoothly along the animation curve rather than snapping to the
|
||||
// nearest integer frame (which causes uneven motion).
|
||||
framePixels = [];
|
||||
for (let t = 0; t < totalFrames; t += frameStep) {
|
||||
// setFrame(0) is a no-op (already rendered after load), so skip it.
|
||||
if (t > 0) {
|
||||
dotLottie.setFrame(t);
|
||||
}
|
||||
framePixels.push(
|
||||
Buffer.from(
|
||||
ctx.getImageData(0, 0, STICKER_SIZE, STICKER_SIZE).data,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
dotLottie.destroy();
|
||||
fail(err);
|
||||
return;
|
||||
}
|
||||
|
||||
dotLottie.destroy();
|
||||
|
||||
// Encode each RGBA frame as a single-frame WebP, then assemble into an
|
||||
// animated WebP via node-webpmux. This preserves full alpha transparency
|
||||
// and avoids GIF's 256-colour palette limitation.
|
||||
if (framePixels.length === 1) {
|
||||
sharp(framePixels[0], {
|
||||
raw: { width: STICKER_SIZE, height: STICKER_SIZE, channels: 4 },
|
||||
})
|
||||
.webp({ quality: 80, effort: 3 })
|
||||
.toBuffer()
|
||||
.then(finish)
|
||||
.catch(fail);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(
|
||||
framePixels.map((px) =>
|
||||
sharp(px, {
|
||||
raw: { width: STICKER_SIZE, height: STICKER_SIZE, channels: 4 },
|
||||
})
|
||||
.webp({ quality: 80, effort: 3 })
|
||||
.toBuffer(),
|
||||
),
|
||||
)
|
||||
.then((webpFrames) =>
|
||||
Promise.all(
|
||||
webpFrames.map((buf) =>
|
||||
WebpImage.generateFrame({
|
||||
buffer: buf,
|
||||
delay: delayMs,
|
||||
x: 0,
|
||||
y: 0,
|
||||
blend: false,
|
||||
dispose: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((frames) =>
|
||||
WebpImage.save(null, {
|
||||
width: STICKER_SIZE,
|
||||
height: STICKER_SIZE,
|
||||
bgColor: [0, 0, 0, 0],
|
||||
loops: 0,
|
||||
frames: frames,
|
||||
}),
|
||||
)
|
||||
.then(finish)
|
||||
.catch(fail);
|
||||
}
|
||||
|
||||
function onLoadError(payload: any) {
|
||||
if (settled) return;
|
||||
dotLottie.destroy();
|
||||
fail(
|
||||
new LottieConvertError(
|
||||
`DotLottie failed to load animation: ${
|
||||
payload?.error ?? 'unknown error'
|
||||
}`,
|
||||
{ cause: payload?.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
dotLottie.addEventListener('load', onLoad);
|
||||
dotLottie.addEventListener('loadError', onLoadError);
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,19 @@ export function IsEditedMessage(message: proto.IMessage): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function IsSecretEncryptedMessageEdit(
|
||||
message: proto.IMessage | null | undefined,
|
||||
): boolean {
|
||||
const sem = message?.secretEncryptedMessage;
|
||||
if (!sem) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
sem.secretEncType ===
|
||||
proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT
|
||||
);
|
||||
}
|
||||
|
||||
export function IsHistorySyncNotification(message: proto.IMessage): boolean {
|
||||
message = normalizeMessageContent(message);
|
||||
if (!message) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Decrypt secret-encrypted message edits (secretEncType MESSAGE_EDIT).
|
||||
*
|
||||
* Mirrors whatsmeow's generateMsgSecretKey + gcmutil.Decrypt for EncSecretMessageEdit.
|
||||
* @see https://github.com/tulir/whatsmeow/blob/main/msgsecret.go
|
||||
*
|
||||
* Key derivation uses Baileys' own hkdf (whatsapp-rust-bridge), which passes nil/undefined
|
||||
* salt = 32-byte zeros, matching Go's golang.org/x/crypto/hkdf with nil salt.
|
||||
*/
|
||||
import { aesDecryptGCM, hkdf, proto } from '@adiwajshing/baileys';
|
||||
|
||||
const ENC_SECRET_MESSAGE_EDIT = 'Message Edit';
|
||||
|
||||
export function jidToNonAD(jid: string): string {
|
||||
if (!jid || !jid.includes('@')) {
|
||||
return jid;
|
||||
}
|
||||
const [user, server] = jid.split('@', 2);
|
||||
return `${user.split(':')[0]}@${server || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors whatsmeow getOrigSenderFromKey — resolves the JID used as the
|
||||
* "origMsgSender" slot in the HKDF info block.
|
||||
*/
|
||||
export function getOrigSenderJidForMsgSecret(
|
||||
editMessageInfo: { Chat?: string; Sender?: string },
|
||||
targetMessageKey: {
|
||||
fromMe?: boolean;
|
||||
FromMe?: boolean;
|
||||
remoteJID?: string;
|
||||
RemoteJID?: string;
|
||||
participant?: string;
|
||||
Participant?: string;
|
||||
},
|
||||
): string {
|
||||
const chat = editMessageInfo.Chat || '';
|
||||
const server = chat.includes('@') ? chat.split('@')[1] : '';
|
||||
const sender = editMessageInfo.Sender || '';
|
||||
|
||||
const fromMe =
|
||||
targetMessageKey.fromMe === true || targetMessageKey.FromMe === true;
|
||||
const remoteJid =
|
||||
targetMessageKey.remoteJID || targetMessageKey.RemoteJID || '';
|
||||
const participant =
|
||||
targetMessageKey.participant || targetMessageKey.Participant || '';
|
||||
|
||||
if (fromMe) {
|
||||
return jidToNonAD(sender);
|
||||
}
|
||||
if (server === 's.whatsapp.net' || server === 'lid') {
|
||||
return jidToNonAD(remoteJid);
|
||||
}
|
||||
return jidToNonAD(participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the AES-256-GCM key for a MESSAGE_EDIT secret-encrypted payload.
|
||||
*
|
||||
* Matches whatsmeow's generateMsgSecretKey:
|
||||
* useCaseSecret = msgId + origSenderJid + modSenderJid + "Message Edit"
|
||||
* key = HKDF-SHA256(ikm=origMsgSecret, salt=nil→32×0, info=useCaseSecret)
|
||||
*/
|
||||
export function generateMsgSecretKeyForMessageEdit(
|
||||
origMsgId: string,
|
||||
origSenderJid: string,
|
||||
modificationSenderJid: string,
|
||||
origMsgSecret: Uint8Array,
|
||||
): Buffer {
|
||||
const useCaseInfo = Buffer.concat([
|
||||
Buffer.from(origMsgId, 'utf8'),
|
||||
Buffer.from(jidToNonAD(origSenderJid), 'utf8'),
|
||||
Buffer.from(jidToNonAD(modificationSenderJid), 'utf8'),
|
||||
Buffer.from(ENC_SECRET_MESSAGE_EDIT, 'utf8'),
|
||||
]);
|
||||
// No salt → Rust bridge uses 32 zero bytes, matching whatsmeow nil salt.
|
||||
// info must be passed as latin1 string so each byte maps 1:1 (same as binary concat).
|
||||
return Buffer.from(
|
||||
hkdf(origMsgSecret, 32, { info: useCaseInfo.toString('latin1') }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a secretEncryptedMessage (MESSAGE_EDIT) payload and decodes the
|
||||
* resulting proto.Message. Throws on AES-GCM auth failure or proto decode error.
|
||||
*/
|
||||
export function decryptSecretEncryptedMessageEditProto(params: {
|
||||
encPayload: Uint8Array;
|
||||
encIv: Uint8Array;
|
||||
origMsgId: string;
|
||||
origSenderJid: string;
|
||||
modificationSenderJid: string;
|
||||
origMsgSecret: Uint8Array;
|
||||
}): proto.Message {
|
||||
const {
|
||||
encPayload,
|
||||
encIv,
|
||||
origMsgId,
|
||||
origSenderJid,
|
||||
modificationSenderJid,
|
||||
origMsgSecret,
|
||||
} = params;
|
||||
const secretKey = generateMsgSecretKeyForMessageEdit(
|
||||
origMsgId,
|
||||
origSenderJid,
|
||||
modificationSenderJid,
|
||||
origMsgSecret,
|
||||
);
|
||||
// MESSAGE_EDIT uses an empty additionalData (unlike Poll Vote which uses msgId+voterJid)
|
||||
const plaintext = aesDecryptGCM(
|
||||
encPayload,
|
||||
secretKey,
|
||||
encIv,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
return proto.Message.decode(plaintext);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDefined,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -265,13 +266,15 @@ export class OverviewFilter {
|
||||
}
|
||||
|
||||
export class OverviewBodyRequest {
|
||||
@IsDefined()
|
||||
@ValidateNested()
|
||||
@Type(() => GetChatsOverviewParams)
|
||||
pagination: GetChatsOverviewParams;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OverviewFilter)
|
||||
filter: OverviewFilter;
|
||||
filter?: OverviewFilter;
|
||||
}
|
||||
|
||||
export class ChatSummary {
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ function getPlatform() {
|
||||
}
|
||||
|
||||
export const VERSION: WAHAEnvironment = {
|
||||
version: '2026.4.3',
|
||||
version: '2026.6.1',
|
||||
engine: getEngineName(),
|
||||
tier: getWAHAVersion(),
|
||||
browser: getBrowser(),
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
"waha": {
|
||||
"gows": {
|
||||
"repo": "devlikeapro/gows",
|
||||
"ref": "v1.0.38"
|
||||
"ref": "v1.0.41"
|
||||
},
|
||||
"dashboard": {
|
||||
"repo": "devlikeapro/dashboard",
|
||||
"ref": "35c218fc9d365552ac356a07103f61205acee0d3"
|
||||
"ref": "47244c7d9202e0f4c1b23d12d45d5c208cd1184f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,19 @@ __metadata:
|
||||
cacheKey: 10
|
||||
|
||||
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-04-28":
|
||||
version: 7.0.0-rc10
|
||||
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=a56f2c88eac278b589101278a2d5e8f343fd0321"
|
||||
version: 7.0.0-rc13
|
||||
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=f619b7e841450140e444a0e93ff3f04ab50519b6"
|
||||
dependencies:
|
||||
"@cacheable/node-cache": "npm:^1.4.0"
|
||||
"@hapi/boom": "npm:^9.1.3"
|
||||
async-mutex: "npm:^0.5.0"
|
||||
libsignal: "git+https://github.com/whiskeysockets/libsignal-node"
|
||||
libsignal: "npm:^6.0.0"
|
||||
lru-cache: "npm:^11.1.0"
|
||||
music-metadata: "npm:^11.12.3"
|
||||
p-queue: "npm:^9.0.0"
|
||||
pino: "npm:^9.6"
|
||||
protobufjs: "npm:^7.5.6"
|
||||
whatsapp-rust-bridge: "npm:0.5.3"
|
||||
whatsapp-rust-bridge: "npm:0.5.4"
|
||||
ws: "npm:^8.13.0"
|
||||
peerDependencies:
|
||||
audio-decode: ^2.1.3
|
||||
@@ -32,7 +32,7 @@ __metadata:
|
||||
optional: true
|
||||
link-preview-js:
|
||||
optional: true
|
||||
checksum: 10/a6eb9438f1d32ccad2d46c8ee84e8e3c448c6ba43bd0d28f2b88f414bcbe46d5e1802a59a8a2866ed73c02e13ada98c35aa996a4545b9db02068a5ca8ad41116
|
||||
checksum: 10/535e52bf0b5bad381c985fcead24cb8c262a97662b9aaf3a4c0081c47ba02204e9f015e96dfb043f9d8e75295498e3e7468ee056f82f832af882a321c475d7bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2250,6 +2250,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lottiefiles/dotlottie-web@npm:^0.72.1":
|
||||
version: 0.72.1
|
||||
resolution: "@lottiefiles/dotlottie-web@npm:0.72.1"
|
||||
checksum: 10/79bec5037fb07f38a676d32a11597277c0b15c8ad9716dbde008fe79b2e4e0374aaefcffd9bb68672a8bb35d02b57af4ed01a857238166f8ed85786ad29756b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lukeed/csprng@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "@lukeed/csprng@npm:1.1.0"
|
||||
@@ -2367,6 +2374,129 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-android-arm64@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-android-arm64@npm:1.0.0"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-darwin-arm64@npm:1.0.0"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-darwin-x64@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-darwin-x64@npm:1.0.0"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:1.0.0"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:1.0.0"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-arm64-musl@npm:1.0.0"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:1.0.0"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-x64-gnu@npm:1.0.0"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-linux-x64-musl@npm:1.0.0"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-win32-arm64-msvc@npm:1.0.0"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas-win32-x64-msvc@npm:1.0.0"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/canvas@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@napi-rs/canvas@npm:1.0.0"
|
||||
dependencies:
|
||||
"@napi-rs/canvas-android-arm64": "npm:1.0.0"
|
||||
"@napi-rs/canvas-darwin-arm64": "npm:1.0.0"
|
||||
"@napi-rs/canvas-darwin-x64": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-arm64-musl": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-x64-gnu": "npm:1.0.0"
|
||||
"@napi-rs/canvas-linux-x64-musl": "npm:1.0.0"
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "npm:1.0.0"
|
||||
"@napi-rs/canvas-win32-x64-msvc": "npm:1.0.0"
|
||||
dependenciesMeta:
|
||||
"@napi-rs/canvas-android-arm64":
|
||||
optional: true
|
||||
"@napi-rs/canvas-darwin-arm64":
|
||||
optional: true
|
||||
"@napi-rs/canvas-darwin-x64":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-arm64-gnu":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-arm64-musl":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-riscv64-gnu":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-x64-gnu":
|
||||
optional: true
|
||||
"@napi-rs/canvas-linux-x64-musl":
|
||||
optional: true
|
||||
"@napi-rs/canvas-win32-arm64-msvc":
|
||||
optional: true
|
||||
"@napi-rs/canvas-win32-x64-msvc":
|
||||
optional: true
|
||||
canvas:
|
||||
built: true
|
||||
skia-canvas:
|
||||
built: true
|
||||
checksum: 10/1b2838833988c98d86105980ccd52fb53c4059e30732c7bebdf21774d83a351bf389337c8e6eb4d55fc31359accb90e27d8d80c94cc3bb08d415ab0586227464
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nestjs/axios@npm:^3.0.2":
|
||||
version: 3.1.3
|
||||
resolution: "@nestjs/axios@npm:3.1.3"
|
||||
@@ -2900,6 +3030,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/eventemitter@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@protobufjs/eventemitter@npm:1.1.1"
|
||||
checksum: 10/a54dc1aff4475ffad4fdf3235c71a553f5e40e3b4cf6a2e217151895a61cb4eb0be20d63791db22441ca25e594671f1021977133f9939540750231ff7d8e9dd6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/fetch@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@protobufjs/fetch@npm:1.1.0"
|
||||
@@ -2910,6 +3047,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/fetch@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@protobufjs/fetch@npm:1.1.1"
|
||||
dependencies:
|
||||
"@protobufjs/aspromise": "npm:^1.1.1"
|
||||
checksum: 10/427cf2da8c69b494b0df3b2fb1f43c97f0f71ca2c8ef8232dac7e44f2527ad0cc9cecb243eda14a918e86018bfa6d54d92252240d2b37ed205b13adb5506fa1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/float@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@protobufjs/float@npm:1.0.2"
|
||||
@@ -2931,6 +3077,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/inquire@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "@protobufjs/inquire@npm:1.1.2"
|
||||
checksum: 10/259756489c75a751552df60d18f82503d2534855646397b96b91cf15807fa852e99bd9eb73dabb64da37aec7913844032ecb031a4326d82aae622f5e4c2f8a17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@protobufjs/path@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "@protobufjs/path@npm:1.1.2"
|
||||
@@ -4464,15 +4617,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@wppconnect-team/wppconnect@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "@wppconnect-team/wppconnect@npm:2.0.2"
|
||||
"@wppconnect-team/wppconnect@npm:^2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "@wppconnect-team/wppconnect@npm:2.2.1"
|
||||
dependencies:
|
||||
"@img/sharp-win32-x64": "npm:^0.34.5"
|
||||
"@wppconnect/wa-js": "npm:^4.1.0"
|
||||
"@wppconnect/wa-version": "npm:^1.5.3829"
|
||||
"@wppconnect/wa-js": "npm:^4.3.0"
|
||||
"@wppconnect/wa-version": "npm:^1.5.3941"
|
||||
atob: "npm:^2.1.2"
|
||||
axios: "npm:^1.16.0"
|
||||
axios: "npm:^1.16.1"
|
||||
boxen: "npm:^5.1.2"
|
||||
catch-exit: "npm:^2.0.0"
|
||||
chalk: "npm:~4.1.2"
|
||||
@@ -4485,7 +4638,7 @@ __metadata:
|
||||
logform: "npm:^2.7.0"
|
||||
lookpath: "npm:^1.2.3"
|
||||
mime-types: "npm:^3.0.2"
|
||||
puppeteer: "npm:^24.37.5"
|
||||
puppeteer: "npm:^24.43.1"
|
||||
puppeteer-extra: "npm:^3.3.6"
|
||||
puppeteer-extra-plugin-stealth: "npm:^2.11.2"
|
||||
puppeteer-extra-plugin-user-data-dir: "npm:^2.4.1"
|
||||
@@ -4498,30 +4651,30 @@ __metadata:
|
||||
tmp: "npm:^0.2.5"
|
||||
tree-kill: "npm:^1.2.2"
|
||||
winston: "npm:^3.19.0"
|
||||
ws: "npm:^8.19.0"
|
||||
ws: "npm:^8.21.0"
|
||||
dependenciesMeta:
|
||||
"@img/sharp-win32-x64":
|
||||
optional: true
|
||||
fsevents:
|
||||
optional: true
|
||||
checksum: 10/b1e5d83174e5786d17ceceec0917d077428d8f3b15f4f9e0df667f23b350d2fd984f322e7fc69270751e55aa10a4747ec95c7f5a6c5d7a6048094520e0e0686d
|
||||
checksum: 10/d7940928c2874ddd0e2412b0628d2750c2457bf9b08a8840e79de24e8666f792e2e7a78c0bc16c13c4ab870e7d37683ad1b08522fb9ea7da90f695e5b21cc044
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@wppconnect/wa-js@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "@wppconnect/wa-js@npm:4.1.0"
|
||||
checksum: 10/e807b6f3b0bdd1db0400937fe24fbdd27414d09067c734e4a7fa852155be28cda57cf64a6b7547c7f9559bd483e933a321156d87e39579bb342d7bbdcf273c9d
|
||||
"@wppconnect/wa-js@npm:^4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "@wppconnect/wa-js@npm:4.3.0"
|
||||
checksum: 10/844e63b58b62d4057ab51f114b3f9b12e13637a026b6df50c5f53801181a3800065342c0f79478db10d43dfe96a6ec60bf9d8fab7840caaf94970a774572279d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@wppconnect/wa-version@npm:^1.5.3829":
|
||||
version: 1.5.3874
|
||||
resolution: "@wppconnect/wa-version@npm:1.5.3874"
|
||||
"@wppconnect/wa-version@npm:^1.5.3941":
|
||||
version: 1.5.4014
|
||||
resolution: "@wppconnect/wa-version@npm:1.5.4014"
|
||||
dependencies:
|
||||
node-fetch: "npm:^2.7.0"
|
||||
semver: "npm:^7.7.4"
|
||||
checksum: 10/37812da2cb3f63c93a6519251da3bc61f52fdd874842a9d31792430ff56e533e9d3f42feed9630bf1c77ec3a5e4d7347ffa4732d58e742d8463019f7770fb9c1
|
||||
checksum: 10/97916af06fc2d1817cb3122233577b39047c46aa607ba0a14ff8d0ba9decd62a1d0220fc658da97dea378f433cc4b3bae89960d25320ae456d905b687ba40ad3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9359,16 +9512,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libsignal@git+https://github.com/whiskeysockets/libsignal-node":
|
||||
version: 2.0.1
|
||||
resolution: "libsignal@https://github.com/whiskeysockets/libsignal-node.git#commit=e81ecfc32eb74951d789ab37f7e341ab66d5fff1"
|
||||
dependencies:
|
||||
curve25519-js: "npm:^0.0.4"
|
||||
protobufjs: "npm:6.8.8"
|
||||
checksum: 10/b3244ebb2f2580bda960180fcb91a20f47dfd68acc04ab3fff3163cd7205876307fb4a50af8830977d650c5719389a8acf547bf63fb722bcd3273e8fbdb94b95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libsignal@github:WhiskeySockets/libsignal-node#master":
|
||||
version: 2.0.1
|
||||
resolution: "libsignal@https://github.com/WhiskeySockets/libsignal-node.git#commit=e81ecfc32eb74951d789ab37f7e341ab66d5fff1"
|
||||
@@ -9379,6 +9522,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libsignal@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "libsignal@npm:6.0.0"
|
||||
dependencies:
|
||||
curve25519-js: "npm:^0.0.4"
|
||||
protobufjs: "npm:^7.5.5"
|
||||
checksum: 10/d2e2463f9bb630fe1c8bb2c6dd8d52312040dce8ab6acefd4a9c37a080156e22db52198d13fa4b5d16bc63ff4b01420f2748e6f5c05b1fd010316cf8bb69f5a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lighthouse-logger@npm:^1.0.0":
|
||||
version: 1.4.2
|
||||
resolution: "lighthouse-logger@npm:1.4.2"
|
||||
@@ -10498,6 +10651,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-webpmux@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "node-webpmux@npm:3.2.1"
|
||||
checksum: 10/8797ebe4846707443629800c70d95c592ab1c18f313b360dd26832b8443efa9b89966269ad6b1e3aa59a18e790406b29a55e19c10212fc3068dc476e48cee6e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nopt@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "nopt@npm:5.0.0"
|
||||
@@ -10597,6 +10757,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"obuf@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "obuf@npm:1.1.2"
|
||||
checksum: 10/53ff4ab3a13cc33ba6c856cf281f2965c0aec9720967af450e8fd06cfd50aceeefc791986a16bcefa14e7898b3ca9acdfcf15b9d9a1b9c7e1366581a8ad6e65e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ogg-opus-decoder@npm:^1.6.12":
|
||||
version: 1.7.0
|
||||
resolution: "ogg-opus-decoder@npm:1.7.0"
|
||||
@@ -11038,6 +11205,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pg-copy-streams@npm:^6.0.0":
|
||||
version: 6.0.6
|
||||
resolution: "pg-copy-streams@npm:6.0.6"
|
||||
dependencies:
|
||||
obuf: "npm:^1.1.2"
|
||||
checksum: 10/58bd4a44f618e0706de402b401bad42fbb3b3743985ac5891536a239331469db3be734352955259bfdf46d1b772604a8da493edd16f0399f3685fcb4517bf165
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pg-int8@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "pg-int8@npm:1.0.1"
|
||||
@@ -11440,6 +11616,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"protobufjs@npm:^7.5.5":
|
||||
version: 7.6.1
|
||||
resolution: "protobufjs@npm:7.6.1"
|
||||
dependencies:
|
||||
"@protobufjs/aspromise": "npm:^1.1.2"
|
||||
"@protobufjs/base64": "npm:^1.1.2"
|
||||
"@protobufjs/codegen": "npm:^2.0.5"
|
||||
"@protobufjs/eventemitter": "npm:^1.1.1"
|
||||
"@protobufjs/fetch": "npm:^1.1.1"
|
||||
"@protobufjs/float": "npm:^1.0.2"
|
||||
"@protobufjs/inquire": "npm:^1.1.2"
|
||||
"@protobufjs/path": "npm:^1.1.2"
|
||||
"@protobufjs/pool": "npm:^1.1.0"
|
||||
"@protobufjs/utf8": "npm:^1.1.1"
|
||||
"@types/node": "npm:>=13.7.0"
|
||||
long: "npm:^5.3.2"
|
||||
checksum: 10/010dcf77ab435da50d62887d57cc4b68384768e4d1cbe85948ec747996222ea659501b7c6b6d3426f96fdbaf59f577213788ce461c0938f89b8126569d8be7e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"protobufjs@npm:^7.5.6":
|
||||
version: 7.5.6
|
||||
resolution: "protobufjs@npm:7.5.6"
|
||||
@@ -13795,7 +13991,9 @@ __metadata:
|
||||
"@grpc/grpc-js": "npm:^1.14.1"
|
||||
"@grpc/proto-loader": "npm:^0.8.0"
|
||||
"@liaoliaots/nestjs-redis": "npm:^9"
|
||||
"@lottiefiles/dotlottie-web": "npm:^0.72.1"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.29.0"
|
||||
"@napi-rs/canvas": "npm:^1.0.0"
|
||||
"@nestjs/axios": "npm:^3.0.2"
|
||||
"@nestjs/bullmq": "npm:^11.0.2"
|
||||
"@nestjs/cli": "npm:^11.0.0"
|
||||
@@ -13824,8 +14022,8 @@ __metadata:
|
||||
"@types/supertest": "npm:^2.0.8"
|
||||
"@types/user-agents": "npm:^1"
|
||||
"@types/ws": "npm:^8.5.4"
|
||||
"@wppconnect-team/wppconnect": "npm:^2.0.2"
|
||||
"@wppconnect/wa-js": "npm:^4.1.0"
|
||||
"@wppconnect-team/wppconnect": "npm:^2.2.1"
|
||||
"@wppconnect/wa-js": "npm:^4.3.0"
|
||||
adm-zip: "npm:0.5.10"
|
||||
agentkeepalive: "npm:^4.5.0"
|
||||
async-lock: "npm:^1.4.1"
|
||||
@@ -13864,10 +14062,12 @@ __metadata:
|
||||
mustache: "npm:^4.2.0"
|
||||
nestjs-pino: "npm:^4.1.0"
|
||||
node-cache: "npm:5.1.2"
|
||||
node-webpmux: "npm:^3.2.1"
|
||||
oxlint: "npm:^1.13.0"
|
||||
passport: "npm:^0.7.0"
|
||||
passport-headerapikey: "npm:^1.2.2"
|
||||
pg: "npm:^8.13.1"
|
||||
pg-copy-streams: "npm:^6.0.0"
|
||||
pino-http: "npm:^10.2.0"
|
||||
pino-pretty: "npm:^11.2.1"
|
||||
prettier: "npm:^1.19.1"
|
||||
|
||||
Reference in new issue
Block a user