diff --git a/Docker/.env b/Docker/.env index 0de30fe3..b0b3ff2a 100644 --- a/Docker/.env +++ b/Docker/.env @@ -12,11 +12,17 @@ LOG_COLOR=true DEL_INSTANCE=5 # Temporary data storage -STORE_CLEANING_INTERVAL=7200 # seconds ===2h -STORE_MESSAGE=true +STORE_MESSAGES=true +STORE_MESSAGE_UP=true STORE_CONTACTS=false STORE_CHATS=false +CLEAN_STORE_CLEANING_INTERVAL=7200 # seconds ===2h +CLEAN_STORE_MESSAGES=true +CLEAN_STORE_MESSAGE_UP=true +CLEAN_STORE_CONTACTS=false +CLEAN_STORE_CHATS=false + # Permanent data storage DATABASE_ENABLED=false DATABASE_CONNECTION_URI='' diff --git a/Dockerfile b/Dockerfile index 84aed8c6..919fedf3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,11 +21,17 @@ ENV LOG_COLOR=true ENV DEL_INSTANCE=$DEL_INSTANCE -ENV STORE_CLEANING_INTERVAL=$STORE_CLEANING_INTERVAL -ENV STORE_MESSAGE=$STORE_MESSAGE +ENV STORE_MESSAGES=$STORE_MESSAGE +ENV STORE_MESSAGE_UP=$STORE_MESSAGE_UP ENV STORE_CONTACTS=$STORE_CONTACTS ENV STORE_CHATS=$STORE_CHATS +ENV CLEAN_STORE_CLEANING_INTERVAL=$CLEAN_STORE_CLEANING_INTERVAL +ENV CLEAN_STORE_MESSAGES=$CLEAN_STORE_MESSAGE +ENV CLEAN_STORE_MESSAGE_UP=$CLEAN_STORE_MESSAGE_UP +ENV CLEAN_STORE_CONTACTS=$CLEAN_STORE_CONTACTS +ENV CLEAN_STORE_CHATS=$CLEAN_STORE_CHATS + ENV DATABASE_ENABLED=$DATABASE_ENABLED ENV DATABASE_CONNECTION_URI=$DATABASE_CONNECTION_URI ENV DATABASE_CONNECTION_DB_PREFIX_NAME=$DATABASE_CONNECTION_DB_PREFIX_NAME diff --git a/docker-compose.yaml b/docker-compose.yaml index 1d6e5135..2eefb398 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,7 @@ services: - 8080:8080 volumes: - evolution_instances:/evolution/instances + - evolution_store:/evolution/store depends_on: - mongodb - redis @@ -21,10 +22,15 @@ services: # If you don't even want an expiration, enter the value false - DEL_INSTANCE=5 # or false # Temporary data storage - - STORE_CLEANING_INTERVAL=7200 # seconds === 2h - - STORE_MESSAGE=true + - STORE_MESSAGES=true + - STORE_MESSAGE_UP=true - STORE_CONTACTS=true - STORE_CHATS=true + - CLEAN_STORE_CLEANING_INTERVAL=7200 # seconds === 2h + - CLEAN_STORE_MESSAGES=true + - CLEAN_STORE_MESSAGE_UP=true + - CLEAN_STORE_CONTACTS=true + - CLEAN_STORE_CHATS=true # Permanent data storage - DATABASE_ENABLED=true - DATABASE_CONNECTION_URI=mongodb://root:root@mongodb:27017/?authSource=admin&readPreference=primary&ssl=false&directConnection=true @@ -131,6 +137,7 @@ services: volumes: evolution_instances: + evolution_store: evolution_mongodb_data: evolution_mongodb_configdb: evolution_redis: \ No newline at end of file diff --git a/src/config/env.config.ts b/src/config/env.config.ts index c54d6012..2517b4b2 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -29,8 +29,16 @@ export type SaveData = { }; export type StoreConf = { + MESSAGES: boolean; + MESSAGE_UP: boolean; + CONTACTS: boolean; + CHATS: boolean; +}; + +export type CleanStoreConf = { CLEANING_INTERVAL: number; MESSAGES: boolean; + MESSAGE_UP: boolean; CONTACTS: boolean; CHATS: boolean; }; @@ -107,6 +115,7 @@ export interface Env { CORS: Cors; SSL_CONF: SslConf; STORE: StoreConf; + CLEAN_STORE: CleanStoreConf; DATABASE: Database; REDIS: Redis; LOG: Log; @@ -160,13 +169,20 @@ export class ConfigService { FULLCHAIN: process.env?.SSL_CONF_FULLCHAIN, }, STORE: { - CLEANING_INTERVAL: Number.isInteger(process.env?.STORE_CLEANING_TERMINAL) - ? Number.parseInt(process.env.STORE_CLEANING_TERMINAL) - : undefined, - MESSAGES: process.env?.STORE_MESSAGE === 'true', + MESSAGES: process.env?.STORE_MESSAGES === 'true', + MESSAGE_UP: process.env?.STORE_MESSAGE_UP === 'true', CONTACTS: process.env?.STORE_CONTACTS === 'true', CHATS: process.env?.STORE_CHATS === 'true', }, + CLEAN_STORE: { + CLEANING_INTERVAL: Number.isInteger(process.env?.CLEAN_STORE_CLEANING_TERMINAL) + ? Number.parseInt(process.env.CLEAN_STORE_CLEANING_TERMINAL) + : undefined, + MESSAGES: process.env?.CLEAN_STORE_MESSAGES === 'true', + MESSAGE_UP: process.env?.CLEAN_STORE_MESSAGE_UP === 'true', + CONTACTS: process.env?.CLEAN_STORE_CONTACTS === 'true', + CHATS: process.env?.CLEAN_STORE_CHATS === 'true', + }, DATABASE: { CONNECTION: { URI: process.env.DATABASE_CONNECTION_URI, diff --git a/src/dev-env.yml b/src/dev-env.yml index b3e6f304..32ac8dd5 100644 --- a/src/dev-env.yml +++ b/src/dev-env.yml @@ -45,8 +45,15 @@ DEL_INSTANCE: false # or false # Temporary data storage STORE: - CLEANING_INTERVAL: 7200 # seconds === 2h - MESSAGE: true + MESSAGES: true + MESSAGE_UP: true + CONTACTS: true + CHATS: true + +CLEAN_STORE: + CLEANING_INTERVAL: 7200 # 7200 seconds === 2h + MESSAGES: true + MESSAGE_UP: true CONTACTS: true CHATS: true diff --git a/src/utils/poll-update-decrypt-message.ts b/src/utils/poll-update-decrypt-message.ts new file mode 100644 index 00000000..ab035e99 --- /dev/null +++ b/src/utils/poll-update-decrypt-message.ts @@ -0,0 +1,164 @@ +// Built around ShellTear's POC at #2215#issuecomment-1292885678 on @adiwajshing/baileys +// Copyright ~ purpshell + +import crypto from 'node:crypto'; + +const enc = new TextEncoder(); +/** + * Decrypt PollUpdate messages + */ +export class PollUpdateDecrypt { + /** + * Compare the SHA-256 hashes of the poll options from the update to find the original choices + * @param options Options from the poll creation message + * @param pollOptionHash hash from `this.decrypt()` + * @returns the original option, can be empty when none are currently selected + */ + static async compare(options: string[], pollOptionHashes: string[]): Promise { + const selectedOptions = []; + for (const option of options) { + const hash = Buffer.from( + await crypto.webcrypto.subtle.digest('SHA-256', new TextEncoder().encode(option)), + ) + .toString('hex') + .toUpperCase(); + for (const pollOptionHash of pollOptionHashes) { + if (pollOptionHash === hash) { + selectedOptions.push(option); + } + } + } + return selectedOptions; + } + + /** + * decrypt a poll message update + * @param encPayload from the update + * @param encIv from the update + * @param encKey from the original poll + * @param pollMsgSender sender jid of the pollCreation message + * @param pollMsgId id of the pollCreation message + * @param voteMsgSender sender of the pollUpdate message + * @returns The option or empty array if something went wrong OR everything was unticked + */ + static async decrypt( + encKey: Uint8Array, + encPayload: Uint8Array, + encIv: Uint8Array, + pollMsgSender: string, + pollMsgId: string, + voteMsgSender: string, + ): Promise { + const stanzaId = enc.encode(pollMsgId); + const parentMsgOriginalSender = enc.encode(pollMsgSender); + const modificationSender = enc.encode(voteMsgSender); + const modificationType = enc.encode('Poll Vote'); + const pad = new Uint8Array([1]); + + const signMe = new Uint8Array([ + ...stanzaId, + ...parentMsgOriginalSender, + ...modificationSender, + ...modificationType, + pad, + ] as any); + + const createSignKey = async (n: Uint8Array = new Uint8Array(32)) => { + return await crypto.webcrypto.subtle.importKey( + 'raw', + n, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + }; + + const sign = async ( + n: crypto.webcrypto.BufferSource, + key: crypto.webcrypto.CryptoKey, + ) => { + return await crypto.webcrypto.subtle.sign( + { name: 'HMAC', hash: 'SHA-256' }, + key, + n, + ); + }; + + let key = await createSignKey(); + + const temp = await sign(encKey, key); + + key = await createSignKey(new Uint8Array(temp)); + + const decryptionKey = new Uint8Array(await sign(signMe, key)); + + const additionalData = enc.encode(`${pollMsgId}\u0000${voteMsgSender}`); + + const decryptedMessage = await this._decryptMessage( + encPayload, + encIv, + additionalData, + decryptionKey, + ); + + const pollOptionHash = this._decodeMessage(decryptedMessage); + + // '0A20' in hex represents unicode " " and "\n" thus declaring the end of one option + // we want multiple hashes to make it easier to iterate and understand for your use cases + return pollOptionHash.split('0A20') || []; + } + + /** + * Internal method to decrypt the message after gathering all information + * @deprecated Use `this.decrypt()` instead, only use this if you know what you are doing + * @param encPayload + * @param encIv + * @param additionalData + * @param decryptionKey + * @returns + */ + static async _decryptMessage( + encPayload: Uint8Array, + encIv: Uint8Array, + additionalData: Uint8Array, + decryptionKey: Uint8Array, + ) { + const tagSize_multiplier = 16; + const encoded = encPayload; + const key = await crypto.webcrypto.subtle.importKey( + 'raw', + decryptionKey, + 'AES-GCM', + false, + ['encrypt', 'decrypt'], + ); + const decrypted = await crypto.webcrypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: encIv, + additionalData: additionalData, + tagLength: 8 * tagSize_multiplier, + }, + key, + encoded, + ); + return new Uint8Array(decrypted).slice(2); // remove 2 bytes (OA20)(space+newline) + } + + /** + * Decode the message from `this._decryptMessage()` + * @param decryptedMessage the message from `this._decrpytMessage()` + * @returns + */ + static _decodeMessage(decryptedMessage: Uint8Array) { + const n = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, 69, 70]; + const outarr: number[] = []; + + for (let i = 0; i < decryptedMessage.length; i++) { + const val = decryptedMessage[i]; + outarr.push(n[val >> 4], n[15 & val]); + } + + return String.fromCharCode(...outarr); + } +} diff --git a/src/whatsapp/repository/chat.repository.ts b/src/whatsapp/repository/chat.repository.ts index 9e61e1de..d57a9ea2 100644 --- a/src/whatsapp/repository/chat.repository.ts +++ b/src/whatsapp/repository/chat.repository.ts @@ -1,5 +1,5 @@ import { join } from 'path'; -import { ConfigService } from '../../config/env.config'; +import { ConfigService, StoreConf } from '../../config/env.config'; import { IInsert, Repository } from '../abstract/abstract.repository'; import { opendirSync, readFileSync, rmSync } from 'fs'; import { ChatRaw, IChatModel } from '../models'; @@ -27,15 +27,21 @@ export class ChatRepository extends Repository { return { insertCount: insert.length }; } - data.forEach((chat) => { - this.writeStore({ - path: join(this.storePath, 'chats', chat.owner), - fileName: chat.id, - data: chat, - }); - }); + const store = this.configService.get('STORE'); - return { insertCount: data.length }; + if (store.CHATS) { + data.forEach((chat) => { + this.writeStore({ + path: join(this.storePath, 'chats', chat.owner), + fileName: chat.id, + data: chat, + }); + }); + + return { insertCount: data.length }; + } + + return { insertCount: 0 }; } catch (error) { return error; } finally { diff --git a/src/whatsapp/repository/contact.repository.ts b/src/whatsapp/repository/contact.repository.ts index 954292d7..26dfc25c 100644 --- a/src/whatsapp/repository/contact.repository.ts +++ b/src/whatsapp/repository/contact.repository.ts @@ -1,6 +1,6 @@ import { opendirSync, readFileSync } from 'fs'; import { join } from 'path'; -import { ConfigService } from '../../config/env.config'; +import { ConfigService, StoreConf } from '../../config/env.config'; import { ContactRaw, IContactModel } from '../models'; import { IInsert, Repository } from '../abstract/abstract.repository'; @@ -27,15 +27,21 @@ export class ContactRepository extends Repository { return { insertCount: insert.length }; } - data.forEach((contact) => { - this.writeStore({ - path: join(this.storePath, 'contacts', contact.owner), - fileName: contact.id, - data: contact, - }); - }); + const store = this.configService.get('STORE'); - return { insertCount: data.length }; + if (store.CONTACTS) { + data.forEach((contact) => { + this.writeStore({ + path: join(this.storePath, 'contacts', contact.owner), + fileName: contact.id, + data: contact, + }); + }); + + return { insertCount: data.length }; + } + + return { insertCount: 0 }; } catch (error) { return error; } finally { diff --git a/src/whatsapp/repository/message.repository.ts b/src/whatsapp/repository/message.repository.ts index 2f8a7178..eeb207af 100644 --- a/src/whatsapp/repository/message.repository.ts +++ b/src/whatsapp/repository/message.repository.ts @@ -1,4 +1,4 @@ -import { ConfigService } from '../../config/env.config'; +import { ConfigService, StoreConf } from '../../config/env.config'; import { join } from 'path'; import { IMessageModel, MessageRaw } from '../models'; import { IInsert, Repository } from '../abstract/abstract.repository'; @@ -47,7 +47,9 @@ export class MessageRepository extends Repository { return { insertCount: insert.length }; } - if (saveDb) { + const store = this.configService.get('STORE'); + + if (store.MESSAGES) { data.forEach((msg) => this.writeStore({ path: join(this.storePath, 'messages', msg.owner), diff --git a/src/whatsapp/repository/messageUp.repository.ts b/src/whatsapp/repository/messageUp.repository.ts index ac92850a..e40fe1c9 100644 --- a/src/whatsapp/repository/messageUp.repository.ts +++ b/src/whatsapp/repository/messageUp.repository.ts @@ -1,4 +1,4 @@ -import { ConfigService } from '../../config/env.config'; +import { ConfigService, StoreConf } from '../../config/env.config'; import { IMessageUpModel, MessageUpdateRaw } from '../models'; import { IInsert, Repository } from '../abstract/abstract.repository'; import { join } from 'path'; @@ -28,13 +28,21 @@ export class MessageUpRepository extends Repository { return { insertCount: insert.length }; } - data.forEach((update) => { - this.writeStore({ - path: join(this.storePath, 'message-up', update.owner), - fileName: update.id, - data: update, + const store = this.configService.get('STORE'); + + if (store.MESSAGE_UP) { + data.forEach((update) => { + this.writeStore({ + path: join(this.storePath, 'message-up', update.owner), + fileName: update.id, + data: update, + }); }); - }); + + return { insertCount: data.length }; + } + + return { insertCount: 0 }; } catch (error) { return error; } diff --git a/src/whatsapp/services/whatsapp.service.ts b/src/whatsapp/services/whatsapp.service.ts index f92b6ab1..437b7721 100644 --- a/src/whatsapp/services/whatsapp.service.ts +++ b/src/whatsapp/services/whatsapp.service.ts @@ -29,9 +29,16 @@ import makeWASocket, { WAMessage, WAMessageUpdate, WASocket, + WAMessageKey, + WAMessageContent, + getAggregateVotesInPollMessage, + jidNormalizedUser, + getKeyAuthor, + decryptPollVote, } from '@evolution/base'; import { Auth, + CleanStoreConf, ConfigService, ConfigSessionPhone, Database, @@ -40,6 +47,7 @@ import { StoreConf, Webhook, } from '../../config/env.config'; +import { PollUpdateDecrypt } from '../../utils/poll-update-decrypt-message'; import fs from 'fs'; import { Logger } from '../../config/logger.config'; import { INSTANCE_DIR, ROOT_DIR } from '../../config/path.config'; @@ -231,14 +239,14 @@ export class WAStartupService { baseURL = this.localWebhook.url; } - this.logger.log({ - local: WAStartupService.name + '.sendDataWebhook-local', - url: baseURL, - event, - instance: this.instance.name, - data, - destination: this.localWebhook.url, - }); + // this.logger.log({ + // local: WAStartupService.name + '.sendDataWebhook-local', + // url: baseURL, + // event, + // instance: this.instance.name, + // data, + // destination: this.localWebhook.url, + // }); try { if (this.localWebhook.enabled && isURL(this.localWebhook.url)) { @@ -286,14 +294,14 @@ export class WAStartupService { localUrl = this.localWebhook.url; } - this.logger.log({ - local: WAStartupService.name + '.sendDataWebhook-global', - url: globalURL, - event, - instance: this.instance.name, - data, - destination: localUrl, - }); + // this.logger.log({ + // local: WAStartupService.name + '.sendDataWebhook-global', + // url: globalURL, + // event, + // instance: this.instance.name, + // data, + // destination: localUrl, + // }); try { if (globalWebhook && globalWebhook?.ENABLED && isURL(globalURL)) { @@ -437,24 +445,24 @@ export class WAStartupService { } private cleanStore() { - const store = this.configService.get('STORE'); + const cleanStore = this.configService.get('CLEAN_STORE'); const database = this.configService.get('DATABASE'); - if (store?.CLEANING_INTERVAL && !database.ENABLED) { + if (cleanStore?.CLEANING_INTERVAL && !database.ENABLED) { setInterval(() => { try { - for (const [key, value] of Object.entries(store)) { + for (const [key, value] of Object.entries(cleanStore)) { if (value === true) { execSync( `rm -rf ${join( this.storePath, - key.toLowerCase(), + key.toLowerCase().replace('_', '-'), this.instance.wuid, )}/*.json`, ); } } } catch (error) {} - }, (store?.CLEANING_INTERVAL ?? 3600) * 1000); + }, (cleanStore?.CLEANING_INTERVAL ?? 3600) * 1000); } } @@ -715,6 +723,49 @@ export class WAStartupService { received.messageTimestamp = received.messageTimestamp?.toNumber(); } + // if (received.message?.pollUpdateMessage) { + // const creationMsgKey = received.message.pollUpdateMessage.pollCreationMessageKey; + // const pollCreation = (await this.getMessage( + // creationMsgKey, + // true, + // )) as proto.IWebMessageInfo; + + // if (pollCreation) { + // const meIdNormalised = jidNormalizedUser(this.instance.wuid); + // const pollCreatorJid = getKeyAuthor(creationMsgKey, meIdNormalised); + // const voterJid = getKeyAuthor(received.key!, meIdNormalised); + // const pollEncKey = pollCreation.message?.messageContextInfo?.messageSecret; + // // const voteMsg = decryptPollVote(received.message.pollUpdateMessage.vote, { + // // pollEncKey, + // // pollCreatorJid, + // // pollMsgId: creationMsgKey.id, + // // voterJid, + // // }); + // // console.log('voteMsg: ', voteMsg); + // // console.log( + // // pollEncKey, + // // received.message?.pollUpdateMessage.vote.encPayload, + // // received.message?.pollUpdateMessage.vote.encIv, + // // pollCreatorJid, + // // pollCreation.key.id, + // // voterJid, + // // ); + // const hash = await PollUpdateDecrypt.decrypt( + // pollEncKey, // from PollCreationMessage, HAS to be Uint8Array + // received.message?.pollUpdateMessage.vote.encPayload, // from PollUpdateMessage, HAS to be Uint8Array + // received.message?.pollUpdateMessage.vote.encIv, // from PollUpdateMessage, HAS to be Uint8Array + // pollCreatorJid, // PollCreationMessage sender jid (author) + // pollCreation.key.id, // Message ID of the PollCreationMessage (can be gotten via the store & pollCreationMessageKey property on the update) + // voterJid, // PollUpdateMessage sender jid (author) \\ from above + // ); + // const opt = pollCreation.message?.pollCreationMessage?.options.map( + // (o) => o.optionName, + // ); + // const option = await PollUpdateDecrypt.compare(opt, hash); + // console.log('option: ', option); + // } + // } + const messageRaw: MessageRaw = { key: received.key, pushName: received.pushName, @@ -732,6 +783,7 @@ export class WAStartupService { }, 'messages.update': async (args: WAMessageUpdate[], database: Database) => { + console.log('messages.update args: ', args); const status: Record = { 0: 'ERROR', 1: 'PENDING', @@ -742,6 +794,18 @@ export class WAStartupService { }; for await (const { key, update } of args) { if (key.remoteJid !== 'status@broadcast' && !key?.remoteJid?.match(/(:\d+)/)) { + if (update.pollUpdates) { + const pollCreation = await this.getMessage(key); + console.log('pollCreation: ', pollCreation); + if (pollCreation) { + const pollMessage = getAggregateVotesInPollMessage({ + message: pollCreation as proto.IMessage, + pollUpdates: update.pollUpdates, + }); + console.log('pollMessage: ', pollMessage); + } + } + const message: MessageUpdateRaw = { ...key, status: status[update.status],