diff --git a/CHANGELOG.md b/CHANGELOG.md index 06379d809..0b96f10da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.8.1 (develop) + +### Feature +* New method of saving sessions to a file using worker, made in partnership with [codechat](https://github.com/code-chat-br/whatsapp-api) + # 1.8.0 (2024-05-27 16:10) ### Feature diff --git a/Docker/.env.example b/Docker/.env.example index e735d8de4..1af5e1cde 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -33,10 +33,7 @@ CLEAN_STORE_CHATS=true # Permanent data storage DATABASE_ENABLED=false -DATABASE_CONNECTION_URI=mongodb://root:root@mongodb:27017/?authSource=admin & -readPreference=primary & -ssl=false & -directConnection=true +DATABASE_CONNECTION_URI=mongodb://root:root@mongodb:27017/?authSource=admin&readPreference=primary&ssl=false&directConnection=true DATABASE_CONNECTION_DB_PREFIX_NAME=evdocker # Choose the data you want to save in the application's database or store @@ -137,7 +134,7 @@ CONFIG_SESSION_PHONE_NAME=Chrome # Set qrcode display limit QRCODE_LIMIT=30 -QRCODE_COLOR=#198754 +QRCODE_COLOR='#198754' # old | latest TYPEBOT_API_VERSION=latest diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 0ead0be1b..90d9622a1 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -12,6 +12,7 @@ import { RabbitmqService } from '../integrations/rabbitmq/services/rabbitmq.serv import { SqsService } from '../integrations/sqs/services/sqs.service'; import { TypebotService } from '../integrations/typebot/services/typebot.service'; import { WebsocketService } from '../integrations/websocket/services/websocket.service'; +import { ProviderFiles } from '../provider/sessions'; import { RepositoryBroker } from '../repository/repository.manager'; import { AuthService, OldToken } from '../services/auth.service'; import { CacheService } from '../services/cache.service'; @@ -43,6 +44,7 @@ export class InstanceController { private readonly cache: CacheService, private readonly chatwootCache: CacheService, private readonly messagesLostCache: CacheService, + private readonly providerFiles: ProviderFiles, ) {} private readonly logger = new Logger(InstanceController.name); @@ -111,6 +113,7 @@ export class InstanceController { this.cache, this.chatwootCache, this.messagesLostCache, + this.providerFiles, ); } else { instance = new BaileysStartupService( @@ -120,6 +123,7 @@ export class InstanceController { this.cache, this.chatwootCache, this.messagesLostCache, + this.providerFiles, ); } diff --git a/src/api/provider/sessions.ts b/src/api/provider/sessions.ts new file mode 100644 index 000000000..e9628823b --- /dev/null +++ b/src/api/provider/sessions.ts @@ -0,0 +1,140 @@ +import axios from 'axios'; +import { execSync } from 'child_process'; + +import { ConfigService, ProviderSession } from '../../config/env.config'; +import { Logger } from '../../config/logger.config'; + +type ResponseSuccess = { status: number; data?: any }; +type ResponseProvider = Promise<[ResponseSuccess?, Error?]>; + +export class ProviderFiles { + constructor(private readonly configService: ConfigService) { + this.baseUrl = `http://${this.config.HOST}:${this.config.PORT}/session`; + } + + private readonly logger = new Logger(ProviderFiles.name); + + private baseUrl: string; + + private readonly config = Object.freeze(this.configService.get('PROVIDER')); + + private readonly prefix = Object.freeze(this.configService.get('PROVIDER').PREFIX); + + get isEnabled() { + return !!this.config?.ENABLED; + } + + public async onModuleInit() { + if (this.config.ENABLED) { + const client = axios.create({ + baseURL: this.baseUrl, + }); + try { + const response = await client.options('/ping'); + if (!response?.data?.pong) { + throw new Error('Offline file provider.'); + } + } catch (error) { + this.logger.error(['Failed to connect to the file server', error?.message, error?.stack]); + const pid = process.pid; + execSync(`kill -9 ${pid}`); + } + } + } + + public async onModuleDestroy() { + // + } + + public async create(instance: string): ResponseProvider { + try { + const response = await axios.post(`${this.baseUrl}/${this.prefix}`, { + instance, + }); + return [{ status: response.status, data: response?.data }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } + + public async write(instance: string, key: string, data: any): ResponseProvider { + try { + const response = await axios.post(`${this.baseUrl}/${this.prefix}/${instance}/${key}`, data); + return [{ status: response.status, data: response?.data }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } + + public async read(instance: string, key: string): ResponseProvider { + try { + const response = await axios.get(`${this.baseUrl}/${this.prefix}/${instance}/${key}`); + return [{ status: response.status, data: response?.data }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } + + public async delete(instance: string, key: string): ResponseProvider { + try { + const response = await axios.delete(`${this.baseUrl}/${this.prefix}/${instance}/${key}`); + return [{ status: response.status, data: response?.data }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } + + public async allInstances(): ResponseProvider { + try { + const response = await axios.get(`${this.baseUrl}/list-instances/${this.prefix}`); + return [{ status: response.status, data: response?.data as string[] }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } + + public async removeSession(instance: string): ResponseProvider { + try { + const response = await axios.delete(`${this.baseUrl}/${this.prefix}/${instance}`); + return [{ status: response.status, data: response?.data }]; + } catch (error) { + return [ + { + status: error?.response?.status, + data: error?.response?.data, + }, + error, + ]; + } + } +} diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 97df81a39..e282be1c8 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -47,6 +47,7 @@ import { WebsocketModel, } from './models'; import { LabelModel } from './models/label.model'; +import { ProviderFiles } from './provider/sessions'; import { AuthRepository } from './repository/auth.repository'; import { ChatRepository } from './repository/chat.repository'; import { ContactRepository } from './repository/contact.repository'; @@ -109,6 +110,7 @@ export const repository = new RepositoryBroker( export const cache = new CacheService(new CacheEngine(configService, 'instance').getEngine()); const chatwootCache = new CacheService(new CacheEngine(configService, ChatwootService.name).getEngine()); const messagesLostCache = new CacheService(new CacheEngine(configService, 'baileys').getEngine()); +const providerFiles = new ProviderFiles(configService); export const waMonitor = new WAMonitoringService( eventEmitter, @@ -117,6 +119,7 @@ export const waMonitor = new WAMonitoringService( cache, chatwootCache, messagesLostCache, + providerFiles, ); const authService = new AuthService(configService, waMonitor, repository); @@ -168,6 +171,7 @@ export const instanceController = new InstanceController( cache, chatwootCache, messagesLostCache, + providerFiles, ); export const sendMessageController = new SendMessageController(waMonitor); export const chatController = new ChatController(waMonitor); diff --git a/src/api/services/channels/whatsapp.baileys.service.ts b/src/api/services/channels/whatsapp.baileys.service.ts index 2840d9a79..4e9ef4f67 100644 --- a/src/api/services/channels/whatsapp.baileys.service.ts +++ b/src/api/services/channels/whatsapp.baileys.service.ts @@ -55,12 +55,21 @@ import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; import qrcodeTerminal from 'qrcode-terminal'; import sharp from 'sharp'; -import { CacheConf, ConfigService, ConfigSessionPhone, Database, Log, QrCode } from '../../../config/env.config'; +import { + CacheConf, + ConfigService, + ConfigSessionPhone, + Database, + Log, + ProviderSession, + QrCode, +} from '../../../config/env.config'; import { INSTANCE_DIR } from '../../../config/path.config'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '../../../exceptions'; import { dbserver } from '../../../libs/db.connect'; import { makeProxyAgent } from '../../../utils/makeProxyAgent'; import { useMultiFileAuthStateDb } from '../../../utils/use-multi-file-auth-state-db'; +import { AuthStateProvider } from '../../../utils/use-multi-file-auth-state-provider-files'; import { useMultiFileAuthStateRedisDb } from '../../../utils/use-multi-file-auth-state-redis-db'; import { ArchiveChatDto, @@ -114,6 +123,7 @@ import { SettingsRaw } from '../../models'; import { ChatRaw } from '../../models/chat.model'; import { ContactRaw } from '../../models/contact.model'; import { MessageRaw, MessageUpdateRaw } from '../../models/message.model'; +import { ProviderFiles } from '../../provider/sessions'; import { RepositoryBroker } from '../../repository/repository.manager'; import { waMonitor } from '../../server.module'; import { Events, MessageSubtype, TypeMediaMessage, wa } from '../../types/wa.types'; @@ -128,6 +138,7 @@ export class BaileysStartupService extends ChannelStartupService { public readonly cache: CacheService, public readonly chatwootCache: CacheService, public readonly messagesLostCache: CacheService, + private readonly providerFiles: ProviderFiles, ) { super(configService, eventEmitter, repository, chatwootCache); this.logger.verbose('BaileysStartupService initialized'); @@ -135,8 +146,10 @@ export class BaileysStartupService extends ChannelStartupService { this.instance.qrcode = { count: 0 }; this.mobile = false; this.recoveringMessages(); + this.authStateProvider = new AuthStateProvider(this.configService, this.providerFiles); } + private authStateProvider: AuthStateProvider; private readonly msgRetryCounterCache: CacheStore = new NodeCache(); private readonly userDevicesCache: CacheStore = new NodeCache(); private endSession = false; @@ -461,6 +474,12 @@ export class BaileysStartupService extends ChannelStartupService { const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); + const provider = this.configService.get('PROVIDER'); + + if (provider?.ENABLED) { + return await this.authStateProvider.authStateProvider(this.instance.name); + } + if (cache?.REDIS.ENABLED && cache?.REDIS.SAVE_INSTANCES) { this.logger.info('Redis enabled'); return await useMultiFileAuthStateRedisDb(this.instance.name, this.cache); diff --git a/src/api/services/channels/whatsapp.business.service.ts b/src/api/services/channels/whatsapp.business.service.ts index 09ddd2a04..e44f36c73 100644 --- a/src/api/services/channels/whatsapp.business.service.ts +++ b/src/api/services/channels/whatsapp.business.service.ts @@ -23,6 +23,7 @@ import { SendTextDto, } from '../../dto/sendMessage.dto'; import { ContactRaw, MessageRaw, MessageUpdateRaw, SettingsRaw } from '../../models'; +import { ProviderFiles } from '../../provider/sessions'; import { RepositoryBroker } from '../../repository/repository.manager'; import { Events, wa } from '../../types/wa.types'; import { CacheService } from './../cache.service'; @@ -36,6 +37,7 @@ export class BusinessStartupService extends ChannelStartupService { public readonly cache: CacheService, public readonly chatwootCache: CacheService, public readonly messagesLostCache: CacheService, + private readonly providerFiles: ProviderFiles, ) { super(configService, eventEmitter, repository, chatwootCache); this.logger.verbose('BusinessStartupService initialized'); diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index af93fa74e..51ccc2012 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -22,6 +22,7 @@ import { WebhookModel, WebsocketModel, } from '../models'; +import { ProviderFiles } from '../provider/sessions'; import { RepositoryBroker } from '../repository/repository.manager'; import { Integration } from '../types/wa.types'; import { CacheService } from './cache.service'; @@ -36,6 +37,7 @@ export class WAMonitoringService { private readonly cache: CacheService, private readonly chatwootCache: CacheService, private readonly messagesLostCache: CacheService, + private readonly providerFiles: ProviderFiles, ) { this.logger.verbose('instance created'); @@ -349,6 +351,7 @@ export class WAMonitoringService { this.cache, this.chatwootCache, this.messagesLostCache, + this.providerFiles, ); instance.instanceName = name; @@ -360,6 +363,7 @@ export class WAMonitoringService { this.cache, this.chatwootCache, this.messagesLostCache, + this.providerFiles, ); instance.instanceName = name; diff --git a/src/config/env.config.ts b/src/config/env.config.ts index eab883f7f..ddd5ce9f4 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -28,6 +28,13 @@ export type Log = { BAILEYS: LogBaileys; }; +export type ProviderSession = { + ENABLED: boolean; + HOST: string; + PORT: string; + PREFIX: string; +}; + export type SaveData = { INSTANCE: boolean; NEW_MESSAGE: boolean; @@ -209,6 +216,7 @@ export interface Env { SERVER: HttpServer; CORS: Cors; SSL_CONF: SslConf; + PROVIDER: ProviderSession; STORE: StoreConf; CLEAN_STORE: CleanStoreConf; DATABASE: Database; @@ -274,6 +282,12 @@ export class ConfigService { PRIVKEY: process.env?.SSL_CONF_PRIVKEY || '', FULLCHAIN: process.env?.SSL_CONF_FULLCHAIN || '', }, + PROVIDER: { + ENABLED: process.env?.PROVIDER_ENABLED === 'true', + HOST: process.env.PROVIDER_HOST, + PORT: process.env?.PROVIDER_PORT || '5656', + PREFIX: process.env?.PROVIDER_PREFIX || 'evolution', + }, STORE: { MESSAGES: process.env?.STORE_MESSAGES === 'true', MESSAGE_UP: process.env?.STORE_MESSAGE_UP === 'true', diff --git a/src/dev-env.yml b/src/dev-env.yml index 23e1b4798..42573ef35 100644 --- a/src/dev-env.yml +++ b/src/dev-env.yml @@ -49,6 +49,14 @@ LOG: DEL_INSTANCE: false # or false DEL_TEMP_INSTANCES: true # Delete instances with status closed on start +# Seesion Files Providers +# Provider responsible for managing credentials files and WhatsApp sessions. +PROVIDER: + ENABLED: true + HOST: 127.0.0.1 + PORT: 5656 + PREFIX: evolution + # Temporary data storage STORE: MESSAGES: true diff --git a/src/main.ts b/src/main.ts index 815e2a111..2cc9e2806 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { join } from 'path'; import { initAMQP, initGlobalQueues } from './api/integrations/rabbitmq/libs/amqp.server'; import { initSQS } from './api/integrations/sqs/libs/sqs.server'; import { initIO } from './api/integrations/websocket/libs/socket.server'; +import { ProviderFiles } from './api/provider/sessions'; import { HttpStatus, router } from './api/routes/index.router'; import { waMonitor } from './api/server.module'; import { Auth, configService, Cors, HttpServer, Rabbitmq, Sqs, Webhook } from './config/env.config'; @@ -22,10 +23,14 @@ function initWA() { waMonitor.loadInstance(); } -function bootstrap() { +async function bootstrap() { const logger = new Logger('SERVER'); const app = express(); + const providerFiles = new ProviderFiles(configService); + await providerFiles.onModuleInit(); + logger.info('Provider:Files - ON'); + app.use( cors({ origin(requestOrigin, callback) { diff --git a/src/utils/use-multi-file-auth-state-provider-files.ts b/src/utils/use-multi-file-auth-state-provider-files.ts new file mode 100644 index 000000000..89ea47679 --- /dev/null +++ b/src/utils/use-multi-file-auth-state-provider-files.ts @@ -0,0 +1,140 @@ +/** + * ┌──────────────────────────────────────────────────────────────────────────────┐ + * │ @author jrCleber │ + * │ @filename use-multi-file-auth-state-redis-db.ts │ + * │ Developed by: Cleber Wilson │ + * │ Creation date: Apr 09, 2023 │ + * │ Contact: contato@codechat.dev │ + * ├──────────────────────────────────────────────────────────────────────────────┤ + * │ @copyright © Cleber Wilson 2023. All rights reserved. │ + * │ Licensed under the Apache License, Version 2.0 │ + * │ │ + * │ @license "https://github.com/code-chat-br/whatsapp-api/blob/main/LICENSE" │ + * │ │ + * │ You may not use this file except in compliance with the License. │ + * │ You may obtain a copy of the License at │ + * │ │ + * │ http://www.apache.org/licenses/LICENSE-2.0 │ + * │ │ + * │ Unless required by applicable law or agreed to in writing, software │ + * │ distributed under the License is distributed on an "AS IS" BASIS, │ + * │ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │ + * │ │ + * │ See the License for the specific language governing permissions and │ + * │ limitations under the License. │ + * │ │ + * │ @type {AuthState} │ + * │ @function useMultiFileAuthStateRedisDb │ + * │ @returns {Promise} │ + * ├──────────────────────────────────────────────────────────────────────────────┤ + * │ @important │ + * │ For any future changes to the code in this file, it is recommended to │ + * │ contain, together with the modification, the information of the developer │ + * │ who changed it and the date of modification. │ + * └──────────────────────────────────────────────────────────────────────────────┘ + */ + +import { + AuthenticationCreds, + AuthenticationState, + BufferJSON, + initAuthCreds, + proto, + SignalDataTypeMap, +} from '@whiskeysockets/baileys'; +import { isNotEmpty } from 'class-validator'; + +import { ProviderFiles } from '../api/provider/sessions'; +import { ConfigService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; + +export type AuthState = { state: AuthenticationState; saveCreds: () => Promise }; + +export class AuthStateProvider { + constructor(private readonly configService: ConfigService, private readonly providerFiles: ProviderFiles) {} + + private readonly logger = new Logger(AuthStateProvider.name); + + public async authStateProvider(instance: string): Promise { + const [, error] = await this.providerFiles.create(instance); + if (error) { + this.logger.error(['Failed to create folder on file server', error?.message, error?.stack]); + return; + } + + const writeData = async (data: any, key: string): Promise => { + const json = JSON.stringify(data, BufferJSON.replacer); + const [response, error] = await this.providerFiles.write(instance, key, { + data: json, + }); + if (error) { + this.logger.error([error?.message, error?.stack]); + return; + } + return response; + }; + + const readData = async (key: string): Promise => { + const [response, error] = await this.providerFiles.read(instance, key); + if (error) { + this.logger.error([error?.message, error?.stack]); + return; + } + if (isNotEmpty(response?.data)) { + return JSON.parse(JSON.stringify(response.data), BufferJSON.reviver); + } + }; + + const removeData = async (key: string) => { + const [response, error] = await this.providerFiles.delete(instance, key); + if (error) { + this.logger.error([error?.message, error?.stack]); + return; + } + + return response; + }; + + const creds: AuthenticationCreds = (await readData('creds')) || initAuthCreds(); + + return { + state: { + creds, + keys: { + get: async (type, ids: string[]) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: { [_: string]: SignalDataTypeMap[type] } = {}; + await Promise.all( + ids.map(async (id) => { + let value = await readData(`${type}-${id}`); + if (type === 'app-state-sync-key' && value) { + value = proto.Message.AppStateSyncKeyData.fromObject(value); + } + + data[id] = value; + }), + ); + + return data; + }, + set: async (data: any) => { + const tasks: Promise[] = []; + for (const category in data) { + for (const id in data[category]) { + const value = data[category][id]; + const key = `${category}-${id}`; + tasks.push(value ? await writeData(value, key) : await removeData(key)); + } + } + + await Promise.all(tasks); + }, + }, + }, + saveCreds: async () => { + return await writeData(creds, 'creds'); + }, + }; + } +}