From 0525501b87612fff15e35f29f2b12656cf7cce3a Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Sat, 17 Feb 2024 17:42:49 -0300 Subject: [PATCH] feat: whatsapp cloud api --- Docker/.env.example | 5 + Dockerfile | 5 + src/config/env.config.ts | 14 + src/dev-env.yml | 14 +- src/validate/validate.schema.ts | 19 + .../controllers/instance.controller.ts | 64 +- .../controllers/sendMessage.controller.ts | 6 + src/whatsapp/dto/chat.dto.ts | 4 + src/whatsapp/dto/instance.dto.ts | 1 + src/whatsapp/dto/integration.dto.ts | 5 + src/whatsapp/dto/sendMessage.dto.ts | 9 + src/whatsapp/models/index.ts | 1 + src/whatsapp/models/integration.model.ts | 20 + .../repository/integration.repository.ts | 64 + src/whatsapp/repository/repository.manager.ts | 7 + src/whatsapp/routers/sendMessage.router.ts | 18 + src/whatsapp/services/integration.service.ts | 33 + src/whatsapp/services/monitor.service.ts | 63 +- src/whatsapp/services/proxy.service.ts | 4 +- .../services/whatsapp.baileys.service.ts | 3130 +++++++++++++++ .../services/whatsapp.business.service.ts | 1148 ++++++ src/whatsapp/services/whatsapp.service.ts | 3346 +---------------- src/whatsapp/types/wa.types.ts | 11 + src/whatsapp/whatsapp.module.ts | 8 + 24 files changed, 4768 insertions(+), 3231 deletions(-) create mode 100644 src/whatsapp/dto/integration.dto.ts create mode 100644 src/whatsapp/models/integration.model.ts create mode 100644 src/whatsapp/repository/integration.repository.ts create mode 100644 src/whatsapp/services/integration.service.ts create mode 100644 src/whatsapp/services/whatsapp.baileys.service.ts create mode 100644 src/whatsapp/services/whatsapp.business.service.ts diff --git a/Docker/.env.example b/Docker/.env.example index dbc82634..5aa74a5c 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -51,6 +51,11 @@ RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672 WEBSOCKET_ENABLED=false +WA_BUSINESS_TOKEN_WEBHOOK=evolution +WA_BUSINESS_URL=https://graph.facebook.com +WA_BUSINESS_VERSION=v18.0 +WA_BUSINESS_LANGUAGE=pt_BR + SQS_ENABLED=false SQS_ACCESS_KEY_ID= SQS_SECRET_ACCESS_KEY= diff --git a/Dockerfile b/Dockerfile index 201dda47..7758c484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,11 @@ ENV RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672 ENV WEBSOCKET_ENABLED=false +ENV WA_BUSINESS_TOKEN_WEBHOOK=evolution +ENV WA_BUSINESS_URL=https://graph.facebook.com +ENV WA_BUSINESS_VERSION=v18.0 +ENV WA_BUSINESS_LANGUAGE=pt_BR + ENV SQS_ENABLED=false ENV SQS_ACCESS_KEY_ID= ENV SQS_SECRET_ACCESS_KEY= diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 4c5c9120..ae4f0951 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -86,6 +86,13 @@ export type Websocket = { ENABLED: boolean; }; +export type WaBusiness = { + TOKEN_WEBHOOK: string; + URL: string; + VERSION: string; + LANGUAGE: string; +}; + export type EventsWebhook = { APPLICATION_STARTUP: boolean; INSTANCE_CREATE: boolean; @@ -179,6 +186,7 @@ export interface Env { RABBITMQ: Rabbitmq; SQS: Sqs; WEBSOCKET: Websocket; + WA_BUSINESS: WaBusiness; LOG: Log; DEL_INSTANCE: DelInstance; LANGUAGE: Language; @@ -286,6 +294,12 @@ export class ConfigService { WEBSOCKET: { ENABLED: process.env?.WEBSOCKET_ENABLED === 'true', }, + WA_BUSINESS: { + TOKEN_WEBHOOK: process.env.WA_BUSINESS_TOKEN_WEBHOOK || '', + URL: process.env.WA_BUSINESS_URL || '', + VERSION: process.env.WA_BUSINESS_VERSION || '', + LANGUAGE: process.env.WA_BUSINESS_LANGUAGE || 'en', + }, LOG: { LEVEL: (process.env?.LOG_LEVEL.split(',') as LogLevel[]) || [ 'ERROR', diff --git a/src/dev-env.yml b/src/dev-env.yml index 8567e117..b05176bc 100644 --- a/src/dev-env.yml +++ b/src/dev-env.yml @@ -12,7 +12,6 @@ SERVER: DISABLE_MANAGER: false DISABLE_DOCS: false - CORS: ORIGIN: - "*" @@ -96,6 +95,12 @@ SQS: WEBSOCKET: ENABLED: false +WA_BUSINESS: + TOKEN_WEBHOOK: evolution + URL: https://graph.facebook.com + VERSION: v18.0 + LANGUAGE: pt_BR + # Global Webhook Settings # Each instance's Webhook URL and events will be requested at the time it is created WEBHOOK: @@ -152,12 +157,12 @@ QRCODE: COLOR: "#198754" TYPEBOT: - API_VERSION: 'old' # old | latest + API_VERSION: "old" # old | latest KEEP_OPEN: false CHATWOOT: # If you leave this option as false, when deleting the message for everyone on WhatsApp, it will not be deleted on Chatwoot. - MESSAGE_DELETE: true # false | true + MESSAGE_DELETE: true # false | true IMPORT: # This db connection is used to import messages from whatsapp to chatwoot database DATABASE: @@ -192,5 +197,4 @@ AUTHENTICATION: EXPIRIN_IN: 0 # seconds - 3600s === 1h | zero (0) - never expires SECRET: L=0YWt]b2w[WF>#>:&E` - -LANGUAGE: "pt-BR" # pt-BR, en \ No newline at end of file +LANGUAGE: "pt-BR" # pt-BR, en diff --git a/src/validate/validate.schema.ts b/src/validate/validate.schema.ts index 3d9e1294..511cad47 100644 --- a/src/validate/validate.schema.ts +++ b/src/validate/validate.schema.ts @@ -277,6 +277,25 @@ export const audioMessageSchema: JSONSchema7 = { required: ['audioMessage', 'number'], }; +export const templateMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { ...numberDefinition }, + options: { ...optionsSchema }, + templateMessage: { + type: 'object', + properties: { + name: { type: 'string' }, + language: { type: 'string' }, + }, + required: ['name', 'language'], + ...isNotEmpty('name', 'language'), + }, + }, + required: ['templateMessage', 'number'], +}; + export const buttonMessageSchema: JSONSchema7 = { $id: v4(), type: 'object', diff --git a/src/whatsapp/controllers/instance.controller.ts b/src/whatsapp/controllers/instance.controller.ts index 414a2c2d..19184e81 100644 --- a/src/whatsapp/controllers/instance.controller.ts +++ b/src/whatsapp/controllers/instance.controller.ts @@ -3,7 +3,7 @@ import { isURL } from 'class-validator'; import EventEmitter2 from 'eventemitter2'; import { v4 } from 'uuid'; -import { ConfigService, HttpServer } from '../../config/env.config'; +import { ConfigService, HttpServer, WaBusiness } from '../../config/env.config'; import { Logger } from '../../config/logger.config'; import { BadRequestException, InternalServerErrorException } from '../../exceptions'; import { RedisCache } from '../../libs/redis.client'; @@ -12,6 +12,7 @@ import { RepositoryBroker } from '../repository/repository.manager'; import { AuthService, OldToken } from '../services/auth.service'; import { CacheService } from '../services/cache.service'; import { ChatwootService } from '../services/chatwoot.service'; +import { IntegrationService } from '../services/integration.service'; import { WAMonitoringService } from '../services/monitor.service'; import { RabbitmqService } from '../services/rabbitmq.service'; import { SettingsService } from '../services/settings.service'; @@ -19,8 +20,9 @@ import { SqsService } from '../services/sqs.service'; import { TypebotService } from '../services/typebot.service'; import { WebhookService } from '../services/webhook.service'; import { WebsocketService } from '../services/websocket.service'; -import { WAStartupService } from '../services/whatsapp.service'; -import { Events, wa } from '../types/wa.types'; +import { BaileysStartupService } from '../services/whatsapp.baileys.service'; +import { BusinessStartupService } from '../services/whatsapp.business.service'; +import { Events, Integration, wa } from '../types/wa.types'; export class InstanceController { constructor( @@ -36,6 +38,7 @@ export class InstanceController { private readonly rabbitmqService: RabbitmqService, private readonly sqsService: SqsService, private readonly typebotService: TypebotService, + private readonly integrationService: IntegrationService, private readonly cache: RedisCache, private readonly chatwootCache: CacheService, ) {} @@ -50,6 +53,7 @@ export class InstanceController { events, qrcode, number, + integration, token, chatwoot_account_id, chatwoot_token, @@ -87,14 +91,31 @@ export class InstanceController { this.logger.verbose('checking duplicate token'); await this.authService.checkDuplicateToken(token); + if (!token && integration !== Integration.WHATSAPP_BUSINESS) { + throw new BadRequestException('token is required'); + } + this.logger.verbose('creating instance'); - const instance = new WAStartupService( - this.configService, - this.eventEmitter, - this.repository, - this.cache, - this.chatwootCache, - ); + let instance: BaileysStartupService | BusinessStartupService; + if (integration === Integration.WHATSAPP_BUSINESS) { + instance = new BusinessStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); + await this.waMonitor.saveInstance({ integration, instanceName, token, number }); + } else { + instance = new BaileysStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); + } + instance.instanceName = instanceName; const instanceId = v4(); @@ -361,6 +382,23 @@ export class InstanceController { this.settingsService.create(instance, settings); + let webhook_wa_business = null, + access_token_wa_business = ''; + + if (integration === Integration.WHATSAPP_BUSINESS) { + if (!number) { + throw new BadRequestException('number is required'); + } + const urlServer = this.configService.get('SERVER').URL; + webhook_wa_business = `${urlServer}/webhook/whatsapp/${encodeURIComponent(instance.instanceName)}`; + access_token_wa_business = this.configService.get('WA_BUSINESS').TOKEN_WEBHOOK; + } + + this.integrationService.create(instance, { + integration, + number, + token, + }); if (!chatwoot_account_id || !chatwoot_token || !chatwoot_url) { let getQrcode: wa.QrCode; @@ -375,6 +413,9 @@ export class InstanceController { instance: { instanceName: instance.instanceName, instanceId: instanceId, + integration: integration, + webhook_wa_business, + access_token_wa_business, status: 'created', }, hash, @@ -470,6 +511,9 @@ export class InstanceController { instance: { instanceName: instance.instanceName, instanceId: instanceId, + integration: integration, + webhook_wa_business, + access_token_wa_business, status: 'created', }, hash, diff --git a/src/whatsapp/controllers/sendMessage.controller.ts b/src/whatsapp/controllers/sendMessage.controller.ts index 20e38ae5..d23ba345 100644 --- a/src/whatsapp/controllers/sendMessage.controller.ts +++ b/src/whatsapp/controllers/sendMessage.controller.ts @@ -14,6 +14,7 @@ import { SendReactionDto, SendStatusDto, SendStickerDto, + SendTemplateDto, SendTextDto, } from '../dto/sendMessage.dto'; import { WAMonitoringService } from '../services/monitor.service'; @@ -28,6 +29,11 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].textMessage(data); } + public async sendTemplate({ instanceName }: InstanceDto, data: SendTemplateDto) { + logger.verbose('requested sendList from ' + instanceName + ' instance'); + return await this.waMonitor.waInstances[instanceName].templateMessage(data); + } + public async sendMedia({ instanceName }: InstanceDto, data: SendMediaDto) { logger.verbose('requested sendMedia from ' + instanceName + ' instance'); diff --git a/src/whatsapp/dto/chat.dto.ts b/src/whatsapp/dto/chat.dto.ts index dc0584bb..31f3dfe3 100644 --- a/src/whatsapp/dto/chat.dto.ts +++ b/src/whatsapp/dto/chat.dto.ts @@ -31,8 +31,12 @@ export class NumberBusiness { message?: string; description?: string; email?: string; + websites?: string[]; website?: string[]; address?: string; + about?: string; + vertical?: string; + profilehandle?: string; } export class ProfileNameDto { diff --git a/src/whatsapp/dto/instance.dto.ts b/src/whatsapp/dto/instance.dto.ts index 70bb1c63..f03f4c8e 100644 --- a/src/whatsapp/dto/instance.dto.ts +++ b/src/whatsapp/dto/instance.dto.ts @@ -3,6 +3,7 @@ export class InstanceDto { instanceId?: string; qrcode?: boolean; number?: string; + integration?: string; token?: string; webhook?: string; webhook_by_events?: boolean; diff --git a/src/whatsapp/dto/integration.dto.ts b/src/whatsapp/dto/integration.dto.ts new file mode 100644 index 00000000..a4cb67a0 --- /dev/null +++ b/src/whatsapp/dto/integration.dto.ts @@ -0,0 +1,5 @@ +export class IntegrationDto { + integration: string; + number: string; + token: string; +} diff --git a/src/whatsapp/dto/sendMessage.dto.ts b/src/whatsapp/dto/sendMessage.dto.ts index bfa5763f..82ff096c 100644 --- a/src/whatsapp/dto/sendMessage.dto.ts +++ b/src/whatsapp/dto/sendMessage.dto.ts @@ -142,6 +142,15 @@ export class ContactMessage { email?: string; url?: string; } + +export class TemplateMessage { + name: string; + language: string; +} + +export class SendTemplateDto extends Metadata { + templateMessage: TemplateMessage; +} export class SendContactDto extends Metadata { contactMessage: ContactMessage[]; } diff --git a/src/whatsapp/models/index.ts b/src/whatsapp/models/index.ts index 4d21e9b8..743b9760 100644 --- a/src/whatsapp/models/index.ts +++ b/src/whatsapp/models/index.ts @@ -3,6 +3,7 @@ export * from './chamaai.model'; export * from './chat.model'; export * from './chatwoot.model'; export * from './contact.model'; +export * from './integration.model'; export * from './label.model'; export * from './message.model'; export * from './proxy.model'; diff --git a/src/whatsapp/models/integration.model.ts b/src/whatsapp/models/integration.model.ts new file mode 100644 index 00000000..9aa6e8c6 --- /dev/null +++ b/src/whatsapp/models/integration.model.ts @@ -0,0 +1,20 @@ +import { Schema } from 'mongoose'; + +import { dbserver } from '../../libs/db.connect'; + +export class IntegrationRaw { + _id?: string; + integration?: string; + number?: string; + token?: string; +} + +const sqsSchema = new Schema({ + _id: { type: String, _id: true }, + integration: { type: String, required: true }, + number: { type: String, required: true }, + token: { type: String, required: true }, +}); + +export const IntegrationModel = dbserver?.model(IntegrationRaw.name, sqsSchema, 'integration'); +export type IntegrationModel = typeof IntegrationModel; diff --git a/src/whatsapp/repository/integration.repository.ts b/src/whatsapp/repository/integration.repository.ts new file mode 100644 index 00000000..09cb8a9a --- /dev/null +++ b/src/whatsapp/repository/integration.repository.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { ConfigService } from '../../config/env.config'; +import { Logger } from '../../config/logger.config'; +import { IInsert, Repository } from '../abstract/abstract.repository'; +import { IntegrationModel, IntegrationRaw } from '../models'; + +export class IntegrationRepository extends Repository { + constructor(private readonly integrationModel: IntegrationModel, private readonly configService: ConfigService) { + super(configService); + } + + private readonly logger = new Logger('IntegrationRepository'); + + public async create(data: IntegrationRaw, instance: string): Promise { + try { + this.logger.verbose('creating integration'); + if (this.dbSettings.ENABLED) { + this.logger.verbose('saving integration to db'); + const insert = await this.integrationModel.replaceOne({ _id: instance }, { ...data }, { upsert: true }); + + this.logger.verbose('integration saved to db: ' + insert.modifiedCount + ' integration'); + return { insertCount: insert.modifiedCount }; + } + + this.logger.verbose('saving integration to store'); + + this.writeStore({ + path: join(this.storePath, 'integration'), + fileName: instance, + data, + }); + + this.logger.verbose( + 'integration saved to store in path: ' + join(this.storePath, 'integration') + '/' + instance, + ); + + this.logger.verbose('integration created'); + return { insertCount: 1 }; + } catch (error) { + return error; + } + } + + public async find(instance: string): Promise { + try { + this.logger.verbose('finding integration'); + if (this.dbSettings.ENABLED) { + this.logger.verbose('finding integration in db'); + return await this.integrationModel.findOne({ _id: instance }); + } + + this.logger.verbose('finding integration in store'); + return JSON.parse( + readFileSync(join(this.storePath, 'integration', instance + '.json'), { + encoding: 'utf-8', + }), + ) as IntegrationRaw; + } catch (error) { + return {}; + } + } +} diff --git a/src/whatsapp/repository/repository.manager.ts b/src/whatsapp/repository/repository.manager.ts index 57b63e16..10207eb3 100644 --- a/src/whatsapp/repository/repository.manager.ts +++ b/src/whatsapp/repository/repository.manager.ts @@ -9,6 +9,7 @@ import { ChamaaiRepository } from './chamaai.repository'; import { ChatRepository } from './chat.repository'; import { ChatwootRepository } from './chatwoot.repository'; import { ContactRepository } from './contact.repository'; +import { IntegrationRepository } from './integration.repository'; import { LabelRepository } from './label.repository'; import { MessageRepository } from './message.repository'; import { MessageUpRepository } from './messageUp.repository'; @@ -34,6 +35,7 @@ export class RepositoryBroker { public readonly typebot: TypebotRepository, public readonly proxy: ProxyRepository, public readonly chamaai: ChamaaiRepository, + public readonly integration: IntegrationRepository, public readonly auth: AuthRepository, public readonly labels: LabelRepository, private configService: ConfigService, @@ -71,6 +73,7 @@ export class RepositoryBroker { const typebotDir = join(storePath, 'typebot'); const proxyDir = join(storePath, 'proxy'); const chamaaiDir = join(storePath, 'chamaai'); + const integrationDir = join(storePath, 'integration'); const tempDir = join(storePath, 'temp'); if (!fs.existsSync(authDir)) { @@ -129,6 +132,10 @@ export class RepositoryBroker { this.logger.verbose('creating chamaai dir: ' + chamaaiDir); fs.mkdirSync(chamaaiDir, { recursive: true }); } + if (!fs.existsSync(integrationDir)) { + this.logger.verbose('creating integration dir: ' + integrationDir); + fs.mkdirSync(integrationDir, { recursive: true }); + } if (!fs.existsSync(tempDir)) { this.logger.verbose('creating temp dir: ' + tempDir); fs.mkdirSync(tempDir, { recursive: true }); diff --git a/src/whatsapp/routers/sendMessage.router.ts b/src/whatsapp/routers/sendMessage.router.ts index d87db44d..51b60593 100644 --- a/src/whatsapp/routers/sendMessage.router.ts +++ b/src/whatsapp/routers/sendMessage.router.ts @@ -12,6 +12,7 @@ import { reactionMessageSchema, statusMessageSchema, stickerMessageSchema, + templateMessageSchema, textMessageSchema, } from '../../validate/validate.schema'; import { RouterBroker } from '../abstract/abstract.router'; @@ -26,6 +27,7 @@ import { SendReactionDto, SendStatusDto, SendStickerDto, + SendTemplateDto, SendTextDto, } from '../dto/sendMessage.dto'; import { sendMessageController } from '../whatsapp.module'; @@ -85,6 +87,22 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('sendTemplate'), ...guards, async (req, res) => { + logger.verbose('request received in sendTemplate'); + logger.verbose('request body: '); + logger.verbose(req.body); + + logger.verbose('request query: '); + logger.verbose(req.query); + const response = await this.dataValidate({ + request: req, + schema: templateMessageSchema, + ClassRef: SendTemplateDto, + execute: (instance, data) => sendMessageController.sendTemplate(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) .post(this.routerPath('sendButtons'), ...guards, async (req, res) => { logger.verbose('request received in sendButtons'); logger.verbose('request body: '); diff --git a/src/whatsapp/services/integration.service.ts b/src/whatsapp/services/integration.service.ts new file mode 100644 index 00000000..6490a491 --- /dev/null +++ b/src/whatsapp/services/integration.service.ts @@ -0,0 +1,33 @@ +import { Logger } from '../../config/logger.config'; +import { InstanceDto } from '../dto/instance.dto'; +import { IntegrationDto } from '../dto/integration.dto'; +import { IntegrationRaw } from '../models'; +import { WAMonitoringService } from './monitor.service'; + +export class IntegrationService { + constructor(private readonly waMonitor: WAMonitoringService) {} + + private readonly logger = new Logger(IntegrationService.name); + + public create(instance: InstanceDto, data: IntegrationDto) { + this.logger.verbose('create integration: ' + instance.instanceName); + this.waMonitor.waInstances[instance.instanceName].setIntegration(data); + + return { integration: { ...instance, integration: data } }; + } + + public async find(instance: InstanceDto): Promise { + try { + this.logger.verbose('find integration: ' + instance.instanceName); + const result = await this.waMonitor.waInstances[instance.instanceName].findIntegration(); + + if (Object.keys(result).length === 0) { + throw new Error('Integration not found'); + } + + return result; + } catch (error) { + return { integration: '', number: '', token: '' }; + } + } +} diff --git a/src/whatsapp/services/monitor.service.ts b/src/whatsapp/services/monitor.service.ts index 38327a32..963ea608 100644 --- a/src/whatsapp/services/monitor.service.ts +++ b/src/whatsapp/services/monitor.service.ts @@ -1,6 +1,6 @@ import { execSync } from 'child_process'; import EventEmitter2 from 'eventemitter2'; -import { opendirSync, readdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, opendirSync, readdirSync, rmSync, writeFileSync } from 'fs'; import { Db } from 'mongodb'; import { Collection } from 'mongoose'; import { join } from 'path'; @@ -24,8 +24,10 @@ import { WebsocketModel, } from '../models'; import { RepositoryBroker } from '../repository/repository.manager'; +import { Integration } from '../types/wa.types'; import { CacheService } from './cache.service'; -import { WAStartupService } from './whatsapp.service'; +import { BaileysStartupService } from './whatsapp.baileys.service'; +import { BusinessStartupService } from './whatsapp.business.service'; export class WAMonitoringService { constructor( @@ -54,7 +56,7 @@ export class WAMonitoringService { private dbInstance: Db; private readonly logger = new Logger(WAMonitoringService.name); - public readonly waInstances: Record = {}; + public readonly waInstances: Record = {}; public delInstanceTime(instance: string) { const time = this.configService.get('DEL_INSTANCE'); @@ -64,9 +66,11 @@ export class WAMonitoringService { setTimeout(async () => { if (this.waInstances[instance]?.connectionStatus?.state !== 'open') { if (this.waInstances[instance]?.connectionStatus?.state === 'connecting') { - await this.waInstances[instance]?.client?.logout('Log out instance: ' + instance); - this.waInstances[instance]?.client?.ws?.close(); - this.waInstances[instance]?.client?.end(undefined); + if ((await this.waInstances[instance].findIntegration()).integration === Integration.WHATSAPP_BAILEYS) { + await this.waInstances[instance]?.client?.logout('Log out instance: ' + instance); + this.waInstances[instance]?.client?.ws?.close(); + this.waInstances[instance]?.client?.end(undefined); + } this.waInstances[instance]?.removeRabbitmqQueues(); delete this.waInstances[instance]; } else { @@ -353,14 +357,47 @@ export class WAMonitoringService { } } + public async saveInstance(data: any) { + this.logger.verbose('Save instance'); + + try { + const msgParsed = JSON.parse(JSON.stringify(data)); + if (this.db.ENABLED && this.db.SAVE_DATA.INSTANCE) { + await this.repository.dbServer.connect(); + await this.dbInstance.collection(data.instanceName).replaceOne({ _id: 'integration' }, msgParsed, { + upsert: true, + }); + } else { + const path = join(INSTANCE_DIR, data.instanceName); + if (!existsSync(path)) mkdirSync(path, { recursive: true }); + writeFileSync(path + '/integration.json', JSON.stringify(msgParsed)); + } + } catch (error) { + this.logger.error(error); + } + } + private async setInstance(name: string) { - const instance = new WAStartupService( - this.configService, - this.eventEmitter, - this.repository, - this.cache, - this.chatwootCache, - ); + const integration = await this.repository.integration.find(name); + + let instance: BaileysStartupService | BusinessStartupService; + if (integration.integration === Integration.WHATSAPP_BUSINESS) { + instance = new BusinessStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); + } else { + instance = new BaileysStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); + } instance.instanceName = name; this.logger.verbose('Instance loaded: ' + name); await instance.connectToWhatsapp(); diff --git a/src/whatsapp/services/proxy.service.ts b/src/whatsapp/services/proxy.service.ts index 66dc5342..f86933c1 100644 --- a/src/whatsapp/services/proxy.service.ts +++ b/src/whatsapp/services/proxy.service.ts @@ -9,9 +9,9 @@ export class ProxyService { private readonly logger = new Logger(ProxyService.name); - public create(instance: InstanceDto, data: ProxyDto, reload = true) { + public create(instance: InstanceDto, data: ProxyDto) { this.logger.verbose('create proxy: ' + instance.instanceName); - this.waMonitor.waInstances[instance.instanceName].setProxy(data, reload); + this.waMonitor.waInstances[instance.instanceName].setProxy(data); return { proxy: { ...instance, proxy: data } }; } diff --git a/src/whatsapp/services/whatsapp.baileys.service.ts b/src/whatsapp/services/whatsapp.baileys.service.ts new file mode 100644 index 00000000..a2ab4ad5 --- /dev/null +++ b/src/whatsapp/services/whatsapp.baileys.service.ts @@ -0,0 +1,3130 @@ +import ffmpegPath from '@ffmpeg-installer/ffmpeg'; +import { Boom } from '@hapi/boom'; +import makeWASocket, { + AnyMessageContent, + BufferedEventData, + BufferJSON, + CacheStore, + Chat, + ConnectionState, + Contact, + delay, + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + generateWAMessageFromContent, + getAggregateVotesInPollMessage, + getContentType, + getDevice, + GroupMetadata, + isJidBroadcast, + isJidGroup, + isJidUser, + makeCacheableSignalKeyStore, + MessageUpsertType, + MiscMessageGenerationOptions, + ParticipantAction, + prepareWAMessageMedia, + proto, + useMultiFileAuthState, + UserFacingSocketConfig, + WABrowserDescription, + WAMediaUpload, + WAMessage, + WAMessageUpdate, + WASocket, +} from '@whiskeysockets/baileys'; +import { Label } from '@whiskeysockets/baileys/lib/Types/Label'; +import { LabelAssociation } from '@whiskeysockets/baileys/lib/Types/LabelAssociation'; +import axios from 'axios'; +import { exec } from 'child_process'; +import { arrayUnique, isBase64, isURL } from 'class-validator'; +import EventEmitter2 from 'eventemitter2'; +import levenshtein from 'fast-levenshtein'; +import fs, { existsSync, readFileSync } from 'fs'; +import Long from 'long'; +import NodeCache from 'node-cache'; +import { getMIMEType } from 'node-mime-types'; +import { release } from 'os'; +import { join } from 'path'; +import P from 'pino'; +import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; +import qrcodeTerminal from 'qrcode-terminal'; +import sharp from 'sharp'; + +import { ConfigService, ConfigSessionPhone, Database, Log, QrCode, Redis } from '../../config/env.config'; +import { INSTANCE_DIR } from '../../config/path.config'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '../../exceptions'; +import { dbserver } from '../../libs/db.connect'; +import { RedisCache } from '../../libs/redis.client'; +import { chatwootImport } from '../../utils/chatwoot-import-helper'; +import { makeProxyAgent } from '../../utils/makeProxyAgent'; +import { useMultiFileAuthStateDb } from '../../utils/use-multi-file-auth-state-db'; +import { useMultiFileAuthStateRedisDb } from '../../utils/use-multi-file-auth-state-redis-db'; +import { + ArchiveChatDto, + DeleteMessage, + getBase64FromMediaMessageDto, + LastMessage, + NumberBusiness, + OnWhatsAppDto, + PrivacySettingDto, + ReadMessageDto, + SendPresenceDto, + UpdateMessageDto, + WhatsAppNumberDto, +} from '../dto/chat.dto'; +import { + AcceptGroupInvite, + CreateGroupDto, + GetParticipant, + GroupDescriptionDto, + GroupInvite, + GroupJid, + GroupPictureDto, + GroupSendInvite, + GroupSubjectDto, + GroupToggleEphemeralDto, + GroupUpdateParticipantDto, + GroupUpdateSettingDto, +} from '../dto/group.dto'; +import { InstanceDto } from '../dto/instance.dto'; +import { HandleLabelDto, LabelDto } from '../dto/label.dto'; +import { + ContactMessage, + MediaMessage, + Options, + SendAudioDto, + SendButtonDto, + SendContactDto, + SendListDto, + SendLocationDto, + SendMediaDto, + SendPollDto, + SendReactionDto, + SendStatusDto, + SendStickerDto, + SendTextDto, + StatusMessage, +} from '../dto/sendMessage.dto'; +import { SettingsRaw } from '../models'; +import { ChatRaw } from '../models/chat.model'; +import { ContactRaw } from '../models/contact.model'; +import { MessageRaw, MessageUpdateRaw } from '../models/message.model'; +import { RepositoryBroker } from '../repository/repository.manager'; +import { Events, MessageSubtype, TypeMediaMessage, wa } from '../types/wa.types'; +import { waMonitor } from '../whatsapp.module'; +import { CacheService } from './cache.service'; +import { WAStartupService } from './whatsapp.service'; + +const retryCache = {}; + +export class BaileysStartupService extends WAStartupService { + constructor( + public readonly configService: ConfigService, + public readonly eventEmitter: EventEmitter2, + public readonly repository: RepositoryBroker, + public readonly cache: RedisCache, + public readonly chatwootCache: CacheService, + ) { + super(configService, eventEmitter, repository, chatwootCache); + this.logger.verbose('BaileysStartupService initialized'); + this.cleanStore(); + this.instance.qrcode = { count: 0 }; + console.log('BaileysStartupService initialized'); + } + + private readonly msgRetryCounterCache: CacheStore = new NodeCache(); + private readonly userDevicesCache: CacheStore = new NodeCache(); + private endSession = false; + private logBaileys = this.configService.get('LOG').BAILEYS; + + public stateConnection: wa.StateConnection = { state: 'close' }; + + private phoneNumber: string; + + public get connectionStatus() { + this.logger.verbose('Getting connection status'); + return this.stateConnection; + } + + public async getProfileName() { + this.logger.verbose('Getting profile name'); + + let profileName = this.client.user?.name ?? this.client.user?.verifiedName; + if (!profileName) { + this.logger.verbose('Profile name not found, trying to get from database'); + if (this.configService.get('DATABASE').ENABLED) { + this.logger.verbose('Database enabled, trying to get from database'); + const collection = dbserver + .getClient() + .db(this.configService.get('DATABASE').CONNECTION.DB_PREFIX_NAME + '-instances') + .collection(this.instanceName); + const data = await collection.findOne({ _id: 'creds' }); + if (data) { + this.logger.verbose('Profile name found in database'); + const creds = JSON.parse(JSON.stringify(data), BufferJSON.reviver); + profileName = creds.me?.name || creds.me?.verifiedName; + } + } else if (existsSync(join(INSTANCE_DIR, this.instanceName, 'creds.json'))) { + this.logger.verbose('Profile name found in file'); + const creds = JSON.parse( + readFileSync(join(INSTANCE_DIR, this.instanceName, 'creds.json'), { + encoding: 'utf-8', + }), + ); + profileName = creds.me?.name || creds.me?.verifiedName; + } + } + + this.logger.verbose(`Profile name: ${profileName}`); + return profileName; + } + + public async getProfileStatus() { + this.logger.verbose('Getting profile status'); + const status = await this.client.fetchStatus(this.instance.wuid); + + this.logger.verbose(`Profile status: ${status.status}`); + return status.status; + } + + public get profilePictureUrl() { + this.logger.verbose('Getting profile picture url'); + return this.instance.profilePictureUrl; + } + + public get qrCode(): wa.QrCode { + this.logger.verbose('Getting qrcode'); + + return { + pairingCode: this.instance.qrcode?.pairingCode, + code: this.instance.qrcode?.code, + base64: this.instance.qrcode?.base64, + count: this.instance.qrcode?.count, + }; + } + + private async connectionUpdate({ qr, connection, lastDisconnect }: Partial) { + this.logger.verbose('Connection update'); + if (qr) { + this.logger.verbose('QR code found'); + if (this.instance.qrcode.count === this.configService.get('QRCODE').LIMIT) { + this.logger.verbose('QR code limit reached'); + + this.logger.verbose('Sending data to webhook in event QRCODE_UPDATED'); + this.sendDataWebhook(Events.QRCODE_UPDATED, { + message: 'QR code limit reached, please login again', + statusCode: DisconnectReason.badSession, + }); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.QRCODE_UPDATED, + { instanceName: this.instance.name }, + { + message: 'QR code limit reached, please login again', + statusCode: DisconnectReason.badSession, + }, + ); + } + + this.logger.verbose('Sending data to webhook in event CONNECTION_UPDATE'); + this.sendDataWebhook(Events.CONNECTION_UPDATE, { + instance: this.instance.name, + state: 'refused', + statusReason: DisconnectReason.connectionClosed, + }); + + this.logger.verbose('endSession defined as true'); + this.endSession = true; + + this.logger.verbose('Emmiting event logout.instance'); + return this.eventEmitter.emit('no.connection', this.instance.name); + } + + this.logger.verbose('Incrementing QR code count'); + this.instance.qrcode.count++; + + const color = this.configService.get('QRCODE').COLOR; + + const optsQrcode: QRCodeToDataURLOptions = { + margin: 3, + scale: 4, + errorCorrectionLevel: 'H', + color: { light: '#ffffff', dark: color }, + }; + + if (this.phoneNumber) { + await delay(2000); + this.instance.qrcode.pairingCode = await this.client.requestPairingCode(this.phoneNumber); + } else { + this.instance.qrcode.pairingCode = null; + } + + this.logger.verbose('Generating QR code'); + qrcode.toDataURL(qr, optsQrcode, (error, base64) => { + if (error) { + this.logger.error('Qrcode generate failed:' + error.toString()); + return; + } + + this.instance.qrcode.base64 = base64; + this.instance.qrcode.code = qr; + + this.sendDataWebhook(Events.QRCODE_UPDATED, { + qrcode: { + instance: this.instance.name, + pairingCode: this.instance.qrcode.pairingCode, + code: qr, + base64, + }, + }); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.QRCODE_UPDATED, + { instanceName: this.instance.name }, + { + qrcode: { + instance: this.instance.name, + pairingCode: this.instance.qrcode.pairingCode, + code: qr, + base64, + }, + }, + ); + } + }); + + this.logger.verbose('Generating QR code in terminal'); + qrcodeTerminal.generate(qr, { small: true }, (qrcode) => + this.logger.log( + `\n{ instance: ${this.instance.name} pairingCode: ${this.instance.qrcode.pairingCode}, qrcodeCount: ${this.instance.qrcode.count} }\n` + + qrcode, + ), + ); + } + + if (connection) { + this.logger.verbose('Connection found'); + this.stateConnection = { + state: connection, + statusReason: (lastDisconnect?.error as Boom)?.output?.statusCode ?? 200, + }; + + this.logger.verbose('Sending data to webhook in event CONNECTION_UPDATE'); + this.sendDataWebhook(Events.CONNECTION_UPDATE, { + instance: this.instance.name, + ...this.stateConnection, + }); + } + + if (connection === 'close') { + this.logger.verbose('Connection closed'); + const shouldReconnect = (lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; + if (shouldReconnect) { + this.logger.verbose('Reconnecting to whatsapp'); + await this.connectToWhatsapp(); + } else { + this.logger.verbose('Do not reconnect to whatsapp'); + this.logger.verbose('Sending data to webhook in event STATUS_INSTANCE'); + this.sendDataWebhook(Events.STATUS_INSTANCE, { + instance: this.instance.name, + status: 'closed', + }); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.STATUS_INSTANCE, + { instanceName: this.instance.name }, + { + instance: this.instance.name, + status: 'closed', + }, + ); + } + + this.logger.verbose('Emittin event logout.instance'); + this.eventEmitter.emit('logout.instance', this.instance.name, 'inner'); + this.client?.ws?.close(); + this.client.end(new Error('Close connection')); + this.logger.verbose('Connection closed'); + } + } + + if (connection === 'open') { + this.logger.verbose('Connection opened'); + this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); + this.instance.profilePictureUrl = (await this.profilePicture(this.instance.wuid)).profilePictureUrl; + const formattedWuid = this.instance.wuid.split('@')[0].padEnd(30, ' '); + const formattedName = this.instance.name; + this.logger.info( + ` + ┌──────────────────────────────┐ + │ CONNECTED TO WHATSAPP │ + └──────────────────────────────┘`.replace(/^ +/gm, ' '), + ); + this.logger.info(` + wuid: ${formattedWuid} + name: ${formattedName} + `); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.CONNECTION_UPDATE, + { instanceName: this.instance.name }, + { + instance: this.instance.name, + status: 'open', + }, + ); + } + } + } + + private async getMessage(key: proto.IMessageKey, full = false) { + this.logger.verbose('Getting message with key: ' + JSON.stringify(key)); + try { + const webMessageInfo = (await this.repository.message.find({ + where: { owner: this.instance.name, key: { id: key.id } }, + })) as unknown as proto.IWebMessageInfo[]; + if (full) { + this.logger.verbose('Returning full message'); + return webMessageInfo[0]; + } + if (webMessageInfo[0].message?.pollCreationMessage) { + this.logger.verbose('Returning poll message'); + const messageSecretBase64 = webMessageInfo[0].message?.messageContextInfo?.messageSecret; + + if (typeof messageSecretBase64 === 'string') { + const messageSecret = Buffer.from(messageSecretBase64, 'base64'); + + const msg = { + messageContextInfo: { + messageSecret, + }, + pollCreationMessage: webMessageInfo[0].message?.pollCreationMessage, + }; + + return msg; + } + } + + this.logger.verbose('Returning message'); + return webMessageInfo[0].message; + } catch (error) { + return { conversation: '' }; + } + } + + private async defineAuthState() { + this.logger.verbose('Defining auth state'); + const db = this.configService.get('DATABASE'); + const redis = this.configService.get('REDIS'); + + if (redis?.ENABLED) { + this.logger.verbose('Redis enabled'); + this.cache.reference = this.instance.name; + return await useMultiFileAuthStateRedisDb(this.cache); + } + + if (db.SAVE_DATA.INSTANCE && db.ENABLED) { + this.logger.verbose('Database enabled'); + return await useMultiFileAuthStateDb(this.instance.name); + } + + this.logger.verbose('Store file enabled'); + return await useMultiFileAuthState(join(INSTANCE_DIR, this.instance.name)); + } + + public async connectToWhatsapp(number?: string): Promise { + this.logger.verbose('Connecting to whatsapp'); + try { + this.loadWebhook(); + this.loadChatwoot(); + this.loadSettings(); + this.loadWebsocket(); + this.loadRabbitmq(); + this.loadSqs(); + this.loadTypebot(); + this.loadProxy(); + this.loadChamaai(); + + this.instance.authState = await this.defineAuthState(); + + const { version } = await fetchLatestBaileysVersion(); + this.logger.verbose('Baileys version: ' + version); + const session = this.configService.get('CONFIG_SESSION_PHONE'); + const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; + this.logger.verbose('Browser: ' + JSON.stringify(browser)); + + let options; + + if (this.localProxy.enabled) { + this.logger.info('Proxy enabled: ' + this.localProxy.proxy); + + if (this.localProxy.proxy.host.includes('proxyscrape')) { + try { + const response = await axios.get(this.localProxy.proxy.host); + const text = response.data; + const proxyUrls = text.split('\r\n'); + const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); + const proxyUrl = 'http://' + proxyUrls[rand]; + options = { + agent: makeProxyAgent(proxyUrl), + }; + } catch (error) { + this.localProxy.enabled = false; + } + } else { + options = { + agent: makeProxyAgent(this.localProxy.proxy), + }; + } + } + + const socketConfig: UserFacingSocketConfig = { + ...options, + auth: { + creds: this.instance.authState.state.creds, + keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), + }, + logger: P({ level: this.logBaileys }), + printQRInTerminal: false, + browser: number ? ['Chrome (Linux)', session.NAME, release()] : browser, + version, + markOnlineOnConnect: this.localSettings.always_online, + retryRequestDelayMs: 10, + connectTimeoutMs: 60_000, + qrTimeout: 40_000, + defaultQueryTimeoutMs: undefined, + emitOwnEvents: false, + shouldIgnoreJid: (jid) => { + const isGroupJid = this.localSettings.groups_ignore && isJidGroup(jid); + const isBroadcast = !this.localSettings.read_status && isJidBroadcast(jid); + + return isGroupJid || isBroadcast; + }, + msgRetryCounterCache: this.msgRetryCounterCache, + getMessage: async (key) => (await this.getMessage(key)) as Promise, + generateHighQualityLinkPreview: true, + syncFullHistory: this.localSettings.sync_full_history, + shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { + return this.historySyncNotification(msg); + }, + userDevicesCache: this.userDevicesCache, + transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 10 }, + patchMessageBeforeSending(message) { + if ( + message.deviceSentMessage?.message?.listMessage?.listType === + proto.Message.ListMessage.ListType.PRODUCT_LIST + ) { + message = JSON.parse(JSON.stringify(message)); + + message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } + + if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { + message = JSON.parse(JSON.stringify(message)); + + message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } + + return message; + }, + }; + + this.endSession = false; + + this.logger.verbose('Creating socket'); + + this.client = makeWASocket(socketConfig); + + this.logger.verbose('Socket created'); + + this.eventHandler(); + + this.logger.verbose('Socket event handler initialized'); + + this.phoneNumber = number; + + return this.client; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(error?.toString()); + } + } + + public async reloadConnection(): Promise { + try { + this.instance.authState = await this.defineAuthState(); + + const { version } = await fetchLatestBaileysVersion(); + const session = this.configService.get('CONFIG_SESSION_PHONE'); + const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; + + let options; + + if (this.localProxy.enabled) { + this.logger.verbose('Proxy enabled'); + options = { + agent: makeProxyAgent(this.localProxy.proxy), + fetchAgent: makeProxyAgent(this.localProxy.proxy), + }; + } + + const socketConfig: UserFacingSocketConfig = { + ...options, + auth: { + creds: this.instance.authState.state.creds, + keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), + }, + logger: P({ level: this.logBaileys }), + printQRInTerminal: false, + browser: this.phoneNumber ? ['Chrome (Linux)', session.NAME, release()] : browser, + version, + markOnlineOnConnect: this.localSettings.always_online, + retryRequestDelayMs: 10, + connectTimeoutMs: 60_000, + qrTimeout: 40_000, + defaultQueryTimeoutMs: undefined, + emitOwnEvents: false, + shouldIgnoreJid: (jid) => { + const isGroupJid = this.localSettings.groups_ignore && isJidGroup(jid); + const isBroadcast = !this.localSettings.read_status && isJidBroadcast(jid); + + return isGroupJid || isBroadcast; + }, + msgRetryCounterCache: this.msgRetryCounterCache, + getMessage: async (key) => (await this.getMessage(key)) as Promise, + generateHighQualityLinkPreview: true, + syncFullHistory: this.localSettings.sync_full_history, + shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { + return this.historySyncNotification(msg); + }, + userDevicesCache: this.userDevicesCache, + transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 10 }, + patchMessageBeforeSending(message) { + if ( + message.deviceSentMessage?.message?.listMessage?.listType === + proto.Message.ListMessage.ListType.PRODUCT_LIST + ) { + message = JSON.parse(JSON.stringify(message)); + + message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } + + if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { + message = JSON.parse(JSON.stringify(message)); + + message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } + + return message; + }, + }; + + this.client = makeWASocket(socketConfig); + + return this.client; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(error?.toString()); + } + } + + private readonly chatHandle = { + 'chats.upsert': async (chats: Chat[], database: Database) => { + this.logger.verbose('Event received: chats.upsert'); + + this.logger.verbose('Finding chats in database'); + const chatsRepository = await this.repository.chat.find({ + where: { owner: this.instance.name }, + }); + + this.logger.verbose('Verifying if chats exists in database to insert'); + const chatsRaw: ChatRaw[] = []; + for await (const chat of chats) { + if (chatsRepository.find((cr) => cr.id === chat.id)) { + continue; + } + + chatsRaw.push({ id: chat.id, owner: this.instance.wuid }); + } + + this.logger.verbose('Sending data to webhook in event CHATS_UPSERT'); + this.sendDataWebhook(Events.CHATS_UPSERT, chatsRaw); + + this.logger.verbose('Inserting chats in database'); + this.repository.chat.insert(chatsRaw, this.instance.name, database.SAVE_DATA.CHATS); + }, + + 'chats.update': async ( + chats: Partial< + proto.IConversation & { + lastMessageRecvTimestamp?: number; + } & { + conditional: (bufferedData: BufferedEventData) => boolean; + } + >[], + ) => { + this.logger.verbose('Event received: chats.update'); + const chatsRaw: ChatRaw[] = chats.map((chat) => { + return { id: chat.id, owner: this.instance.wuid }; + }); + + this.logger.verbose('Sending data to webhook in event CHATS_UPDATE'); + this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); + }, + + 'chats.delete': async (chats: string[]) => { + this.logger.verbose('Event received: chats.delete'); + + this.logger.verbose('Deleting chats in database'); + chats.forEach( + async (chat) => + await this.repository.chat.delete({ + where: { owner: this.instance.name, id: chat }, + }), + ); + + this.logger.verbose('Sending data to webhook in event CHATS_DELETE'); + this.sendDataWebhook(Events.CHATS_DELETE, [...chats]); + }, + }; + + private readonly contactHandle = { + 'contacts.upsert': async (contacts: Contact[], database: Database) => { + try { + this.logger.verbose('Event received: contacts.upsert'); + + this.logger.verbose('Finding contacts in database'); + const contactsRepository = new Set( + ( + await this.repository.contact.find({ + select: { id: 1, _id: 0 }, + where: { owner: this.instance.name }, + }) + ).map((contact) => contact.id), + ); + + this.logger.verbose('Verifying if contacts exists in database to insert'); + let contactsRaw: ContactRaw[] = []; + + for (const contact of contacts) { + if (contactsRepository.has(contact.id)) { + continue; + } + + contactsRaw.push({ + id: contact.id, + pushName: contact?.name || contact?.verifiedName || contact.id.split('@')[0], + profilePictureUrl: null, + owner: this.instance.name, + }); + } + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPSERT'); + this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw); + + this.logger.verbose('Inserting contacts in database'); + this.repository.contact.insert(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); + + if (this.localChatwoot.enabled && this.localChatwoot.import_contacts && contactsRaw.length) { + this.chatwootService.addHistoryContacts({ instanceName: this.instance.name }, contactsRaw); + chatwootImport.importHistoryContacts({ instanceName: this.instance.name }, this.localChatwoot); + } + + // Update profile pictures + contactsRaw = []; + for await (const contact of contacts) { + contactsRaw.push({ + id: contact.id, + pushName: contact?.name || contact?.verifiedName || contact.id.split('@')[0], + profilePictureUrl: (await this.profilePicture(contact.id)).profilePictureUrl, + owner: this.instance.name, + }); + } + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); + this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw); + + this.logger.verbose('Updating contacts in database'); + this.repository.contact.update(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); + } catch (error) { + this.logger.error(error); + } + }, + + 'contacts.update': async (contacts: Partial[], database: Database) => { + this.logger.verbose('Event received: contacts.update'); + + this.logger.verbose('Verifying if contacts exists in database to update'); + const contactsRaw: ContactRaw[] = []; + for await (const contact of contacts) { + contactsRaw.push({ + id: contact.id, + pushName: contact?.name ?? contact?.verifiedName, + profilePictureUrl: (await this.profilePicture(contact.id)).profilePictureUrl, + owner: this.instance.name, + }); + } + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); + this.sendDataWebhook(Events.CONTACTS_UPDATE, contactsRaw); + + this.logger.verbose('Updating contacts in database'); + this.repository.contact.update(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); + }, + }; + + private readonly messageHandle = { + 'messaging-history.set': async ( + { + messages, + chats, + contacts, + }: { + chats: Chat[]; + contacts: Contact[]; + messages: proto.IWebMessageInfo[]; + isLatest: boolean; + }, + database: Database, + ) => { + try { + this.logger.verbose('Event received: messaging-history.set'); + + const instance: InstanceDto = { instanceName: this.instance.name }; + + const daysLimitToImport = this.localChatwoot.enabled ? this.localChatwoot.days_limit_import_messages : 1000; + this.logger.verbose(`Param days limit import messages is: ${daysLimitToImport}`); + + const date = new Date(); + const timestampLimitToImport = new Date(date.setDate(date.getDate() - daysLimitToImport)).getTime() / 1000; + + const maxBatchTimestamp = Math.max(...messages.map((message) => message.messageTimestamp as number)); + + const processBatch = maxBatchTimestamp >= timestampLimitToImport; + + if (!processBatch) { + this.logger.verbose('Batch ignored by maxTimestamp in this batch'); + return; + } + + const chatsRaw: ChatRaw[] = []; + const chatsRepository = new Set( + ( + await this.repository.chat.find({ + select: { id: 1, _id: 0 }, + where: { owner: this.instance.name }, + }) + ).map((chat) => chat.id), + ); + + for (const chat of chats) { + if (chatsRepository.has(chat.id)) { + continue; + } + + chatsRaw.push({ + id: chat.id, + owner: this.instance.name, + lastMsgTimestamp: chat.lastMessageRecvTimestamp, + }); + } + + this.logger.verbose('Sending data to webhook in event CHATS_SET'); + this.sendDataWebhook(Events.CHATS_SET, chatsRaw); + + this.logger.verbose('Inserting chats in database'); + this.repository.chat.insert(chatsRaw, this.instance.name, database.SAVE_DATA.CHATS); + + const messagesRaw: MessageRaw[] = []; + const messagesRepository = new Set( + chatwootImport.getRepositoryMessagesCache(instance) ?? + ( + await this.repository.message.find({ + select: { key: { id: 1 }, _id: 0 }, + where: { owner: this.instance.name }, + }) + ).map((message) => message.key.id), + ); + + if (chatwootImport.getRepositoryMessagesCache(instance) === null) { + chatwootImport.setRepositoryMessagesCache(instance, messagesRepository); + } + + for (const m of messages) { + if (!m.message || !m.key || !m.messageTimestamp) { + continue; + } + + if (Long.isLong(m?.messageTimestamp)) { + m.messageTimestamp = m.messageTimestamp?.toNumber(); + } + + if (m.messageTimestamp <= timestampLimitToImport) { + continue; + } + + if (messagesRepository.has(m.key.id)) { + continue; + } + + messagesRaw.push({ + key: m.key, + pushName: m.pushName || m.key.remoteJid.split('@')[0], + participant: m.participant, + message: { ...m.message }, + messageType: getContentType(m.message), + messageTimestamp: m.messageTimestamp as number, + owner: this.instance.name, + }); + } + + this.logger.verbose('Sending data to webhook in event MESSAGES_SET'); + this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw]); + + this.logger.verbose('Inserting messages in database'); + await this.repository.message.insert(messagesRaw, this.instance.name, database.SAVE_DATA.NEW_MESSAGE); + + if (this.localChatwoot.enabled && this.localChatwoot.import_messages && messagesRaw.length > 0) { + this.chatwootService.addHistoryMessages( + instance, + messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), + ); + } + + await this.contactHandle['contacts.upsert']( + contacts + .filter((c) => !!c.notify ?? !!c.name) + .map((c) => ({ + id: c.id, + name: c.name ?? c.notify, + })), + database, + ); + + contacts = undefined; + messages = undefined; + chats = undefined; + } catch (error) { + this.logger.error(error); + } + }, + + 'messages.upsert': async ( + { + messages, + type, + }: { + messages: proto.IWebMessageInfo[]; + type: MessageUpsertType; + }, + database: Database, + settings: SettingsRaw, + ) => { + try { + this.logger.verbose('Event received: messages.upsert'); + for (const received of messages) { + if ( + (type !== 'notify' && type !== 'append') || + received.message?.protocolMessage || + received.message?.pollUpdateMessage + ) { + this.logger.verbose('message rejected'); + return; + } + + if (Long.isLong(received.messageTimestamp)) { + received.messageTimestamp = received.messageTimestamp?.toNumber(); + } + + if (settings?.groups_ignore && received.key.remoteJid.includes('@g.us')) { + this.logger.verbose('group ignored'); + return; + } + + let messageRaw: MessageRaw; + + const isMedia = + received?.message?.imageMessage || + received?.message?.videoMessage || + received?.message?.stickerMessage || + received?.message?.documentMessage || + received?.message?.audioMessage; + + const contentMsg = received.message[getContentType(received.message)] as any; + + if (this.localWebhook.webhook_base64 === true && isMedia) { + const buffer = await downloadMediaMessage( + { key: received.key, message: received?.message }, + 'buffer', + {}, + { + logger: P({ level: 'error' }) as any, + reuploadRequest: this.client.updateMediaMessage, + }, + ); + messageRaw = { + key: received.key, + pushName: received.pushName, + message: { + ...received.message, + base64: buffer ? buffer.toString('base64') : undefined, + }, + contextInfo: contentMsg?.contextInfo, + messageType: getContentType(received.message), + messageTimestamp: received.messageTimestamp as number, + owner: this.instance.name, + source: getDevice(received.key.id), + }; + } else { + messageRaw = { + key: received.key, + pushName: received.pushName, + message: { ...received.message }, + contextInfo: contentMsg?.contextInfo, + messageType: getContentType(received.message), + messageTimestamp: received.messageTimestamp as number, + owner: this.instance.name, + source: getDevice(received.key.id), + }; + } + + if (this.localSettings.read_messages && received.key.id !== 'status@broadcast') { + await this.client.readMessages([received.key]); + } + + if (this.localSettings.read_status && received.key.id === 'status@broadcast') { + await this.client.readMessages([received.key]); + } + + this.logger.log(messageRaw); + + this.logger.verbose('Sending data to webhook in event MESSAGES_UPSERT'); + this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); + + if (this.localChatwoot.enabled && !received.key.id.includes('@broadcast')) { + const chatwootSentMessage = await this.chatwootService.eventWhatsapp( + Events.MESSAGES_UPSERT, + { instanceName: this.instance.name }, + messageRaw, + ); + + if (chatwootSentMessage?.id) { + messageRaw.chatwoot = { + messageId: chatwootSentMessage.id, + inboxId: chatwootSentMessage.inbox_id, + conversationId: chatwootSentMessage.conversation_id, + }; + } + } + + const typebotSessionRemoteJid = this.localTypebot.sessions?.find( + (session) => session.remoteJid === received.key.remoteJid, + ); + + if ((this.localTypebot.enabled && type === 'notify') || typebotSessionRemoteJid) { + if (!(this.localTypebot.listening_from_me === false && messageRaw.key.fromMe === true)) { + if (messageRaw.messageType !== 'reactionMessage') + await this.typebotService.sendTypebot( + { instanceName: this.instance.name }, + messageRaw.key.remoteJid, + messageRaw, + ); + } + } + + if (this.localChamaai.enabled && messageRaw.key.fromMe === false && type === 'notify') { + await this.chamaaiService.sendChamaai( + { instanceName: this.instance.name }, + messageRaw.key.remoteJid, + messageRaw, + ); + } + + this.logger.verbose('Inserting message in database'); + await this.repository.message.insert([messageRaw], this.instance.name, database.SAVE_DATA.NEW_MESSAGE); + + this.logger.verbose('Verifying contact from message'); + const contact = await this.repository.contact.find({ + where: { owner: this.instance.name, id: received.key.remoteJid }, + }); + + const contactRaw: ContactRaw = { + id: received.key.remoteJid, + pushName: received.pushName, + profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, + owner: this.instance.name, + }; + + if (contactRaw.id === 'status@broadcast') { + this.logger.verbose('Contact is status@broadcast'); + return; + } + + if (contact?.length) { + this.logger.verbose('Contact found in database'); + const contactRaw: ContactRaw = { + id: received.key.remoteJid, + pushName: contact[0].pushName, + profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, + owner: this.instance.name, + }; + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); + this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); + + if (this.localChatwoot.enabled) { + await this.chatwootService.eventWhatsapp( + Events.CONTACTS_UPDATE, + { instanceName: this.instance.name }, + contactRaw, + ); + } + + this.logger.verbose('Updating contact in database'); + await this.repository.contact.update([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); + return; + } + + this.logger.verbose('Contact not found in database'); + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPSERT'); + this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); + + this.logger.verbose('Inserting contact in database'); + this.repository.contact.insert([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); + } + } catch (error) { + this.logger.error(error); + } + }, + + 'messages.update': async (args: WAMessageUpdate[], database: Database, settings: SettingsRaw) => { + this.logger.verbose('Event received: messages.update'); + const status: Record = { + 0: 'ERROR', + 1: 'PENDING', + 2: 'SERVER_ACK', + 3: 'DELIVERY_ACK', + 4: 'READ', + 5: 'PLAYED', + }; + for await (const { key, update } of args) { + if (settings?.groups_ignore && key.remoteJid.includes('@g.us')) { + this.logger.verbose('group ignored'); + return; + } + + if (status[update.status] === 'READ' && key.fromMe) { + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp('messages.read', { instanceName: this.instance.name }, { key: key }); + } + } + + // if (key.remoteJid !== 'status@broadcast' && !key?.remoteJid?.match(/(:\d+)/)) { + if (key.remoteJid !== 'status@broadcast') { + this.logger.verbose('Message update is valid'); + + let pollUpdates: any; + if (update.pollUpdates) { + this.logger.verbose('Poll update found'); + + this.logger.verbose('Getting poll message'); + const pollCreation = await this.getMessage(key); + this.logger.verbose(pollCreation); + + if (pollCreation) { + this.logger.verbose('Getting aggregate votes in poll message'); + pollUpdates = getAggregateVotesInPollMessage({ + message: pollCreation as proto.IMessage, + pollUpdates: update.pollUpdates, + }); + } + } + + if (status[update.status] === 'READ' && !key.fromMe) return; + + if (update.message === null && update.status === undefined) { + this.logger.verbose('Message deleted'); + + this.logger.verbose('Sending data to webhook in event MESSAGE_DELETE'); + this.sendDataWebhook(Events.MESSAGES_DELETE, key); + + const message: MessageUpdateRaw = { + ...key, + status: 'DELETED', + datetime: Date.now(), + owner: this.instance.name, + }; + + this.logger.verbose(message); + + this.logger.verbose('Inserting message in database'); + await this.repository.messageUpdate.insert( + [message], + this.instance.name, + database.SAVE_DATA.MESSAGE_UPDATE, + ); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.MESSAGES_DELETE, + { instanceName: this.instance.name }, + { key: key }, + ); + } + + return; + } + + const message: MessageUpdateRaw = { + ...key, + status: status[update.status], + datetime: Date.now(), + owner: this.instance.name, + pollUpdates, + }; + + this.logger.verbose(message); + + this.logger.verbose('Sending data to webhook in event MESSAGES_UPDATE'); + this.sendDataWebhook(Events.MESSAGES_UPDATE, message); + + this.logger.verbose('Inserting message in database'); + this.repository.messageUpdate.insert([message], this.instance.name, database.SAVE_DATA.MESSAGE_UPDATE); + } + } + }, + }; + + private readonly groupHandler = { + 'groups.upsert': (groupMetadata: GroupMetadata[]) => { + this.logger.verbose('Event received: groups.upsert'); + + this.logger.verbose('Sending data to webhook in event GROUPS_UPSERT'); + this.sendDataWebhook(Events.GROUPS_UPSERT, groupMetadata); + }, + + 'groups.update': (groupMetadataUpdate: Partial[]) => { + this.logger.verbose('Event received: groups.update'); + + this.logger.verbose('Sending data to webhook in event GROUPS_UPDATE'); + this.sendDataWebhook(Events.GROUPS_UPDATE, groupMetadataUpdate); + }, + + 'group-participants.update': (participantsUpdate: { + id: string; + participants: string[]; + action: ParticipantAction; + }) => { + this.logger.verbose('Event received: group-participants.update'); + + this.logger.verbose('Sending data to webhook in event GROUP_PARTICIPANTS_UPDATE'); + this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, participantsUpdate); + }, + }; + + private readonly labelHandle = { + [Events.LABELS_EDIT]: async (label: Label, database: Database) => { + this.logger.verbose('Event received: labels.edit'); + this.logger.verbose('Finding labels in database'); + const labelsRepository = await this.repository.labels.find({ + where: { owner: this.instance.name }, + }); + + const savedLabel = labelsRepository.find((l) => l.id === label.id); + if (label.deleted && savedLabel) { + this.logger.verbose('Sending data to webhook in event LABELS_EDIT'); + await this.repository.labels.delete({ + where: { owner: this.instance.name, id: label.id }, + }); + this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name }); + return; + } + + const labelName = label.name.replace(/[^\x20-\x7E]/g, ''); + if (!savedLabel || savedLabel.color !== label.color || savedLabel.name !== labelName) { + this.logger.verbose('Sending data to webhook in event LABELS_EDIT'); + await this.repository.labels.insert( + { + color: label.color, + name: labelName, + owner: this.instance.name, + id: label.id, + predefinedId: label.predefinedId, + }, + this.instance.name, + database.SAVE_DATA.LABELS, + ); + this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name }); + } + }, + + [Events.LABELS_ASSOCIATION]: async ( + data: { association: LabelAssociation; type: 'remove' | 'add' }, + database: Database, + ) => { + this.logger.verbose('Sending data to webhook in event LABELS_ASSOCIATION'); + + // Atualiza labels nos chats + if (database.SAVE_DATA.CHATS) { + const chats = await this.repository.chat.find({ + where: { + owner: this.instance.name, + }, + }); + const chat = chats.find((c) => c.id === data.association.chatId); + if (chat) { + let labels = [...chat.labels]; + if (data.type === 'remove') { + labels = labels.filter((label) => label !== data.association.labelId); + } else if (data.type === 'add') { + labels = [...labels, data.association.labelId]; + } + await this.repository.chat.update( + [{ id: chat.id, owner: this.instance.name, labels }], + this.instance.name, + database.SAVE_DATA.CHATS, + ); + } + } + + // Envia dados para o webhook + this.sendDataWebhook(Events.LABELS_ASSOCIATION, { + instance: this.instance.name, + type: data.type, + chatId: data.association.chatId, + labelId: data.association.labelId, + }); + }, + }; + + private eventHandler() { + this.logger.verbose('Initializing event handler'); + this.client.ev.process(async (events) => { + if (!this.endSession) { + this.logger.verbose(`Event received: ${Object.keys(events).join(', ')}`); + const database = this.configService.get('DATABASE'); + const settings = await this.findSettings(); + + if (events.call) { + this.logger.verbose('Listening event: call'); + const call = events.call[0]; + + if (settings?.reject_call && call.status == 'offer') { + this.logger.verbose('Rejecting call'); + this.client.rejectCall(call.id, call.from); + } + + if (settings?.msg_call?.trim().length > 0 && call.status == 'offer') { + this.logger.verbose('Sending message in call'); + const msg = await this.client.sendMessage(call.from, { + text: settings.msg_call, + }); + + this.logger.verbose('Sending data to event messages.upsert'); + this.client.ev.emit('messages.upsert', { + messages: [msg], + type: 'notify', + }); + } + + this.logger.verbose('Sending data to webhook in event CALL'); + this.sendDataWebhook(Events.CALL, call); + } + + if (events['connection.update']) { + this.logger.verbose('Listening event: connection.update'); + this.connectionUpdate(events['connection.update']); + } + + if (events['creds.update']) { + this.logger.verbose('Listening event: creds.update'); + this.instance.authState.saveCreds(); + } + + if (events['messaging-history.set']) { + this.logger.verbose('Listening event: messaging-history.set'); + const payload = events['messaging-history.set']; + this.messageHandle['messaging-history.set'](payload, database); + } + + if (events['messages.upsert']) { + this.logger.verbose('Listening event: messages.upsert'); + const payload = events['messages.upsert']; + if (payload.messages.find((a) => a?.messageStubType === 2)) { + const msg = payload.messages[0]; + retryCache[msg.key.id] = msg; + return; + } + this.messageHandle['messages.upsert'](payload, database, settings); + } + + if (events['messages.update']) { + this.logger.verbose('Listening event: messages.update'); + const payload = events['messages.update']; + payload.forEach((message) => { + if (retryCache[message.key.id]) { + this.client.ev.emit('messages.upsert', { + messages: [message], + type: 'notify', + }); + delete retryCache[message.key.id]; + return; + } + }); + this.messageHandle['messages.update'](payload, database, settings); + } + + if (events['presence.update']) { + this.logger.verbose('Listening event: presence.update'); + const payload = events['presence.update']; + + if (settings.groups_ignore && payload.id.includes('@g.us')) { + this.logger.verbose('group ignored'); + return; + } + this.sendDataWebhook(Events.PRESENCE_UPDATE, payload); + } + + if (!settings?.groups_ignore) { + if (events['groups.upsert']) { + this.logger.verbose('Listening event: groups.upsert'); + const payload = events['groups.upsert']; + this.groupHandler['groups.upsert'](payload); + } + + if (events['groups.update']) { + this.logger.verbose('Listening event: groups.update'); + const payload = events['groups.update']; + this.groupHandler['groups.update'](payload); + } + + if (events['group-participants.update']) { + this.logger.verbose('Listening event: group-participants.update'); + const payload = events['group-participants.update']; + this.groupHandler['group-participants.update'](payload); + } + } + + if (events['chats.upsert']) { + this.logger.verbose('Listening event: chats.upsert'); + const payload = events['chats.upsert']; + this.chatHandle['chats.upsert'](payload, database); + } + + if (events['chats.update']) { + this.logger.verbose('Listening event: chats.update'); + const payload = events['chats.update']; + this.chatHandle['chats.update'](payload); + } + + if (events['chats.delete']) { + this.logger.verbose('Listening event: chats.delete'); + const payload = events['chats.delete']; + this.chatHandle['chats.delete'](payload); + } + + if (events['contacts.upsert']) { + this.logger.verbose('Listening event: contacts.upsert'); + const payload = events['contacts.upsert']; + this.contactHandle['contacts.upsert'](payload, database); + } + + if (events['contacts.update']) { + this.logger.verbose('Listening event: contacts.update'); + const payload = events['contacts.update']; + this.contactHandle['contacts.update'](payload, database); + } + + if (events[Events.LABELS_ASSOCIATION]) { + this.logger.verbose('Listening event: labels.association'); + const payload = events[Events.LABELS_ASSOCIATION]; + this.labelHandle[Events.LABELS_ASSOCIATION](payload, database); + return; + } + + if (events[Events.LABELS_EDIT]) { + this.logger.verbose('Listening event: labels.edit'); + const payload = events[Events.LABELS_EDIT]; + this.labelHandle[Events.LABELS_EDIT](payload, database); + return; + } + } + }); + } + + private historySyncNotification(msg: proto.Message.IHistorySyncNotification) { + const instance: InstanceDto = { instanceName: this.instance.name }; + + if ( + this.localChatwoot.enabled && + this.localChatwoot.import_messages && + this.isSyncNotificationFromUsedSyncType(msg) + ) { + if (msg.chunkOrder === 1) { + this.chatwootService.startImportHistoryMessages(instance); + } + + if (msg.progress === 100) { + setTimeout(() => { + this.chatwootService.importHistoryMessages(instance); + }, 10000); + } + } + + return true; + } + + private isSyncNotificationFromUsedSyncType(msg: proto.Message.IHistorySyncNotification) { + return ( + (this.localSettings.sync_full_history && msg?.syncType === 2) || + (!this.localSettings.sync_full_history && msg?.syncType === 3) + ); + } + + public async profilePicture(number: string) { + const jid = this.createJid(number); + + this.logger.verbose('Getting profile picture with jid: ' + jid); + try { + this.logger.verbose('Getting profile picture url'); + return { + wuid: jid, + profilePictureUrl: await this.client.profilePictureUrl(jid, 'image'), + }; + } catch (error) { + this.logger.verbose('Profile picture not found'); + return { + wuid: jid, + profilePictureUrl: null, + }; + } + } + + public async getStatus(number: string) { + const jid = this.createJid(number); + + this.logger.verbose('Getting profile status with jid:' + jid); + try { + this.logger.verbose('Getting status'); + return { + wuid: jid, + status: (await this.client.fetchStatus(jid))?.status, + }; + } catch (error) { + this.logger.verbose('Status not found'); + return { + wuid: jid, + status: null, + }; + } + } + + public async fetchProfile(instanceName: string, number?: string) { + const jid = number ? this.createJid(number) : this.client?.user?.id; + + const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + + if (!onWhatsapp.exists) { + throw new BadRequestException(onWhatsapp); + } + + this.logger.verbose('Getting profile with jid: ' + jid); + try { + this.logger.verbose('Getting profile info'); + + if (number) { + const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + const picture = await this.profilePicture(info?.jid); + const status = await this.getStatus(info?.jid); + const business = await this.fetchBusinessProfile(info?.jid); + + return { + wuid: info?.jid || jid, + name: info?.name, + numberExists: info?.exists, + picture: picture?.profilePictureUrl, + status: status?.status, + isBusiness: business.isBusiness, + email: business?.email, + description: business?.description, + website: business?.website?.shift(), + }; + } else { + const info = await waMonitor.instanceInfo(instanceName); + const business = await this.fetchBusinessProfile(jid); + + return { + wuid: jid, + name: info?.instance?.profileName, + numberExists: true, + picture: info?.instance?.profilePictureUrl, + status: info?.instance?.profileStatus, + isBusiness: business.isBusiness, + email: business?.email, + description: business?.description, + website: business?.website?.shift(), + }; + } + } catch (error) { + this.logger.verbose('Profile not found'); + return { + wuid: jid, + name: null, + picture: null, + status: null, + os: null, + isBusiness: false, + }; + } + } + + private async sendMessageWithTyping( + number: string, + message: T, + options?: Options, + isChatwoot = false, + ) { + this.logger.verbose('Sending message with typing'); + + this.logger.verbose(`Check if number "${number}" is WhatsApp`); + const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); + + this.logger.verbose(`Exists: "${isWA.exists}" | jid: ${isWA.jid}`); + + if (!isWA.exists && !isJidGroup(isWA.jid) && !isWA.jid.includes('@broadcast')) { + if (this.localChatwoot.enabled) { + const body = { + key: { remoteJid: isWA.jid }, + }; + + this.chatwootService.eventWhatsapp('contact.is_not_in_wpp', { instanceName: this.instance.name }, body); + } + throw new BadRequestException(isWA); + } + + const sender = isWA.jid; + + try { + if (options?.delay) { + this.logger.verbose('Delaying message'); + + await this.client.presenceSubscribe(sender); + this.logger.verbose('Subscribing to presence'); + + await this.client.sendPresenceUpdate(options?.presence ?? 'composing', sender); + this.logger.verbose('Sending presence update: ' + options?.presence ?? 'composing'); + + await delay(options.delay); + this.logger.verbose('Set delay: ' + options.delay); + + await this.client.sendPresenceUpdate('paused', sender); + this.logger.verbose('Sending presence update: paused'); + } + + const linkPreview = options?.linkPreview != false ? undefined : false; + + let quoted: WAMessage; + + if (options?.quoted) { + const m = options?.quoted; + + const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); + + if (!msg) { + throw 'Message not found'; + } + + quoted = msg; + this.logger.verbose('Quoted message'); + } + + let mentions: string[]; + if (isJidGroup(sender)) { + try { + const group = await this.findGroup({ groupJid: sender }, 'inner'); + + if (!group) { + throw new NotFoundException('Group not found'); + } + + if (options?.mentions) { + this.logger.verbose('Mentions defined'); + + if (options.mentions?.everyOne) { + this.logger.verbose('Mentions everyone'); + + this.logger.verbose('Getting group metadata'); + mentions = group.participants.map((participant) => participant.id); + this.logger.verbose('Getting group metadata for mentions'); + } else if (options.mentions?.mentioned?.length) { + this.logger.verbose('Mentions manually defined'); + mentions = options.mentions.mentioned.map((mention) => { + const jid = this.createJid(mention); + if (isJidGroup(jid)) { + return null; + } + return jid; + }); + } + } + } catch (error) { + throw new NotFoundException('Group not found'); + } + } + + const messageSent = await (async () => { + const option = { + quoted, + }; + + if ( + !message['audio'] && + !message['poll'] && + !message['sticker'] && + !message['conversation'] && + sender !== 'status@broadcast' + ) { + if (message['reactionMessage']) { + this.logger.verbose('Sending reaction'); + return await this.client.sendMessage( + sender, + { + react: { + text: message['reactionMessage']['text'], + key: message['reactionMessage']['key'], + }, + } as unknown as AnyMessageContent, + option as unknown as MiscMessageGenerationOptions, + ); + } + } + if (message['conversation']) { + this.logger.verbose('Sending message'); + return await this.client.sendMessage( + sender, + { + text: message['conversation'], + mentions, + linkPreview: linkPreview, + } as unknown as AnyMessageContent, + option as unknown as MiscMessageGenerationOptions, + ); + } + + if (!message['audio'] && !message['poll'] && sender != 'status@broadcast') { + this.logger.verbose('Sending message'); + return await this.client.sendMessage( + sender, + { + forward: { + key: { remoteJid: this.instance.wuid, fromMe: true }, + message, + }, + mentions, + }, + option as unknown as MiscMessageGenerationOptions, + ); + } + + if (sender === 'status@broadcast') { + this.logger.verbose('Sending message'); + return await this.client.sendMessage( + sender, + message['status'].content as unknown as AnyMessageContent, + { + backgroundColor: message['status'].option.backgroundColor, + font: message['status'].option.font, + statusJidList: message['status'].option.statusJidList, + } as unknown as MiscMessageGenerationOptions, + ); + } + + this.logger.verbose('Sending message'); + return await this.client.sendMessage( + sender, + message as unknown as AnyMessageContent, + option as unknown as MiscMessageGenerationOptions, + ); + })(); + + const contentMsg = messageSent.message[getContentType(messageSent.message)] as any; + + const messageRaw: MessageRaw = { + key: messageSent.key, + pushName: messageSent.pushName, + message: { ...messageSent.message }, + contextInfo: contentMsg?.contextInfo, + messageType: getContentType(messageSent.message), + messageTimestamp: messageSent.messageTimestamp as number, + owner: this.instance.name, + source: getDevice(messageSent.key.id), + }; + + this.logger.log(messageRaw); + + this.logger.verbose('Sending data to webhook in event SEND_MESSAGE'); + this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); + + if (this.localChatwoot.enabled && !isChatwoot) { + this.chatwootService.eventWhatsapp(Events.SEND_MESSAGE, { instanceName: this.instance.name }, messageRaw); + } + + this.logger.verbose('Inserting message in database'); + await this.repository.message.insert( + [messageRaw], + this.instance.name, + this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE, + ); + + return messageSent; + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + // Instance Controller + + public async sendPresence(data: SendPresenceDto) { + try { + const { number } = data; + + this.logger.verbose(`Check if number "${number}" is WhatsApp`); + const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); + + this.logger.verbose(`Exists: "${isWA.exists}" | jid: ${isWA.jid}`); + if (!isWA.exists && !isJidGroup(isWA.jid) && !isWA.jid.includes('@broadcast')) { + throw new BadRequestException(isWA); + } + + const sender = isWA.jid; + + this.logger.verbose('Sending presence'); + await this.client.presenceSubscribe(sender); + this.logger.verbose('Subscribing to presence'); + + await this.client.sendPresenceUpdate(data.options?.presence ?? 'composing', sender); + this.logger.verbose('Sending presence update: ' + data.options?.presence ?? 'composing'); + + await delay(data.options.delay); + this.logger.verbose('Set delay: ' + data.options.delay); + + await this.client.sendPresenceUpdate('paused', sender); + this.logger.verbose('Sending presence update: paused'); + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + // Send Message Controller + public async textMessage(data: SendTextDto, isChatwoot = false) { + this.logger.verbose('Sending text message'); + return await this.sendMessageWithTyping( + data.number, + { + conversation: data.textMessage.text, + }, + data?.options, + isChatwoot, + ); + } + + public async pollMessage(data: SendPollDto) { + this.logger.verbose('Sending poll message'); + return await this.sendMessageWithTyping( + data.number, + { + poll: { + name: data.pollMessage.name, + selectableCount: data.pollMessage.selectableCount, + values: data.pollMessage.values, + }, + }, + data?.options, + ); + } + + private async formatStatusMessage(status: StatusMessage) { + this.logger.verbose('Formatting status message'); + + if (!status.type) { + throw new BadRequestException('Type is required'); + } + + if (!status.content) { + throw new BadRequestException('Content is required'); + } + + if (status.allContacts) { + this.logger.verbose('All contacts defined as true'); + + this.logger.verbose('Getting contacts from database'); + const contacts = await this.repository.contact.find({ + where: { owner: this.instance.name }, + }); + + if (!contacts.length) { + throw new BadRequestException('Contacts not found'); + } + + this.logger.verbose('Getting contacts with push name'); + status.statusJidList = contacts.filter((contact) => contact.pushName).map((contact) => contact.id); + + this.logger.verbose(status.statusJidList); + } + + if (!status.statusJidList?.length && !status.allContacts) { + throw new BadRequestException('StatusJidList is required'); + } + + if (status.type === 'text') { + this.logger.verbose('Type defined as text'); + + if (!status.backgroundColor) { + throw new BadRequestException('Background color is required'); + } + + if (!status.font) { + throw new BadRequestException('Font is required'); + } + + return { + content: { + text: status.content, + }, + option: { + backgroundColor: status.backgroundColor, + font: status.font, + statusJidList: status.statusJidList, + }, + }; + } + if (status.type === 'image') { + this.logger.verbose('Type defined as image'); + + return { + content: { + image: { + url: status.content, + }, + caption: status.caption, + }, + option: { + statusJidList: status.statusJidList, + }, + }; + } + if (status.type === 'video') { + this.logger.verbose('Type defined as video'); + + return { + content: { + video: { + url: status.content, + }, + caption: status.caption, + }, + option: { + statusJidList: status.statusJidList, + }, + }; + } + if (status.type === 'audio') { + this.logger.verbose('Type defined as audio'); + + this.logger.verbose('Processing audio'); + const convert = await this.processAudio(status.content, 'status@broadcast'); + if (typeof convert === 'string') { + this.logger.verbose('Audio processed'); + const audio = fs.readFileSync(convert).toString('base64'); + + const result = { + content: { + audio: Buffer.from(audio, 'base64'), + ptt: true, + mimetype: 'audio/mp4', + }, + option: { + statusJidList: status.statusJidList, + }, + }; + + fs.unlinkSync(convert); + + return result; + } else { + throw new InternalServerErrorException(convert); + } + } + + throw new BadRequestException('Type not found'); + } + + public async statusMessage(data: SendStatusDto) { + this.logger.verbose('Sending status message'); + const status = await this.formatStatusMessage(data.statusMessage); + + return await this.sendMessageWithTyping('status@broadcast', { + status, + }); + } + + private async prepareMediaMessage(mediaMessage: MediaMessage) { + try { + this.logger.verbose('Preparing media message'); + const prepareMedia = await prepareWAMessageMedia( + { + [mediaMessage.mediatype]: isURL(mediaMessage.media) + ? { url: mediaMessage.media } + : Buffer.from(mediaMessage.media, 'base64'), + } as any, + { upload: this.client.waUploadToServer }, + ); + + const mediaType = mediaMessage.mediatype + 'Message'; + this.logger.verbose('Media type: ' + mediaType); + + if (mediaMessage.mediatype === 'document' && !mediaMessage.fileName) { + this.logger.verbose('If media type is document and file name is not defined then'); + const regex = new RegExp(/.*\/(.+?)\./); + const arrayMatch = regex.exec(mediaMessage.media); + mediaMessage.fileName = arrayMatch[1]; + this.logger.verbose('File name: ' + mediaMessage.fileName); + } + + if (mediaMessage.mediatype === 'image' && !mediaMessage.fileName) { + mediaMessage.fileName = 'image.png'; + } + + if (mediaMessage.mediatype === 'video' && !mediaMessage.fileName) { + mediaMessage.fileName = 'video.mp4'; + } + + let mimetype: string; + + if (mediaMessage.mimetype) { + mimetype = mediaMessage.mimetype; + } else { + if (isURL(mediaMessage.media)) { + const response = await axios.get(mediaMessage.media, { responseType: 'arraybuffer' }); + + mimetype = response.headers['content-type']; + } else { + mimetype = getMIMEType(mediaMessage.fileName); + } + } + + this.logger.verbose('Mimetype: ' + mimetype); + + prepareMedia[mediaType].caption = mediaMessage?.caption; + prepareMedia[mediaType].mimetype = mimetype; + prepareMedia[mediaType].fileName = mediaMessage.fileName; + + if (mediaMessage.mediatype === 'video') { + this.logger.verbose('Is media type video then set gif playback as false'); + prepareMedia[mediaType].jpegThumbnail = Uint8Array.from( + readFileSync(join(process.cwd(), 'public', 'images', 'video-cover.png')), + ); + prepareMedia[mediaType].gifPlayback = false; + } + + this.logger.verbose('Generating wa message from content'); + return generateWAMessageFromContent( + '', + { [mediaType]: { ...prepareMedia[mediaType] } }, + { userJid: this.instance.wuid }, + ); + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(error?.toString() || error); + } + } + + private async convertToWebP(image: string, number: string) { + try { + this.logger.verbose('Converting image to WebP to sticker'); + + let imagePath: string; + const hash = `${number}-${new Date().getTime()}`; + this.logger.verbose('Hash to image name: ' + hash); + + const outputPath = `${join(this.storePath, 'temp', `${hash}.webp`)}`; + this.logger.verbose('Output path: ' + outputPath); + + if (isBase64(image)) { + this.logger.verbose('Image is base64'); + + const base64Data = image.replace(/^data:image\/(jpeg|png|gif);base64,/, ''); + const imageBuffer = Buffer.from(base64Data, 'base64'); + imagePath = `${join(this.storePath, 'temp', `temp-${hash}.png`)}`; + this.logger.verbose('Image path: ' + imagePath); + + await sharp(imageBuffer).toFile(imagePath); + this.logger.verbose('Image created'); + } else { + this.logger.verbose('Image is url'); + + const timestamp = new Date().getTime(); + const url = `${image}?timestamp=${timestamp}`; + this.logger.verbose('including timestamp in url: ' + url); + + const response = await axios.get(url, { responseType: 'arraybuffer' }); + this.logger.verbose('Getting image from url'); + + const imageBuffer = Buffer.from(response.data, 'binary'); + imagePath = `${join(this.storePath, 'temp', `temp-${hash}.png`)}`; + this.logger.verbose('Image path: ' + imagePath); + + await sharp(imageBuffer).toFile(imagePath); + this.logger.verbose('Image created'); + } + + await sharp(imagePath).webp().toFile(outputPath); + this.logger.verbose('Image converted to WebP'); + + fs.unlinkSync(imagePath); + this.logger.verbose('Temp image deleted'); + + return outputPath; + } catch (error) { + console.error('Erro ao converter a imagem para WebP:', error); + } + } + + public async mediaSticker(data: SendStickerDto) { + this.logger.verbose('Sending media sticker'); + const convert = await this.convertToWebP(data.stickerMessage.image, data.number); + const result = await this.sendMessageWithTyping( + data.number, + { + sticker: { url: convert }, + }, + data?.options, + ); + + fs.unlinkSync(convert); + this.logger.verbose('Converted image deleted'); + + return result; + } + + public async mediaMessage(data: SendMediaDto, isChatwoot = false) { + this.logger.verbose('Sending media message'); + const generate = await this.prepareMediaMessage(data.mediaMessage); + + return await this.sendMessageWithTyping(data.number, { ...generate.message }, data?.options, isChatwoot); + } + + public async processAudio(audio: string, number: string) { + this.logger.verbose('Processing audio'); + let tempAudioPath: string; + let outputAudio: string; + + number = number.replace(/\D/g, ''); + const hash = `${number}-${new Date().getTime()}`; + this.logger.verbose('Hash to audio name: ' + hash); + + if (isURL(audio)) { + this.logger.verbose('Audio is url'); + + outputAudio = `${join(this.storePath, 'temp', `${hash}.mp4`)}`; + tempAudioPath = `${join(this.storePath, 'temp', `temp-${hash}.mp3`)}`; + + this.logger.verbose('Output audio path: ' + outputAudio); + this.logger.verbose('Temp audio path: ' + tempAudioPath); + + const timestamp = new Date().getTime(); + const url = `${audio}?timestamp=${timestamp}`; + + this.logger.verbose('Including timestamp in url: ' + url); + + const response = await axios.get(url, { responseType: 'arraybuffer' }); + this.logger.verbose('Getting audio from url'); + + fs.writeFileSync(tempAudioPath, response.data); + } else { + this.logger.verbose('Audio is base64'); + + outputAudio = `${join(this.storePath, 'temp', `${hash}.mp4`)}`; + tempAudioPath = `${join(this.storePath, 'temp', `temp-${hash}.mp3`)}`; + + this.logger.verbose('Output audio path: ' + outputAudio); + this.logger.verbose('Temp audio path: ' + tempAudioPath); + + const audioBuffer = Buffer.from(audio, 'base64'); + fs.writeFileSync(tempAudioPath, audioBuffer); + this.logger.verbose('Temp audio created'); + } + + this.logger.verbose('Converting audio to mp4'); + return new Promise((resolve, reject) => { + exec(`${ffmpegPath.path} -i ${tempAudioPath} -vn -ab 128k -ar 44100 -f ipod ${outputAudio} -y`, (error) => { + fs.unlinkSync(tempAudioPath); + this.logger.verbose('Temp audio deleted'); + + if (error) reject(error); + + this.logger.verbose('Audio converted to mp4'); + resolve(outputAudio); + }); + }); + } + + public async audioWhatsapp(data: SendAudioDto, isChatwoot = false) { + this.logger.verbose('Sending audio whatsapp'); + + if (!data.options?.encoding && data.options?.encoding !== false) { + data.options.encoding = true; + } + + if (data.options?.encoding) { + const convert = await this.processAudio(data.audioMessage.audio, data.number); + if (typeof convert === 'string') { + const audio = fs.readFileSync(convert).toString('base64'); + const result = this.sendMessageWithTyping( + data.number, + { + audio: Buffer.from(audio, 'base64'), + ptt: true, + mimetype: 'audio/mp4', + }, + { presence: 'recording', delay: data?.options?.delay }, + isChatwoot, + ); + + fs.unlinkSync(convert); + this.logger.verbose('Converted audio deleted'); + + return result; + } else { + throw new InternalServerErrorException(convert); + } + } + + return await this.sendMessageWithTyping( + data.number, + { + audio: isURL(data.audioMessage.audio) + ? { url: data.audioMessage.audio } + : Buffer.from(data.audioMessage.audio, 'base64'), + ptt: true, + mimetype: 'audio/ogg; codecs=opus', + }, + { presence: 'recording', delay: data?.options?.delay }, + isChatwoot, + ); + } + + public async buttonMessage(data: SendButtonDto) { + this.logger.verbose('Sending button message'); + const embeddedMedia: any = {}; + let mediatype = 'TEXT'; + + if (data.buttonMessage?.mediaMessage) { + mediatype = data.buttonMessage.mediaMessage?.mediatype.toUpperCase() ?? 'TEXT'; + embeddedMedia.mediaKey = mediatype.toLowerCase() + 'Message'; + const generate = await this.prepareMediaMessage(data.buttonMessage.mediaMessage); + embeddedMedia.message = generate.message[embeddedMedia.mediaKey]; + embeddedMedia.contentText = `*${data.buttonMessage.title}*\n\n${data.buttonMessage.description}`; + } + + const btnItems = { + text: data.buttonMessage.buttons.map((btn) => btn.buttonText), + ids: data.buttonMessage.buttons.map((btn) => btn.buttonId), + }; + + if (!arrayUnique(btnItems.text) || !arrayUnique(btnItems.ids)) { + throw new BadRequestException('Button texts cannot be repeated', 'Button IDs cannot be repeated.'); + } + + return await this.sendMessageWithTyping( + data.number, + { + buttonsMessage: { + text: !embeddedMedia?.mediaKey ? data.buttonMessage.title : undefined, + contentText: embeddedMedia?.contentText ?? data.buttonMessage.description, + footerText: data.buttonMessage?.footerText, + buttons: data.buttonMessage.buttons.map((button) => { + return { + buttonText: { + displayText: button.buttonText, + }, + buttonId: button.buttonId, + type: 1, + }; + }), + headerType: proto.Message.ButtonsMessage.HeaderType[mediatype], + [embeddedMedia?.mediaKey]: embeddedMedia?.message, + }, + }, + data?.options, + ); + } + + public async locationMessage(data: SendLocationDto) { + this.logger.verbose('Sending location message'); + return await this.sendMessageWithTyping( + data.number, + { + locationMessage: { + degreesLatitude: data.locationMessage.latitude, + degreesLongitude: data.locationMessage.longitude, + name: data.locationMessage?.name, + address: data.locationMessage?.address, + }, + }, + data?.options, + ); + } + + public async listMessage(data: SendListDto) { + this.logger.verbose('Sending list message'); + return await this.sendMessageWithTyping( + data.number, + { + listMessage: { + title: data.listMessage.title, + description: data.listMessage.description, + buttonText: data.listMessage?.buttonText, + footerText: data.listMessage?.footerText, + sections: data.listMessage.sections, + listType: 2, + }, + }, + data?.options, + ); + } + + public async contactMessage(data: SendContactDto) { + this.logger.verbose('Sending contact message'); + const message: proto.IMessage = {}; + + const vcard = (contact: ContactMessage) => { + this.logger.verbose('Creating vcard'); + let result = 'BEGIN:VCARD\n' + 'VERSION:3.0\n' + `N:${contact.fullName}\n` + `FN:${contact.fullName}\n`; + + if (contact.organization) { + this.logger.verbose('Organization defined'); + result += `ORG:${contact.organization};\n`; + } + + if (contact.email) { + this.logger.verbose('Email defined'); + result += `EMAIL:${contact.email}\n`; + } + + if (contact.url) { + this.logger.verbose('Url defined'); + result += `URL:${contact.url}\n`; + } + + if (!contact.wuid) { + this.logger.verbose('Wuid defined'); + contact.wuid = this.createJid(contact.phoneNumber); + } + + result += `item1.TEL;waid=${contact.wuid}:${contact.phoneNumber}\n` + 'item1.X-ABLabel:Celular\n' + 'END:VCARD'; + + this.logger.verbose('Vcard created'); + return result; + }; + + if (data.contactMessage.length === 1) { + message.contactMessage = { + displayName: data.contactMessage[0].fullName, + vcard: vcard(data.contactMessage[0]), + }; + } else { + message.contactsArrayMessage = { + displayName: `${data.contactMessage.length} contacts`, + contacts: data.contactMessage.map((contact) => { + return { + displayName: contact.fullName, + vcard: vcard(contact), + }; + }), + }; + } + + return await this.sendMessageWithTyping(data.number, { ...message }, data?.options); + } + + public async reactionMessage(data: SendReactionDto) { + this.logger.verbose('Sending reaction message'); + return await this.sendMessageWithTyping(data.reactionMessage.key.remoteJid, { + reactionMessage: { + key: data.reactionMessage.key, + text: data.reactionMessage.reaction, + }, + }); + } + + // Chat Controller + public async whatsappNumber(data: WhatsAppNumberDto) { + this.logger.verbose('Getting whatsapp number'); + + const jids: { + groups: { number: string; jid: string }[]; + broadcast: { number: string; jid: string }[]; + users: { number: string; jid: string; name?: string }[]; + } = { + groups: [], + broadcast: [], + users: [], + }; + + data.numbers.forEach((number) => { + const jid = this.createJid(number); + + if (isJidGroup(jid)) { + jids.groups.push({ number, jid }); + } else if (jid === 'status@broadcast') { + jids.broadcast.push({ number, jid }); + } else { + jids.users.push({ number, jid }); + } + }); + + const onWhatsapp: OnWhatsAppDto[] = []; + + // BROADCAST + onWhatsapp.push(...jids.broadcast.map(({ jid, number }) => new OnWhatsAppDto(jid, false, number))); + + // GROUPS + const groups = await Promise.all( + jids.groups.map(async ({ jid, number }) => { + const group = await this.findGroup({ groupJid: jid }, 'inner'); + + if (!group) { + new OnWhatsAppDto(jid, false, number); + } + + return new OnWhatsAppDto(group.id, !!group?.id, number, group?.subject); + }), + ); + onWhatsapp.push(...groups); + + // USERS + const contacts: ContactRaw[] = await this.repository.contact.findManyById({ + owner: this.instance.name, + ids: jids.users.map(({ jid }) => (jid.startsWith('+') ? jid.substring(1) : jid)), + }); + const verify = await this.client.onWhatsApp( + ...jids.users.map(({ jid }) => (!jid.startsWith('+') ? `+${jid}` : jid)), + ); + const users: OnWhatsAppDto[] = await Promise.all( + jids.users.map(async (user) => { + const MAX_SIMILARITY_THRESHOLD = 0.01; + const isBrWithDigit = user.jid.startsWith('55') && user.jid.slice(4, 5) === '9' && user.jid.length === 28; + const jid = isBrWithDigit ? user.jid.slice(0, 4) + user.jid.slice(5) : user.jid; + + const numberVerified = verify.find((v) => { + const mainJidSimilarity = levenshtein.get(user.jid, v.jid) / Math.max(user.jid.length, v.jid.length); + const jidSimilarity = levenshtein.get(jid, v.jid) / Math.max(jid.length, v.jid.length); + return mainJidSimilarity <= MAX_SIMILARITY_THRESHOLD || jidSimilarity <= MAX_SIMILARITY_THRESHOLD; + }); + return { + exists: !!numberVerified?.exists, + jid: numberVerified?.jid || user.jid, + name: contacts.find((c) => c.id === jid)?.pushName, + number: user.number, + }; + }), + ); + + onWhatsapp.push(...users); + + return onWhatsapp; + } + + public async markMessageAsRead(data: ReadMessageDto) { + this.logger.verbose('Marking message as read'); + + try { + const keys: proto.IMessageKey[] = []; + data.read_messages.forEach((read) => { + if (isJidGroup(read.remoteJid) || isJidUser(read.remoteJid)) { + keys.push({ + remoteJid: read.remoteJid, + fromMe: read.fromMe, + id: read.id, + }); + } + }); + await this.client.readMessages(keys); + return { message: 'Read messages', read: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Read messages fail', error.toString()); + } + } + + public async getLastMessage(number: string) { + const messages = await this.fetchMessages({ + where: { + key: { + remoteJid: number, + }, + owner: this.instance.name, + }, + }); + + let lastMessage = messages.pop(); + + for (const message of messages) { + if (message.messageTimestamp >= lastMessage.messageTimestamp) { + lastMessage = message; + } + } + + return lastMessage as unknown as LastMessage; + } + + public async archiveChat(data: ArchiveChatDto) { + this.logger.verbose('Archiving chat'); + try { + let last_message = data.lastMessage; + let number = data.chat; + + if (!last_message && number) { + last_message = await this.getLastMessage(number); + } else { + last_message = data.lastMessage; + last_message.messageTimestamp = last_message?.messageTimestamp ?? Date.now(); + number = last_message?.key?.remoteJid; + } + + if (!last_message || Object.keys(last_message).length === 0) { + throw new NotFoundException('Last message not found'); + } + + await this.client.chatModify( + { + archive: data.archive, + lastMessages: [last_message], + }, + this.createJid(number), + ); + + return { + chatId: number, + archived: true, + }; + } catch (error) { + throw new InternalServerErrorException({ + archived: false, + message: ['An error occurred while archiving the chat. Open a calling.', error.toString()], + }); + } + } + + public async deleteMessage(del: DeleteMessage) { + this.logger.verbose('Deleting message'); + try { + return await this.client.sendMessage(del.remoteJid, { delete: del }); + } catch (error) { + throw new InternalServerErrorException('Error while deleting message for everyone', error?.toString()); + } + } + + public async getBase64FromMediaMessage(data: getBase64FromMediaMessageDto) { + this.logger.verbose('Getting base64 from media message'); + try { + const m = data?.message; + const convertToMp4 = data?.convertToMp4 ?? false; + + const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); + + if (!msg) { + throw 'Message not found'; + } + + for (const subtype of MessageSubtype) { + if (msg.message[subtype]) { + msg.message = msg.message[subtype].message; + } + } + + let mediaMessage: any; + let mediaType: string; + + for (const type of TypeMediaMessage) { + mediaMessage = msg.message[type]; + if (mediaMessage) { + mediaType = type; + break; + } + } + + if (!mediaMessage) { + throw 'The message is not of the media type'; + } + + if (typeof mediaMessage['mediaKey'] === 'object') { + msg.message = JSON.parse(JSON.stringify(msg.message)); + } + + this.logger.verbose('Downloading media message'); + const buffer = await downloadMediaMessage( + { key: msg?.key, message: msg?.message }, + 'buffer', + {}, + { + logger: P({ level: 'error' }) as any, + reuploadRequest: this.client.updateMediaMessage, + }, + ); + const typeMessage = getContentType(msg.message); + + if (convertToMp4 && typeMessage === 'audioMessage') { + this.logger.verbose('Converting audio to mp4'); + const number = msg.key.remoteJid.split('@')[0]; + const convert = await this.processAudio(buffer.toString('base64'), number); + + if (typeof convert === 'string') { + const audio = fs.readFileSync(convert).toString('base64'); + this.logger.verbose('Audio converted to mp4'); + + const result = { + mediaType, + fileName: mediaMessage['fileName'], + caption: mediaMessage['caption'], + size: { + fileLength: mediaMessage['fileLength'], + height: mediaMessage['height'], + width: mediaMessage['width'], + }, + mimetype: 'audio/mp4', + base64: Buffer.from(audio, 'base64').toString('base64'), + }; + + fs.unlinkSync(convert); + this.logger.verbose('Converted audio deleted'); + + this.logger.verbose('Media message downloaded'); + return result; + } + } + + this.logger.verbose('Media message downloaded'); + return { + mediaType, + fileName: mediaMessage['fileName'], + caption: mediaMessage['caption'], + size: { + fileLength: mediaMessage['fileLength'], + height: mediaMessage['height'], + width: mediaMessage['width'], + }, + mimetype: mediaMessage['mimetype'], + base64: buffer.toString('base64'), + }; + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + public async fetchPrivacySettings() { + this.logger.verbose('Fetching privacy settings'); + const privacy = await this.client.fetchPrivacySettings(); + + return { + readreceipts: privacy.readreceipts, + profile: privacy.profile, + status: privacy.status, + online: privacy.online, + last: privacy.last, + groupadd: privacy.groupadd, + }; + } + + public async updatePrivacySettings(settings: PrivacySettingDto) { + this.logger.verbose('Updating privacy settings'); + try { + await this.client.updateReadReceiptsPrivacy(settings.privacySettings.readreceipts); + this.logger.verbose('Read receipts privacy updated'); + + await this.client.updateProfilePicturePrivacy(settings.privacySettings.profile); + this.logger.verbose('Profile picture privacy updated'); + + await this.client.updateStatusPrivacy(settings.privacySettings.status); + this.logger.verbose('Status privacy updated'); + + await this.client.updateOnlinePrivacy(settings.privacySettings.online); + this.logger.verbose('Online privacy updated'); + + await this.client.updateLastSeenPrivacy(settings.privacySettings.last); + this.logger.verbose('Last seen privacy updated'); + + await this.client.updateGroupsAddPrivacy(settings.privacySettings.groupadd); + this.logger.verbose('Groups add privacy updated'); + + this.reloadConnection(); + + return { + update: 'success', + data: { + readreceipts: settings.privacySettings.readreceipts, + profile: settings.privacySettings.profile, + status: settings.privacySettings.status, + online: settings.privacySettings.online, + last: settings.privacySettings.last, + groupadd: settings.privacySettings.groupadd, + }, + }; + } catch (error) { + throw new InternalServerErrorException('Error updating privacy settings', error.toString()); + } + } + + public async fetchBusinessProfile(number: string): Promise { + this.logger.verbose('Fetching business profile'); + try { + const jid = number ? this.createJid(number) : this.instance.wuid; + + const profile = await this.client.getBusinessProfile(jid); + this.logger.verbose('Trying to get business profile'); + + if (!profile) { + const info = await this.whatsappNumber({ numbers: [jid] }); + + return { + isBusiness: false, + message: 'Not is business profile', + ...info?.shift(), + }; + } + + this.logger.verbose('Business profile fetched'); + return { + isBusiness: true, + ...profile, + }; + } catch (error) { + throw new InternalServerErrorException('Error updating profile name', error.toString()); + } + } + + public async updateProfileName(name: string) { + this.logger.verbose('Updating profile name to ' + name); + try { + await this.client.updateProfileName(name); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error updating profile name', error.toString()); + } + } + + public async updateProfileStatus(status: string) { + this.logger.verbose('Updating profile status to: ' + status); + try { + await this.client.updateProfileStatus(status); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error updating profile status', error.toString()); + } + } + + public async updateProfilePicture(picture: string) { + this.logger.verbose('Updating profile picture'); + try { + let pic: WAMediaUpload; + if (isURL(picture)) { + this.logger.verbose('Picture is url'); + + const timestamp = new Date().getTime(); + const url = `${picture}?timestamp=${timestamp}`; + this.logger.verbose('Including timestamp in url: ' + url); + + pic = (await axios.get(url, { responseType: 'arraybuffer' })).data; + this.logger.verbose('Getting picture from url'); + } else if (isBase64(picture)) { + this.logger.verbose('Picture is base64'); + pic = Buffer.from(picture, 'base64'); + this.logger.verbose('Getting picture from base64'); + } else { + throw new BadRequestException('"profilePicture" must be a url or a base64'); + } + + await this.client.updateProfilePicture(this.instance.wuid, pic); + this.logger.verbose('Profile picture updated'); + + this.reloadConnection(); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error updating profile picture', error.toString()); + } + } + + public async removeProfilePicture() { + this.logger.verbose('Removing profile picture'); + try { + await this.client.removeProfilePicture(this.instance.wuid); + + this.reloadConnection(); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error removing profile picture', error.toString()); + } + } + + public async updateMessage(data: UpdateMessageDto) { + try { + const jid = this.createJid(data.number); + + this.logger.verbose('Updating message'); + return await this.client.sendMessage(jid, { + text: data.text, + edit: data.key, + }); + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + public async fetchLabels(): Promise { + this.logger.verbose('Fetching labels'); + const labels = await this.repository.labels.find({ + where: { + owner: this.instance.name, + }, + }); + + return labels.map((label) => ({ + color: label.color, + name: label.name, + id: label.id, + predefinedId: label.predefinedId, + })); + } + + public async handleLabel(data: HandleLabelDto) { + this.logger.verbose('Adding label'); + const whatsappContact = await this.whatsappNumber({ numbers: [data.number] }); + if (whatsappContact.length === 0) { + throw new NotFoundException('Number not found'); + } + const contact = whatsappContact[0]; + if (!contact.exists) { + throw new NotFoundException('Number is not on WhatsApp'); + } + + try { + if (data.action === 'add') { + await this.client.addChatLabel(contact.jid, data.labelId); + + return { numberJid: contact.jid, labelId: data.labelId, add: true }; + } + if (data.action === 'remove') { + await this.client.removeChatLabel(contact.jid, data.labelId); + + return { numberJid: contact.jid, labelId: data.labelId, remove: true }; + } + } catch (error) { + throw new BadRequestException(`Unable to ${data.action} label to chat`, error.toString()); + } + } + + // Group + public async createGroup(create: CreateGroupDto) { + this.logger.verbose('Creating group: ' + create.subject); + try { + const participants = (await this.whatsappNumber({ numbers: create.participants })) + .filter((participant) => participant.exists) + .map((participant) => participant.jid); + const { id } = await this.client.groupCreate(create.subject, participants); + this.logger.verbose('Group created: ' + id); + + if (create?.description) { + this.logger.verbose('Updating group description: ' + create.description); + await this.client.groupUpdateDescription(id, create.description); + } + + if (create?.promoteParticipants) { + this.logger.verbose('Prometing group participants: ' + participants); + await this.updateGParticipant({ + groupJid: id, + action: 'promote', + participants: participants, + }); + } + + this.logger.verbose('Getting group metadata'); + const group = await this.client.groupMetadata(id); + + return group; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException('Error creating group', error.toString()); + } + } + + public async updateGroupPicture(picture: GroupPictureDto) { + this.logger.verbose('Updating group picture'); + try { + let pic: WAMediaUpload; + if (isURL(picture.image)) { + this.logger.verbose('Picture is url'); + + const timestamp = new Date().getTime(); + const url = `${picture.image}?timestamp=${timestamp}`; + this.logger.verbose('Including timestamp in url: ' + url); + + pic = (await axios.get(url, { responseType: 'arraybuffer' })).data; + this.logger.verbose('Getting picture from url'); + } else if (isBase64(picture.image)) { + this.logger.verbose('Picture is base64'); + pic = Buffer.from(picture.image, 'base64'); + this.logger.verbose('Getting picture from base64'); + } else { + throw new BadRequestException('"profilePicture" must be a url or a base64'); + } + await this.client.updateProfilePicture(picture.groupJid, pic); + this.logger.verbose('Group picture updated'); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error update group picture', error.toString()); + } + } + + public async updateGroupSubject(data: GroupSubjectDto) { + this.logger.verbose('Updating group subject to: ' + data.subject); + try { + await this.client.groupUpdateSubject(data.groupJid, data.subject); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error updating group subject', error.toString()); + } + } + + public async updateGroupDescription(data: GroupDescriptionDto) { + this.logger.verbose('Updating group description to: ' + data.description); + try { + await this.client.groupUpdateDescription(data.groupJid, data.description); + + return { update: 'success' }; + } catch (error) { + throw new InternalServerErrorException('Error updating group description', error.toString()); + } + } + + public async findGroup(id: GroupJid, reply: 'inner' | 'out' = 'out') { + this.logger.verbose('Fetching group'); + try { + const group = await this.client.groupMetadata(id.groupJid); + + const picture = await this.profilePicture(group.id); + + return { + id: group.id, + subject: group.subject, + subjectOwner: group.subjectOwner, + subjectTime: group.subjectTime, + pictureUrl: picture.profilePictureUrl, + size: group.participants.length, + creation: group.creation, + owner: group.owner, + desc: group.desc, + descId: group.descId, + restrict: group.restrict, + announce: group.announce, + participants: group.participants, + }; + } catch (error) { + if (reply === 'inner') { + return; + } + throw new NotFoundException('Error fetching group', error.toString()); + } + } + + public async fetchAllGroups(getParticipants: GetParticipant) { + this.logger.verbose('Fetching all groups'); + try { + const fetch = Object.values(await this.client.groupFetchAllParticipating()); + let groups = []; + for (const group of fetch) { + const picture = await this.profilePicture(group.id); + + const result = { + id: group.id, + subject: group.subject, + subjectOwner: group.subjectOwner, + subjectTime: group.subjectTime, + pictureUrl: picture.profilePictureUrl, + size: group.participants.length, + creation: group.creation, + owner: group.owner, + desc: group.desc, + descId: group.descId, + restrict: group.restrict, + announce: group.announce, + }; + + if (getParticipants.getParticipants == 'true') { + result['participants'] = group.participants; + } + + groups = [...groups, result]; + } + + return groups; + } catch (error) { + throw new NotFoundException('Error fetching group', error.toString()); + } + } + + public async inviteCode(id: GroupJid) { + this.logger.verbose('Fetching invite code for group: ' + id.groupJid); + try { + const code = await this.client.groupInviteCode(id.groupJid); + return { inviteUrl: `https://chat.whatsapp.com/${code}`, inviteCode: code }; + } catch (error) { + throw new NotFoundException('No invite code', error.toString()); + } + } + + public async inviteInfo(id: GroupInvite) { + this.logger.verbose('Fetching invite info for code: ' + id.inviteCode); + try { + return await this.client.groupGetInviteInfo(id.inviteCode); + } catch (error) { + throw new NotFoundException('No invite info', id.inviteCode); + } + } + + public async sendInvite(id: GroupSendInvite) { + this.logger.verbose('Sending invite for group: ' + id.groupJid); + try { + const inviteCode = await this.inviteCode({ groupJid: id.groupJid }); + this.logger.verbose('Getting invite code: ' + inviteCode.inviteCode); + + const inviteUrl = inviteCode.inviteUrl; + this.logger.verbose('Invite url: ' + inviteUrl); + + const numbers = id.numbers.map((number) => this.createJid(number)); + const description = id.description ?? ''; + + const msg = `${description}\n\n${inviteUrl}`; + + const message = { + conversation: msg, + }; + + for await (const number of numbers) { + await this.sendMessageWithTyping(number, message); + } + + this.logger.verbose('Invite sent for numbers: ' + numbers.join(', ')); + + return { send: true, inviteUrl }; + } catch (error) { + throw new NotFoundException('No send invite'); + } + } + + public async acceptInviteCode(id: AcceptGroupInvite) { + this.logger.verbose('Joining the group by invitation code: ' + id.inviteCode); + try { + const groupJid = await this.client.groupAcceptInvite(id.inviteCode); + return { accepted: true, groupJid: groupJid }; + } catch (error) { + throw new NotFoundException('Accept invite error', error.toString()); + } + } + + public async revokeInviteCode(id: GroupJid) { + this.logger.verbose('Revoking invite code for group: ' + id.groupJid); + try { + const inviteCode = await this.client.groupRevokeInvite(id.groupJid); + return { revoked: true, inviteCode }; + } catch (error) { + throw new NotFoundException('Revoke error', error.toString()); + } + } + + public async findParticipants(id: GroupJid) { + this.logger.verbose('Fetching participants for group: ' + id.groupJid); + try { + const participants = (await this.client.groupMetadata(id.groupJid)).participants; + const contacts = await this.repository.contact.findManyById({ + owner: this.instance.name, + ids: participants.map((p) => p.id), + }); + const parsedParticipants = participants.map((participant) => { + const contact = contacts.find((c) => c.id === participant.id); + return { + ...participant, + name: participant.name ?? contact?.pushName, + imgUrl: participant.imgUrl ?? contact?.profilePictureUrl, + }; + }); + return { participants: parsedParticipants }; + } catch (error) { + throw new NotFoundException('No participants', error.toString()); + } + } + + public async updateGParticipant(update: GroupUpdateParticipantDto) { + this.logger.verbose('Updating participants'); + try { + const participants = update.participants.map((p) => this.createJid(p)); + const updateParticipants = await this.client.groupParticipantsUpdate( + update.groupJid, + participants, + update.action, + ); + return { updateParticipants: updateParticipants }; + } catch (error) { + throw new BadRequestException('Error updating participants', error.toString()); + } + } + + public async updateGSetting(update: GroupUpdateSettingDto) { + this.logger.verbose('Updating setting for group: ' + update.groupJid); + try { + const updateSetting = await this.client.groupSettingUpdate(update.groupJid, update.action); + return { updateSetting: updateSetting }; + } catch (error) { + throw new BadRequestException('Error updating setting', error.toString()); + } + } + + public async toggleEphemeral(update: GroupToggleEphemeralDto) { + this.logger.verbose('Toggling ephemeral for group: ' + update.groupJid); + try { + await this.client.groupToggleEphemeral(update.groupJid, update.expiration); + return { success: true }; + } catch (error) { + throw new BadRequestException('Error updating setting', error.toString()); + } + } + + public async leaveGroup(id: GroupJid) { + this.logger.verbose('Leaving group: ' + id.groupJid); + try { + await this.client.groupLeave(id.groupJid); + return { groupJid: id.groupJid, leave: true }; + } catch (error) { + throw new BadRequestException('Unable to leave the group', error.toString()); + } + } + public async templateMessage() { + console.log('templateMessage'); + } +} diff --git a/src/whatsapp/services/whatsapp.business.service.ts b/src/whatsapp/services/whatsapp.business.service.ts new file mode 100644 index 00000000..8285b299 --- /dev/null +++ b/src/whatsapp/services/whatsapp.business.service.ts @@ -0,0 +1,1148 @@ +import axios from 'axios'; +import { arrayUnique, isURL } from 'class-validator'; +import EventEmitter2 from 'eventemitter2'; +import fs from 'fs/promises'; +import { getMIMEType } from 'node-mime-types'; + +import { ConfigService, Database, WaBusiness } from '../../config/env.config'; +import { BadRequestException, InternalServerErrorException } from '../../exceptions'; +import { RedisCache } from '../../libs/redis.client'; +import { NumberBusiness } from '../dto/chat.dto'; +import { + ContactMessage, + MediaMessage, + Options, + SendButtonDto, + SendContactDto, + SendListDto, + SendLocationDto, + SendMediaDto, + SendReactionDto, + SendTemplateDto, + SendTextDto, +} from '../dto/sendMessage.dto'; +import { ContactRaw, MessageRaw, MessageUpdateRaw, SettingsRaw } from '../models'; +import { RepositoryBroker } from '../repository/repository.manager'; +import { Events, wa } from '../types/wa.types'; +import { CacheService } from './cache.service'; +import { WAStartupService } from './whatsapp.service'; + +export class BusinessStartupService extends WAStartupService { + constructor( + public readonly configService: ConfigService, + public readonly eventEmitter: EventEmitter2, + public readonly repository: RepositoryBroker, + public readonly cache: RedisCache, + public readonly chatwootCache: CacheService, + ) { + super(configService, eventEmitter, repository, chatwootCache); + this.logger.verbose('BusinessStartupService initialized'); + this.cleanStore(); + console.log('BusinessStartupService initialized'); + } + + public stateConnection: wa.StateConnection = { state: 'open' }; + + private phoneNumber: string; + + public get connectionStatus() { + this.logger.verbose('Getting connection status'); + return this.stateConnection; + } + + public async closeClient() { + this.stateConnection = { state: 'close' }; + } + + public get qrCode(): wa.QrCode { + this.logger.verbose('Getting qrcode'); + + return { + pairingCode: this.instance.qrcode?.pairingCode, + code: this.instance.qrcode?.code, + base64: this.instance.qrcode?.base64, + count: this.instance.qrcode?.count, + }; + } + + private async post(message: any, params: string) { + try { + const integration = await this.findIntegration(); + + let urlServer = this.configService.get('WA_BUSINESS').URL; + const version = this.configService.get('WA_BUSINESS').VERSION; + urlServer = `${urlServer}/${version}/${integration.number}/${params}`; + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${integration.token}` }; + const result = await axios.post(urlServer, message, { headers }); + return result.data; + } catch (e) { + this.logger.error(e); + return e.response.data; + } + } + + public async profilePicture(number: string) { + const jid = this.createJid(number); + + this.logger.verbose('Getting profile picture with jid: ' + jid); + try { + this.logger.verbose('Getting profile picture url'); + return { + wuid: jid, + profilePictureUrl: await this.client.profilePictureUrl(jid, 'image'), + }; + } catch (error) { + this.logger.verbose('Profile picture not found'); + return { + wuid: jid, + profilePictureUrl: null, + }; + } + } + + public async getProfileName() { + return null; + } + + public async profilePictureUrl() { + return null; + } + + public async getProfileStatus() { + return null; + } + + public async setWhatsappBusinessProfile(data: NumberBusiness): Promise { + this.logger.verbose('set profile'); + const content = { + messaging_product: 'whatsapp', + about: data.about, + address: data.address, + description: data.description, + vertical: data.vertical, + email: data.email, + websites: data.websites, + profile_picture_handle: data.profilehandle, + }; + return await this.post(content, 'whatsapp_business_profile'); + } + + public async connectToWhatsapp(data?: any): Promise { + if (!data) return; + const content = data.entry[0].changes[0].value; + try { + this.loadWebhook(); + this.loadChatwoot(); + this.loadWebsocket(); + this.loadRabbitmq(); + this.loadSqs(); + this.loadTypebot(); + this.loadChamaai(); + + this.logger.verbose('Creating socket'); + + this.logger.verbose('Socket created'); + + this.eventHandler(content); + + this.logger.verbose('Socket event handler initialized'); + + this.phoneNumber = this.createJid( + content.messages ? content.messages[0].from : content.statuses[0]?.recipient_id, + ); + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(error?.toString()); + } + } + + private async downloadMediaMessage(message: any) { + try { + const integration = await this.findIntegration(); + + const id = message[message.type].id; + let urlServer = this.configService.get('WA_BUSINESS').URL; + const version = this.configService.get('WA_BUSINESS').VERSION; + urlServer = `${urlServer}/${version}/${id}`; + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${integration.token}` }; + let result = await axios.get(urlServer, { headers }); + result = await axios.get(result.data.url, { headers, responseType: 'arraybuffer' }); + return result.data; + } catch (e) { + this.logger.error(e); + } + } + + private messageMediaJson(received: any) { + const message = received.messages[0]; + let content: any = message.type + 'Message'; + content = { [content]: message[message.type] }; + message.context ? (content.extendedTextMessage = { contextInfo: { stanzaId: message.context.id } }) : content; + return content; + } + + private messageInteractiveJson(received: any) { + const message = received.messages[0]; + const content: any = { conversation: message.interactive[message.interactive.type].title }; + message.context ? (content.extendedTextMessage = { contextInfo: { stanzaId: message.context.id } }) : content; + return content; + } + + private messageTextJson(received: any) { + let content: any; + const message = received.messages[0]; + if (message.from === received.metadata.phone_number_id) { + content = { extendedTextMessage: { text: message.text.body } }; + message.context ? (content.extendedTextMessage.contextInfo = { stanzaId: message.context.id }) : content; + } else { + content = { conversation: message.text.body }; + message.context ? (content.extendedTextMessage = { contextInfo: { stanzaId: message.context.id } }) : content; + } + return content; + } + + private messageContactsJson(received: any) { + const message = received.messages[0]; + const content: any = {}; + + const vcard = (contact: any) => { + this.logger.verbose('Creating vcard'); + let result = + 'BEGIN:VCARD\n' + + 'VERSION:3.0\n' + + `N:${contact.name.formatted_name}\n` + + `FN:${contact.name.formatted_name}\n`; + + if (contact.org) { + this.logger.verbose('Organization defined'); + result += `ORG:${contact.org.company};\n`; + } + + if (contact.emails) { + this.logger.verbose('Email defined'); + result += `EMAIL:${contact.emails[0].email}\n`; + } + + if (contact.urls) { + this.logger.verbose('Url defined'); + result += `URL:${contact.urls[0].url}\n`; + } + + if (!contact.phones[0]?.wa_id) { + this.logger.verbose('Wuid defined'); + contact.phones[0].wa_id = this.createJid(contact.phones[0].phone); + } + + result += + `item1.TEL;waid=${contact.phones[0]?.wa_id}:${contact.phones[0].phone}\n` + + 'item1.X-ABLabel:Celular\n' + + 'END:VCARD'; + + this.logger.verbose('Vcard created'); + return result; + }; + + if (message.contacts.length === 1) { + content.contactMessage = { + displayName: message.contacts[0].name.formatted_name, + vcard: vcard(message.contacts[0]), + }; + } else { + content.contactsArrayMessage = { + displayName: `${message.length} contacts`, + contacts: message.map((contact) => { + return { + displayName: contact.name.formatted_name, + vcard: vcard(contact), + }; + }), + }; + } + message.context ? (content.extendedTextMessage = { contextInfo: { stanzaId: message.context.id } }) : content; + return content; + } + + protected async messageHandle(received: any, database: Database, settings: SettingsRaw) { + try { + let messageRaw: MessageRaw; + let pushName: any; + + if (received.contacts) pushName = received.contacts[0].profile.name; + + if (received.messages) { + const key = { + id: received.messages[0].id, + remoteJid: this.phoneNumber, + fromMe: received.messages[0].from === received.metadata.phone_number_id, + }; + if ( + received?.messages[0].document || + received?.messages[0].image || + received?.messages[0].audio || + received?.messages[0].video + ) { + const buffer = await this.downloadMediaMessage(received?.messages[0]); + messageRaw = { + key, + pushName, + message: { + ...this.messageMediaJson(received), + base64: buffer ? buffer.toString('base64') : undefined, + }, + messageType: received.messages[0].type, + messageTimestamp: received.messages[0].timestamp as number, + owner: this.instance.name, + // source: getDevice(received.key.id), + }; + } else if (received?.messages[0].interactive) { + messageRaw = { + key, + pushName, + message: { + ...this.messageInteractiveJson(received), + }, + messageType: 'text', + messageTimestamp: received.messages[0].timestamp as number, + owner: this.instance.name, + // source: getDevice(received.key.id), + }; + } else if (received?.messages[0].contacts) { + messageRaw = { + key, + pushName, + message: { + ...this.messageContactsJson(received), + }, + messageType: 'text', + messageTimestamp: received.messages[0].timestamp as number, + owner: this.instance.name, + // source: getDevice(received.key.id), + }; + } else { + messageRaw = { + key, + pushName, + message: this.messageTextJson(received), + messageType: received.messages[0].type, + messageTimestamp: received.messages[0].timestamp as number, + owner: this.instance.name, + //source: getDevice(received.key.id), + }; + } + + if (this.localSettings.read_messages && received.key.id !== 'status@broadcast') { + // await this.client.readMessages([received.key]); + } + + if (this.localSettings.read_status && received.key.id === 'status@broadcast') { + // await this.client.readMessages([received.key]); + } + + this.logger.log(messageRaw); + + this.logger.verbose('Sending data to webhook in event MESSAGES_UPSERT'); + this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); + + if (this.localChatwoot.enabled) { + const chatwootSentMessage = await this.chatwootService.eventWhatsapp( + Events.MESSAGES_UPSERT, + { instanceName: this.instance.name }, + messageRaw, + ); + + if (chatwootSentMessage?.id) { + messageRaw.chatwoot = { + messageId: chatwootSentMessage.id, + inboxId: chatwootSentMessage.inbox_id, + conversationId: chatwootSentMessage.conversation_id, + }; + } + } + + const typebotSessionRemoteJid = this.localTypebot.sessions?.find( + (session) => session.remoteJid === key.remoteJid, + ); + + if (this.localTypebot.enabled || typebotSessionRemoteJid) { + if (!(this.localTypebot.listening_from_me === false && key.fromMe === true)) { + if (messageRaw.messageType !== 'reactionMessage') + await this.typebotService.sendTypebot( + { instanceName: this.instance.name }, + messageRaw.key.remoteJid, + messageRaw, + ); + } + } + + if (this.localChamaai.enabled && messageRaw.key.fromMe === false && received?.message.type === 'notify') { + await this.chamaaiService.sendChamaai( + { instanceName: this.instance.name }, + messageRaw.key.remoteJid, + messageRaw, + ); + } + + this.logger.verbose('Inserting message in database'); + await this.repository.message.insert([messageRaw], this.instance.name, database.SAVE_DATA.NEW_MESSAGE); + + this.logger.verbose('Verifying contact from message'); + const contact = await this.repository.contact.find({ + where: { owner: this.instance.name, id: key.remoteJid }, + }); + + const contactRaw: ContactRaw = { + id: received.contacts[0].profile.phone, + pushName, + //profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, + owner: this.instance.name, + }; + + if (contactRaw.id === 'status@broadcast') { + this.logger.verbose('Contact is status@broadcast'); + return; + } + + if (contact?.length) { + this.logger.verbose('Contact found in database'); + const contactRaw: ContactRaw = { + id: received.contacts[0].profile.phone, + pushName, + //profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, + owner: this.instance.name, + }; + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); + this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); + + if (this.localChatwoot.enabled) { + await this.chatwootService.eventWhatsapp( + Events.CONTACTS_UPDATE, + { instanceName: this.instance.name }, + contactRaw, + ); + } + + this.logger.verbose('Updating contact in database'); + await this.repository.contact.update([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); + return; + } + + this.logger.verbose('Contact not found in database'); + + this.logger.verbose('Sending data to webhook in event CONTACTS_UPSERT'); + this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); + + this.logger.verbose('Inserting contact in database'); + this.repository.contact.insert([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); + } + this.logger.verbose('Event received: messages.update'); + if (received.statuses) { + for await (const item of received.statuses) { + const key = { + id: item.id, + remoteJid: this.phoneNumber, + fromMe: this.phoneNumber === received.metadata.phone_number_id, + }; + if (settings?.groups_ignore && key.remoteJid.includes('@g.us')) { + this.logger.verbose('group ignored'); + return; + } + if (key.remoteJid !== 'status@broadcast' && !key?.remoteJid?.match(/(:\d+)/)) { + this.logger.verbose('Message update is valid'); + + if (item.status === 'read' && !key.fromMe) return; + + if (item.message === null && item.status === undefined) { + this.logger.verbose('Message deleted'); + + this.logger.verbose('Sending data to webhook in event MESSAGE_DELETE'); + this.sendDataWebhook(Events.MESSAGES_DELETE, key); + + const message: MessageUpdateRaw = { + ...key, + status: 'DELETED', + datetime: Date.now(), + owner: this.instance.name, + }; + + this.logger.verbose(message); + + this.logger.verbose('Inserting message in database'); + await this.repository.messageUpdate.insert( + [message], + this.instance.name, + database.SAVE_DATA.MESSAGE_UPDATE, + ); + + if (this.localChatwoot.enabled) { + this.chatwootService.eventWhatsapp( + Events.MESSAGES_DELETE, + { instanceName: this.instance.name }, + { key: key }, + ); + } + + return; + } + + const message: MessageUpdateRaw = { + ...key, + status: item.status.toUpperCase(), + datetime: Date.now(), + owner: this.instance.name, + }; + + this.logger.verbose(message); + + this.logger.verbose('Sending data to webhook in event MESSAGES_UPDATE'); + this.sendDataWebhook(Events.MESSAGES_UPDATE, message); + + this.logger.verbose('Inserting message in database'); + this.repository.messageUpdate.insert([message], this.instance.name, database.SAVE_DATA.MESSAGE_UPDATE); + } + } + } + } catch (error) { + this.logger.error(error); + } + } + + protected async eventHandler(content: any) { + this.logger.verbose('Initializing event handler'); + const database = this.configService.get('DATABASE'); + const settings = await this.findSettings(); + + this.logger.verbose('Listening event: messages.statuses'); + this.messageHandle(content, database, settings); + } + + protected async sendMessageWithTyping(number: string, message: any, options?: Options, isChatwoot = false) { + this.logger.verbose('Sending message with typing'); + try { + let quoted: any; + const linkPreview = options?.linkPreview != false ? undefined : false; + if (options?.quoted) { + const m = options?.quoted; + + const msg = m?.key; + + if (!msg) { + throw 'Message not found'; + } + + quoted = msg; + this.logger.verbose('Quoted message'); + } + + let content: any; + const messageSent = await (async () => { + if (message['reactionMessage']) { + this.logger.verbose('Sending reaction'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: 'reaction', + to: number.replace(/\D/g, ''), + reaction: { + message_id: message['reactionMessage']['key']['id'], + emoji: message['reactionMessage']['text'], + }, + context: { message_id: quoted.id }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + return await this.post(content, 'messages'); + } + if (message['locationMessage']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: 'location', + to: number.replace(/\D/g, ''), + location: { + longitude: message['locationMessage']['degreesLongitude'], + latitude: message['locationMessage']['degreesLatitude'], + name: message['locationMessage']['name'], + address: message['locationMessage']['address'], + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + return await this.post(content, 'messages'); + } + if (message['contacts']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: 'contacts', + to: number.replace(/\D/g, ''), + contacts: message['contacts'], + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + message = message['message']; + return await this.post(content, 'messages'); + } + if (message['conversation']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: 'text', + to: number.replace(/\D/g, ''), + text: { + body: message['conversation'], + preview_url: linkPreview, + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + return await this.post(content, 'messages'); + } + if (message['media']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: message['mediaType'], + to: number.replace(/\D/g, ''), + [message['mediaType']]: { + [message['type']]: message['id'], + preview_url: linkPreview, + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + return await this.post(content, 'messages'); + } + if (message['buttons']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: number.replace(/\D/g, ''), + type: 'interactive', + interactive: { + type: 'button', + body: { + text: message['text'] || 'Select', + }, + action: { + buttons: message['buttons'], + }, + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + let formattedText = ''; + for (const item of message['buttons']) { + formattedText += `▶️ ${item.reply?.title}\n`; + } + message = { conversation: `${message['text'] || 'Select'}\n` + formattedText }; + return await this.post(content, 'messages'); + } + if (message['sections']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: number.replace(/\D/g, ''), + type: 'interactive', + interactive: { + type: 'list', + header: { + type: 'text', + text: message['title'], + }, + body: { + text: message['text'], + }, + footer: { + text: message['footerText'], + }, + action: { + button: message['buttonText'], + sections: message['sections'], + }, + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + let formattedText = ''; + for (const section of message['sections']) { + formattedText += `${section?.title}\n`; + for (const row of section.rows) { + formattedText += `${row?.title}\n`; + } + } + message = { conversation: `${message['title']}\n` + formattedText }; + return await this.post(content, 'messages'); + } + if (message['template']) { + this.logger.verbose('Sending message'); + content = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: number.replace(/\D/g, ''), + type: 'template', + template: { + name: message['template']['name'], + language: { + code: message['template']['language'] || 'en_US', + }, + }, + }; + quoted ? (content.context = { message_id: quoted.id }) : content; + message = { conversation: `▶️${message['template']['name']}◀️` }; + return await this.post(content, 'messages'); + } + })(); + + const messageRaw: MessageRaw = { + key: { fromMe: true, id: messageSent?.messages[0]?.id, remoteJid: this.createJid(number) }, + //pushName: messageSent.pushName, + message, + messageType: content.type, + messageTimestamp: (messageSent?.messages[0]?.timestamp as number) || Math.round(new Date().getTime() / 1000), + owner: this.instance.name, + //ource: getDevice(messageSent.key.id), + }; + + this.logger.log(messageRaw); + + this.logger.verbose('Sending data to webhook in event SEND_MESSAGE'); + this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); + + if (this.localChatwoot.enabled && !isChatwoot) { + this.chatwootService.eventWhatsapp(Events.SEND_MESSAGE, { instanceName: this.instance.name }, messageRaw); + } + + this.logger.verbose('Inserting message in database'); + await this.repository.message.insert( + [messageRaw], + this.instance.name, + this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE, + ); + + return messageRaw; + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + // Send Message Controller + public async textMessage(data: SendTextDto, isChatwoot = false) { + this.logger.verbose('Sending text message'); + const res = await this.sendMessageWithTyping( + data.number, + { + conversation: data.textMessage.text, + }, + data?.options, + isChatwoot, + ); + return res; + } + + private async getIdMedia(mediaMessage: any) { + const integration = await this.findIntegration(); + + const formData = new FormData(); + const arquivoBuffer = await fs.readFile(mediaMessage.media); + const arquivoBlob = new Blob([arquivoBuffer], { type: mediaMessage.mimetype }); + formData.append('file', arquivoBlob); + formData.append('typeFile', mediaMessage.mimetype); + formData.append('messaging_product', 'whatsapp'); + const headers = { Authorization: `Bearer ${integration.token}` }; + const res = await axios.post( + process.env.API_URL + '/' + process.env.VERSION + '/' + integration.number + '/media', + formData, + { headers }, + ); + return res.data.id; + } + + protected async prepareMediaMessage(mediaMessage: MediaMessage) { + try { + this.logger.verbose('Preparing media message'); + + const mediaType = mediaMessage.mediatype + 'Message'; + this.logger.verbose('Media type: ' + mediaType); + + if (mediaMessage.mediatype === 'document' && !mediaMessage.fileName) { + this.logger.verbose('If media type is document and file name is not defined then'); + const regex = new RegExp(/.*\/(.+?)\./); + const arrayMatch = regex.exec(mediaMessage.media); + mediaMessage.fileName = arrayMatch[1]; + this.logger.verbose('File name: ' + mediaMessage.fileName); + } + + if (mediaMessage.mediatype === 'image' && !mediaMessage.fileName) { + mediaMessage.fileName = 'image.png'; + } + + if (mediaMessage.mediatype === 'video' && !mediaMessage.fileName) { + mediaMessage.fileName = 'video.mp4'; + } + + let mimetype: string; + + const prepareMedia: any = { + caption: mediaMessage?.caption, + fileName: mediaMessage.fileName, + mediaType: mediaMessage.mediatype, + media: mediaMessage.media, + gifPlayback: false, + }; + + if (mediaMessage.mimetype) { + mimetype = mediaMessage.mimetype; + } else { + if (isURL(mediaMessage.media)) { + prepareMedia.id = mediaMessage.media; + prepareMedia.type = 'link'; + } else { + mimetype = getMIMEType(mediaMessage.fileName); + const id = await this.getIdMedia(prepareMedia); + prepareMedia.id = id; + prepareMedia.type = 'id'; + } + } + + prepareMedia.mimetype = mimetype; + + this.logger.verbose('Generating wa message from content'); + return prepareMedia; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(error?.toString() || error); + } + } + + public async mediaMessage(data: SendMediaDto, isChatwoot = false) { + this.logger.verbose('Sending media message'); + const message = await this.prepareMediaMessage(data.mediaMessage); + + return await this.sendMessageWithTyping(data.number, { ...message }, data?.options, isChatwoot); + } + + public async buttonMessage(data: SendButtonDto) { + this.logger.verbose('Sending button message'); + const embeddedMedia: any = {}; + let mediatype = 'TEXT'; + + if (data.buttonMessage?.mediaMessage) { + mediatype = data.buttonMessage.mediaMessage?.mediatype.toUpperCase() ?? 'TEXT'; + embeddedMedia.mediaKey = mediatype.toLowerCase() + 'Message'; + const generate = await this.prepareMediaMessage(data.buttonMessage.mediaMessage); + embeddedMedia.message = generate.message[embeddedMedia.mediaKey]; + embeddedMedia.contentText = `*${data.buttonMessage.title}*\n\n${data.buttonMessage.description}`; + } + + const btnItems = { + text: data.buttonMessage.buttons.map((btn) => btn.buttonText), + ids: data.buttonMessage.buttons.map((btn) => btn.buttonId), + }; + + if (!arrayUnique(btnItems.text) || !arrayUnique(btnItems.ids)) { + throw new BadRequestException('Button texts cannot be repeated', 'Button IDs cannot be repeated.'); + } + + return await this.sendMessageWithTyping( + data.number, + { + text: !embeddedMedia?.mediaKey ? data.buttonMessage.title : undefined, + buttons: data.buttonMessage.buttons.map((button) => { + return { + type: 'reply', + reply: { + title: button.buttonText, + id: button.buttonId, + }, + }; + }), + [embeddedMedia?.mediaKey]: embeddedMedia?.message, + }, + data?.options, + ); + } + + public async locationMessage(data: SendLocationDto) { + this.logger.verbose('Sending location message'); + return await this.sendMessageWithTyping( + data.number, + { + locationMessage: { + degreesLatitude: data.locationMessage.latitude, + degreesLongitude: data.locationMessage.longitude, + name: data.locationMessage?.name, + address: data.locationMessage?.address, + }, + }, + data?.options, + ); + } + + public async listMessage(data: SendListDto) { + this.logger.verbose('Sending list message'); + const sectionsItems = { + title: data.listMessage.sections.map((list) => list.title), + }; + + if (!arrayUnique(sectionsItems.title)) { + throw new BadRequestException('Section tiles cannot be repeated'); + } + + return await this.sendMessageWithTyping( + data.number, + { + title: data.listMessage.title, + text: data.listMessage.description, + footerText: data.listMessage?.footerText, + buttonText: data.listMessage?.buttonText, + sections: data.listMessage.sections.map((section) => { + return { + title: section.title, + rows: section.rows.map((row) => { + return { + title: row.title, + description: row.description, + id: row.rowId, + }; + }), + }; + }), + }, + data?.options, + ); + } + + public async templateMessage(data: SendTemplateDto, isChatwoot = false) { + this.logger.verbose('Sending text message'); + const res = await this.sendMessageWithTyping( + data.number, + { + template: { + name: data.templateMessage.name, + language: data.templateMessage.language, + }, + }, + data?.options, + isChatwoot, + ); + return res; + } + + public async contactMessage(data: SendContactDto) { + this.logger.verbose('Sending contact message'); + const message: any = {}; + + const vcard = (contact: ContactMessage) => { + this.logger.verbose('Creating vcard'); + let result = 'BEGIN:VCARD\n' + 'VERSION:3.0\n' + `N:${contact.fullName}\n` + `FN:${contact.fullName}\n`; + + if (contact.organization) { + this.logger.verbose('Organization defined'); + result += `ORG:${contact.organization};\n`; + } + + if (contact.email) { + this.logger.verbose('Email defined'); + result += `EMAIL:${contact.email}\n`; + } + + if (contact.url) { + this.logger.verbose('Url defined'); + result += `URL:${contact.url}\n`; + } + + if (!contact.wuid) { + this.logger.verbose('Wuid defined'); + contact.wuid = this.createJid(contact.phoneNumber); + } + + result += `item1.TEL;waid=${contact.wuid}:${contact.phoneNumber}\n` + 'item1.X-ABLabel:Celular\n' + 'END:VCARD'; + + this.logger.verbose('Vcard created'); + return result; + }; + + if (data.contactMessage.length === 1) { + message.contactMessage = { + displayName: data.contactMessage[0].fullName, + vcard: vcard(data.contactMessage[0]), + }; + } else { + message.contactsArrayMessage = { + displayName: `${data.contactMessage.length} contacts`, + contacts: data.contactMessage.map((contact) => { + return { + displayName: contact.fullName, + vcard: vcard(contact), + }; + }), + }; + } + return await this.sendMessageWithTyping( + data.number, + { + contacts: data.contactMessage.map((contact) => { + return { + name: { formatted_name: contact.fullName, first_name: contact.fullName }, + phones: [{ phone: contact.phoneNumber }], + urls: [{ url: contact.url }], + emails: [{ email: contact.email }], + org: { company: contact.organization }, + }; + }), + message, + }, + data?.options, + ); + } + + public async reactionMessage(data: SendReactionDto) { + this.logger.verbose('Sending reaction message'); + return await this.sendMessageWithTyping(data.reactionMessage.key.remoteJid, { + reactionMessage: { + key: data.reactionMessage.key, + text: data.reactionMessage.reaction, + }, + }); + } + + public async getBase64FromMediaMessage(data: any) { + try { + const msg = data.message; + this.logger.verbose('Getting base64 from media message'); + const messageType = msg.messageType + 'Message'; + const mediaMessage = msg.message[messageType]; + + this.logger.verbose('Media message downloaded'); + return { + mediaType: msg.messageType, + fileName: mediaMessage?.fileName, + caption: mediaMessage?.caption, + size: { + fileLength: mediaMessage?.fileLength, + height: mediaMessage?.fileLength, + width: mediaMessage?.width, + }, + mimetype: mediaMessage?.mime_type, + base64: msg.message.base64, + }; + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.toString()); + } + } + + // methods not implemented yet + public async mediaSticker() { + console.log('mediaSticker'); + } + public async audioWhatsapp() { + console.log('audioWhatsapp'); + } + public async pollMessage() { + console.log('pollMessage'); + } + public async statusMessage() { + console.log('statusMessage'); + } + public async reloadConnection() { + console.log('Reloading connection'); + } + public async whatsappNumber() { + console.log('whatsappNumber'); + } + public async markMessageAsRead() { + console.log('markMessageAsRead'); + } + public async archiveChat() { + console.log('archiveChat'); + } + public async deleteMessage() { + console.log('deleteMessage'); + } + public async fetchProfile() { + console.log('fetchProfile'); + } + public async sendPresence() { + console.log('sendPresence'); + } + public async fetchPrivacySettings() { + console.log('fetchPrivacySettings'); + } + public async updatePrivacySettings() { + console.log('updatePrivacySettings'); + } + public async fetchBusinessProfile() { + console.log('fetchBusinessProfile'); + } + public async updateProfileName() { + console.log('updateProfileName'); + } + public async updateProfileStatus() { + console.log('updateProfileStatus'); + } + public async updateProfilePicture() { + console.log('updateProfilePicture'); + } + public async removeProfilePicture() { + console.log('removeProfilePicture'); + } + public async updateMessage() { + console.log('updateMessage'); + } + public async createGroup() { + console.log('createGroup'); + } + public async updateGroupPicture() { + console.log('updateGroupPicture'); + } + public async updateGroupSubject() { + console.log('updateGroupSubject'); + } + public async updateGroupDescription() { + console.log('updateGroupDescription'); + } + public async findGroup() { + console.log('findGroup'); + } + public async fetchAllGroups() { + console.log('fetchAllGroups'); + } + public async inviteCode() { + console.log('inviteCode'); + } + public async inviteInfo() { + console.log('inviteInfo'); + } + public async sendInvite() { + console.log('sendInvite'); + } + public async acceptInviteCode() { + console.log('acceptInviteCode'); + } + public async revokeInviteCode() { + console.log('revokeInviteCode'); + } + public async findParticipants() { + console.log('findParticipants'); + } + public async updateGParticipant() { + console.log('updateGParticipant'); + } + public async updateGSetting() { + console.log('updateGSetting'); + } + public async toggleEphemeral() { + console.log('toggleEphemeral'); + } + public async leaveGroup() { + console.log('leaveGroup'); + } + public async fetchLabels() { + console.log('fetchLabels'); + } + public async handleLabel() { + console.log('handleLabel'); + } +} diff --git a/src/whatsapp/services/whatsapp.service.ts b/src/whatsapp/services/whatsapp.service.ts index c9bd0a58..938767af 100644 --- a/src/whatsapp/services/whatsapp.service.ts +++ b/src/whatsapp/services/whatsapp.service.ts @@ -1,189 +1,75 @@ -import ffmpegPath from '@ffmpeg-installer/ffmpeg'; -import { Boom } from '@hapi/boom'; -import makeWASocket, { - AnyMessageContent, - BufferedEventData, - BufferJSON, - CacheStore, - Chat, - ConnectionState, - Contact, - delay, - DisconnectReason, - downloadMediaMessage, - fetchLatestBaileysVersion, - generateWAMessageFromContent, - getAggregateVotesInPollMessage, - getContentType, - getDevice, - GroupMetadata, - isJidBroadcast, - isJidGroup, - isJidUser, - makeCacheableSignalKeyStore, - MessageUpsertType, - MiscMessageGenerationOptions, - ParticipantAction, - prepareWAMessageMedia, - proto, - useMultiFileAuthState, - UserFacingSocketConfig, - WABrowserDescription, - WAMediaUpload, - WAMessage, - WAMessageUpdate, - WASocket, -} from '@whiskeysockets/baileys'; -import { Label } from '@whiskeysockets/baileys/lib/Types/Label'; -import { LabelAssociation } from '@whiskeysockets/baileys/lib/Types/LabelAssociation'; +import { WASocket } from '@whiskeysockets/baileys'; import axios from 'axios'; -import { exec, execSync } from 'child_process'; -import { arrayUnique, isBase64, isURL } from 'class-validator'; +import { execSync } from 'child_process'; +import { isURL } from 'class-validator'; import EventEmitter2 from 'eventemitter2'; -import levenshtein from 'fast-levenshtein'; -import fs, { existsSync, readFileSync } from 'fs'; -import Long from 'long'; -import NodeCache from 'node-cache'; -import { getMIMEType } from 'node-mime-types'; -import { release } from 'os'; import { join } from 'path'; -import P from 'pino'; -import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; -import qrcodeTerminal from 'qrcode-terminal'; -import sharp from 'sharp'; import { v4 } from 'uuid'; import { Auth, CleanStoreConf, ConfigService, - ConfigSessionPhone, Database, HttpServer, Log, - QrCode, - Redis, Sqs, Webhook, Websocket, } from '../../config/env.config'; import { Logger } from '../../config/logger.config'; -import { INSTANCE_DIR, ROOT_DIR } from '../../config/path.config'; -import { BadRequestException, InternalServerErrorException, NotFoundException } from '../../exceptions'; +import { ROOT_DIR } from '../../config/path.config'; +import { NotFoundException } from '../../exceptions'; import { getAMQP, removeQueues } from '../../libs/amqp.server'; -import { dbserver } from '../../libs/db.connect'; -import { RedisCache } from '../../libs/redis.client'; import { getIO } from '../../libs/socket.server'; import { getSQS, removeQueues as removeQueuesSQS } from '../../libs/sqs.server'; -import { chatwootImport } from '../../utils/chatwoot-import-helper'; -import { makeProxyAgent } from '../../utils/makeProxyAgent'; -import { useMultiFileAuthStateDb } from '../../utils/use-multi-file-auth-state-db'; -import { useMultiFileAuthStateRedisDb } from '../../utils/use-multi-file-auth-state-redis-db'; -import { - ArchiveChatDto, - DeleteMessage, - getBase64FromMediaMessageDto, - LastMessage, - NumberBusiness, - OnWhatsAppDto, - PrivacySettingDto, - ReadMessageDto, - SendPresenceDto, - UpdateMessageDto, - WhatsAppNumberDto, -} from '../dto/chat.dto'; -import { - AcceptGroupInvite, - CreateGroupDto, - GetParticipant, - GroupDescriptionDto, - GroupInvite, - GroupJid, - GroupPictureDto, - GroupSendInvite, - GroupSubjectDto, - GroupToggleEphemeralDto, - GroupUpdateParticipantDto, - GroupUpdateSettingDto, -} from '../dto/group.dto'; -import { InstanceDto } from '../dto/instance.dto'; -import { HandleLabelDto, LabelDto } from '../dto/label.dto'; -import { - ContactMessage, - MediaMessage, - Options, - SendAudioDto, - SendButtonDto, - SendContactDto, - SendListDto, - SendLocationDto, - SendMediaDto, - SendPollDto, - SendReactionDto, - SendStatusDto, - SendStickerDto, - SendTextDto, - StatusMessage, -} from '../dto/sendMessage.dto'; -import { ChamaaiRaw, ProxyRaw, RabbitmqRaw, SettingsRaw, SqsRaw, TypebotRaw } from '../models'; -import { ChatRaw } from '../models/chat.model'; +import { ChamaaiRaw, IntegrationRaw, ProxyRaw, RabbitmqRaw, SettingsRaw, SqsRaw, TypebotRaw } from '../models'; import { ChatwootRaw } from '../models/chatwoot.model'; -import { ContactRaw } from '../models/contact.model'; -import { MessageRaw, MessageUpdateRaw } from '../models/message.model'; import { WebhookRaw } from '../models/webhook.model'; import { WebsocketRaw } from '../models/websocket.model'; import { ContactQuery } from '../repository/contact.repository'; import { MessageQuery } from '../repository/message.repository'; import { MessageUpQuery } from '../repository/messageUp.repository'; import { RepositoryBroker } from '../repository/repository.manager'; -import { Events, MessageSubtype, TypeMediaMessage, wa } from '../types/wa.types'; +import { Events, wa } from '../types/wa.types'; import { waMonitor } from '../whatsapp.module'; import { CacheService } from './cache.service'; import { ChamaaiService } from './chamaai.service'; import { ChatwootService } from './chatwoot.service'; import { TypebotService } from './typebot.service'; -const retryCache = {}; - export class WAStartupService { constructor( - private readonly configService: ConfigService, - private readonly eventEmitter: EventEmitter2, - private readonly repository: RepositoryBroker, - private readonly cache: RedisCache, - private readonly chatwootCache: CacheService, + public readonly configService: ConfigService, + public readonly eventEmitter: EventEmitter2, + public readonly repository: RepositoryBroker, + public readonly chatwootCache: CacheService, ) { this.logger.verbose('WAStartupService initialized'); - this.cleanStore(); - this.instance.qrcode = { count: 0 }; + console.log('WAStartupService initialized'); } - private readonly logger = new Logger(WAStartupService.name); - public readonly instance: wa.Instance = {}; + public readonly logger = new Logger(WAStartupService.name); + public client: WASocket; - private readonly localWebhook: wa.LocalWebHook = {}; - private readonly localChatwoot: wa.LocalChatwoot = {}; - private readonly localSettings: wa.LocalSettings = {}; - private readonly localWebsocket: wa.LocalWebsocket = {}; - private readonly localRabbitmq: wa.LocalRabbitmq = {}; - private readonly localSqs: wa.LocalSqs = {}; + public readonly instance: wa.Instance = {}; + public readonly localWebhook: wa.LocalWebHook = {}; + public readonly localChatwoot: wa.LocalChatwoot = {}; + public readonly localWebsocket: wa.LocalWebsocket = {}; + public readonly localRabbitmq: wa.LocalRabbitmq = {}; + public readonly localSqs: wa.LocalSqs = {}; public readonly localTypebot: wa.LocalTypebot = {}; - private readonly localProxy: wa.LocalProxy = {}; - private readonly localChamaai: wa.LocalChamaai = {}; - public stateConnection: wa.StateConnection = { state: 'close' }; + public readonly localProxy: wa.LocalProxy = {}; + public readonly localChamaai: wa.LocalChamaai = {}; + public readonly localIntegration: wa.LocalIntegration = {}; + public readonly localSettings: wa.LocalSettings = {}; public readonly storePath = join(ROOT_DIR, 'store'); - private readonly msgRetryCounterCache: CacheStore = new NodeCache(); - private readonly userDevicesCache: CacheStore = new NodeCache(); - private endSession = false; - private logBaileys = this.configService.get('LOG').BAILEYS; - private phoneNumber: string; + public chatwootService = new ChatwootService(waMonitor, this.configService, this.repository, this.chatwootCache); - private chatwootService = new ChatwootService(waMonitor, this.configService, this.repository, this.chatwootCache); + public typebotService = new TypebotService(waMonitor, this.configService, this.eventEmitter); - private typebotService = new TypebotService(waMonitor, this.configService, this.eventEmitter); - - private chamaaiService = new ChamaaiService(waMonitor, this.configService); + public chamaaiService = new ChamaaiService(waMonitor, this.configService); public set instanceName(name: string) { this.logger.verbose(`Initializing instance '${name}'`); @@ -222,64 +108,120 @@ export class WAStartupService { return this.instance.wuid; } - public async getProfileName() { - this.logger.verbose('Getting profile name'); + public async loadIntegration() { + this.logger.verbose('Loading webhook'); + const data = await this.repository.integration.find(this.instanceName); + this.localIntegration.integration = data?.integration; + this.logger.verbose(`Integration: ${this.localIntegration.integration}`); - let profileName = this.client.user?.name ?? this.client.user?.verifiedName; - if (!profileName) { - this.logger.verbose('Profile name not found, trying to get from database'); - if (this.configService.get('DATABASE').ENABLED) { - this.logger.verbose('Database enabled, trying to get from database'); - const collection = dbserver - .getClient() - .db(this.configService.get('DATABASE').CONNECTION.DB_PREFIX_NAME + '-instances') - .collection(this.instanceName); - const data = await collection.findOne({ _id: 'creds' }); - if (data) { - this.logger.verbose('Profile name found in database'); - const creds = JSON.parse(JSON.stringify(data), BufferJSON.reviver); - profileName = creds.me?.name || creds.me?.verifiedName; - } - } else if (existsSync(join(INSTANCE_DIR, this.instanceName, 'creds.json'))) { - this.logger.verbose('Profile name found in file'); - const creds = JSON.parse( - readFileSync(join(INSTANCE_DIR, this.instanceName, 'creds.json'), { - encoding: 'utf-8', - }), - ); - profileName = creds.me?.name || creds.me?.verifiedName; - } + this.localIntegration.number = data?.number; + this.logger.verbose(`Integration number: ${this.localIntegration.number}`); + + this.localIntegration.token = data?.token; + this.logger.verbose(`Integration token: ${this.localIntegration.token}`); + + this.logger.verbose('Integration loaded'); + } + + public async setIntegration(data: IntegrationRaw) { + this.logger.verbose('Setting integration'); + await this.repository.integration.create(data, this.instanceName); + this.logger.verbose(`Integration: ${data.integration}`); + this.logger.verbose(`Integration number: ${data.number}`); + this.logger.verbose(`Integration token: ${data.token}`); + Object.assign(this.localIntegration, data); + this.logger.verbose('Integration set'); + } + + public async findIntegration() { + this.logger.verbose('Finding integration'); + const data = await this.repository.integration.find(this.instanceName); + + if (!data) { + this.logger.verbose('Integration not found'); + throw new NotFoundException('Integration not found'); } - this.logger.verbose(`Profile name: ${profileName}`); - return profileName; - } - - public async getProfileStatus() { - this.logger.verbose('Getting profile status'); - const status = await this.client.fetchStatus(this.instance.wuid); - - this.logger.verbose(`Profile status: ${status.status}`); - return status.status; - } - - public get profilePictureUrl() { - this.logger.verbose('Getting profile picture url'); - return this.instance.profilePictureUrl; - } - - public get qrCode(): wa.QrCode { - this.logger.verbose('Getting qrcode'); + this.logger.verbose(`Integration: ${data.integration}`); + this.logger.verbose(`Integration number: ${data.number}`); + this.logger.verbose(`Integration token: ${data.token}`); return { - pairingCode: this.instance.qrcode?.pairingCode, - code: this.instance.qrcode?.code, - base64: this.instance.qrcode?.base64, - count: this.instance.qrcode?.count, + integration: data.integration, + number: data.number, + token: data.token, }; } - private async loadWebhook() { + public async loadSettings() { + this.logger.verbose('Loading settings'); + const data = await this.repository.settings.find(this.instanceName); + this.localSettings.reject_call = data?.reject_call; + this.logger.verbose(`Settings reject_call: ${this.localSettings.reject_call}`); + + this.localSettings.msg_call = data?.msg_call; + this.logger.verbose(`Settings msg_call: ${this.localSettings.msg_call}`); + + this.localSettings.groups_ignore = data?.groups_ignore; + this.logger.verbose(`Settings groups_ignore: ${this.localSettings.groups_ignore}`); + + this.localSettings.always_online = data?.always_online; + this.logger.verbose(`Settings always_online: ${this.localSettings.always_online}`); + + this.localSettings.read_messages = data?.read_messages; + this.logger.verbose(`Settings read_messages: ${this.localSettings.read_messages}`); + + this.localSettings.read_status = data?.read_status; + this.logger.verbose(`Settings read_status: ${this.localSettings.read_status}`); + + this.localSettings.sync_full_history = data?.sync_full_history; + this.logger.verbose(`Settings sync_full_history: ${this.localSettings.sync_full_history}`); + + this.logger.verbose('Settings loaded'); + } + + public async setSettings(data: SettingsRaw) { + this.logger.verbose('Setting settings'); + await this.repository.settings.create(data, this.instanceName); + this.logger.verbose(`Settings reject_call: ${data.reject_call}`); + this.logger.verbose(`Settings msg_call: ${data.msg_call}`); + this.logger.verbose(`Settings groups_ignore: ${data.groups_ignore}`); + this.logger.verbose(`Settings always_online: ${data.always_online}`); + this.logger.verbose(`Settings read_messages: ${data.read_messages}`); + this.logger.verbose(`Settings read_status: ${data.read_status}`); + this.logger.verbose(`Settings sync_full_history: ${data.sync_full_history}`); + Object.assign(this.localSettings, data); + this.logger.verbose('Settings set'); + } + + public async findSettings() { + this.logger.verbose('Finding settings'); + const data = await this.repository.settings.find(this.instanceName); + + if (!data) { + this.logger.verbose('Settings not found'); + return null; + } + + this.logger.verbose(`Settings url: ${data.reject_call}`); + this.logger.verbose(`Settings msg_call: ${data.msg_call}`); + this.logger.verbose(`Settings groups_ignore: ${data.groups_ignore}`); + this.logger.verbose(`Settings always_online: ${data.always_online}`); + this.logger.verbose(`Settings read_messages: ${data.read_messages}`); + this.logger.verbose(`Settings read_status: ${data.read_status}`); + this.logger.verbose(`Settings sync_full_history: ${data.sync_full_history}`); + return { + reject_call: data.reject_call, + msg_call: data.msg_call, + groups_ignore: data.groups_ignore, + always_online: data.always_online, + read_messages: data.read_messages, + read_status: data.read_status, + sync_full_history: data.sync_full_history, + }; + } + + public async loadWebhook() { this.logger.verbose('Loading webhook'); const data = await this.repository.webhook.find(this.instanceName); this.localWebhook.url = data?.url; @@ -330,7 +272,7 @@ export class WAStartupService { }; } - private async loadChatwoot() { + public async loadChatwoot() { this.logger.verbose('Loading chatwoot'); const data = await this.repository.chatwoot.find(this.instanceName); this.localChatwoot.enabled = data?.enabled; @@ -439,77 +381,7 @@ export class WAStartupService { } } - private async loadSettings() { - this.logger.verbose('Loading settings'); - const data = await this.repository.settings.find(this.instanceName); - this.localSettings.reject_call = data?.reject_call; - this.logger.verbose(`Settings reject_call: ${this.localSettings.reject_call}`); - - this.localSettings.msg_call = data?.msg_call; - this.logger.verbose(`Settings msg_call: ${this.localSettings.msg_call}`); - - this.localSettings.groups_ignore = data?.groups_ignore; - this.logger.verbose(`Settings groups_ignore: ${this.localSettings.groups_ignore}`); - - this.localSettings.always_online = data?.always_online; - this.logger.verbose(`Settings always_online: ${this.localSettings.always_online}`); - - this.localSettings.read_messages = data?.read_messages; - this.logger.verbose(`Settings read_messages: ${this.localSettings.read_messages}`); - - this.localSettings.read_status = data?.read_status; - this.logger.verbose(`Settings read_status: ${this.localSettings.read_status}`); - - this.localSettings.sync_full_history = data?.sync_full_history; - this.logger.verbose(`Settings sync_full_history: ${this.localSettings.sync_full_history}`); - - this.logger.verbose('Settings loaded'); - } - - public async setSettings(data: SettingsRaw) { - this.logger.verbose('Setting settings'); - await this.repository.settings.create(data, this.instanceName); - this.logger.verbose(`Settings reject_call: ${data.reject_call}`); - this.logger.verbose(`Settings msg_call: ${data.msg_call}`); - this.logger.verbose(`Settings groups_ignore: ${data.groups_ignore}`); - this.logger.verbose(`Settings always_online: ${data.always_online}`); - this.logger.verbose(`Settings read_messages: ${data.read_messages}`); - this.logger.verbose(`Settings read_status: ${data.read_status}`); - this.logger.verbose(`Settings sync_full_history: ${data.sync_full_history}`); - Object.assign(this.localSettings, data); - this.logger.verbose('Settings set'); - - this.client?.ws?.close(); - } - - public async findSettings() { - this.logger.verbose('Finding settings'); - const data = await this.repository.settings.find(this.instanceName); - - if (!data) { - this.logger.verbose('Settings not found'); - return null; - } - - this.logger.verbose(`Settings url: ${data.reject_call}`); - this.logger.verbose(`Settings msg_call: ${data.msg_call}`); - this.logger.verbose(`Settings groups_ignore: ${data.groups_ignore}`); - this.logger.verbose(`Settings always_online: ${data.always_online}`); - this.logger.verbose(`Settings read_messages: ${data.read_messages}`); - this.logger.verbose(`Settings read_status: ${data.read_status}`); - this.logger.verbose(`Settings sync_full_history: ${data.sync_full_history}`); - return { - reject_call: data.reject_call, - msg_call: data.msg_call, - groups_ignore: data.groups_ignore, - always_online: data.always_online, - read_messages: data.read_messages, - read_status: data.read_status, - sync_full_history: data.sync_full_history, - }; - } - - private async loadWebsocket() { + public async loadWebsocket() { this.logger.verbose('Loading websocket'); const data = await this.repository.websocket.find(this.instanceName); @@ -546,7 +418,7 @@ export class WAStartupService { }; } - private async loadRabbitmq() { + public async loadRabbitmq() { this.logger.verbose('Loading rabbitmq'); const data = await this.repository.rabbitmq.find(this.instanceName); @@ -591,7 +463,7 @@ export class WAStartupService { } } - private async loadSqs() { + public async loadSqs() { this.logger.verbose('Loading sqs'); const data = await this.repository.sqs.find(this.instanceName); @@ -636,7 +508,7 @@ export class WAStartupService { } } - private async loadTypebot() { + public async loadTypebot() { this.logger.verbose('Loading typebot'); const data = await this.repository.typebot.find(this.instanceName); @@ -704,7 +576,7 @@ export class WAStartupService { }; } - private async loadProxy() { + public async loadProxy() { this.logger.verbose('Loading proxy'); const data = await this.repository.proxy.find(this.instanceName); @@ -717,16 +589,12 @@ export class WAStartupService { this.logger.verbose('Proxy loaded'); } - public async setProxy(data: ProxyRaw, reload = true) { + public async setProxy(data: ProxyRaw) { this.logger.verbose('Setting proxy'); await this.repository.proxy.create(data, this.instanceName); this.logger.verbose(`Proxy proxy: ${data.proxy}`); Object.assign(this.localProxy, data); this.logger.verbose('Proxy set'); - - if (reload) { - this.reloadConnection(); - } } public async findProxy() { @@ -744,7 +612,7 @@ export class WAStartupService { }; } - private async loadChamaai() { + public async loadChamaai() { this.logger.verbose('Loading chamaai'); const data = await this.repository.chamaai.find(this.instanceName); @@ -1130,220 +998,7 @@ export class WAStartupService { } } - private async connectionUpdate({ qr, connection, lastDisconnect }: Partial) { - this.logger.verbose('Connection update'); - if (qr) { - this.logger.verbose('QR code found'); - if (this.instance.qrcode.count === this.configService.get('QRCODE').LIMIT) { - this.logger.verbose('QR code limit reached'); - - this.logger.verbose('Sending data to webhook in event QRCODE_UPDATED'); - this.sendDataWebhook(Events.QRCODE_UPDATED, { - message: 'QR code limit reached, please login again', - statusCode: DisconnectReason.badSession, - }); - - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp( - Events.QRCODE_UPDATED, - { instanceName: this.instance.name }, - { - message: 'QR code limit reached, please login again', - statusCode: DisconnectReason.badSession, - }, - ); - } - - this.logger.verbose('Sending data to webhook in event CONNECTION_UPDATE'); - this.sendDataWebhook(Events.CONNECTION_UPDATE, { - instance: this.instance.name, - state: 'refused', - statusReason: DisconnectReason.connectionClosed, - }); - - this.logger.verbose('endSession defined as true'); - this.endSession = true; - - this.logger.verbose('Emmiting event logout.instance'); - return this.eventEmitter.emit('no.connection', this.instance.name); - } - - this.logger.verbose('Incrementing QR code count'); - this.instance.qrcode.count++; - - const color = this.configService.get('QRCODE').COLOR; - - const optsQrcode: QRCodeToDataURLOptions = { - margin: 3, - scale: 4, - errorCorrectionLevel: 'H', - color: { light: '#ffffff', dark: color }, - }; - - if (this.phoneNumber) { - await delay(2000); - this.instance.qrcode.pairingCode = await this.client.requestPairingCode(this.phoneNumber); - } else { - this.instance.qrcode.pairingCode = null; - } - - this.logger.verbose('Generating QR code'); - qrcode.toDataURL(qr, optsQrcode, (error, base64) => { - if (error) { - this.logger.error('Qrcode generate failed:' + error.toString()); - return; - } - - this.instance.qrcode.base64 = base64; - this.instance.qrcode.code = qr; - - this.sendDataWebhook(Events.QRCODE_UPDATED, { - qrcode: { - instance: this.instance.name, - pairingCode: this.instance.qrcode.pairingCode, - code: qr, - base64, - }, - }); - - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp( - Events.QRCODE_UPDATED, - { instanceName: this.instance.name }, - { - qrcode: { - instance: this.instance.name, - pairingCode: this.instance.qrcode.pairingCode, - code: qr, - base64, - }, - }, - ); - } - }); - - this.logger.verbose('Generating QR code in terminal'); - qrcodeTerminal.generate(qr, { small: true }, (qrcode) => - this.logger.log( - `\n{ instance: ${this.instance.name} pairingCode: ${this.instance.qrcode.pairingCode}, qrcodeCount: ${this.instance.qrcode.count} }\n` + - qrcode, - ), - ); - } - - if (connection) { - this.logger.verbose('Connection found'); - this.stateConnection = { - state: connection, - statusReason: (lastDisconnect?.error as Boom)?.output?.statusCode ?? 200, - }; - - this.logger.verbose('Sending data to webhook in event CONNECTION_UPDATE'); - this.sendDataWebhook(Events.CONNECTION_UPDATE, { - instance: this.instance.name, - ...this.stateConnection, - }); - } - - if (connection === 'close') { - this.logger.verbose('Connection closed'); - const shouldReconnect = (lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; - if (shouldReconnect) { - this.logger.verbose('Reconnecting to whatsapp'); - await this.connectToWhatsapp(); - } else { - this.logger.verbose('Do not reconnect to whatsapp'); - this.logger.verbose('Sending data to webhook in event STATUS_INSTANCE'); - this.sendDataWebhook(Events.STATUS_INSTANCE, { - instance: this.instance.name, - status: 'closed', - }); - - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp( - Events.STATUS_INSTANCE, - { instanceName: this.instance.name }, - { - instance: this.instance.name, - status: 'closed', - }, - ); - } - - this.logger.verbose('Emittin event logout.instance'); - this.eventEmitter.emit('logout.instance', this.instance.name, 'inner'); - this.client?.ws?.close(); - this.client.end(new Error('Close connection')); - this.logger.verbose('Connection closed'); - } - } - - if (connection === 'open') { - this.logger.verbose('Connection opened'); - this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); - this.instance.profilePictureUrl = (await this.profilePicture(this.instance.wuid)).profilePictureUrl; - const formattedWuid = this.instance.wuid.split('@')[0].padEnd(30, ' '); - const formattedName = this.instance.name; - this.logger.info( - ` - ┌──────────────────────────────┐ - │ CONNECTED TO WHATSAPP │ - └──────────────────────────────┘`.replace(/^ +/gm, ' '), - ); - this.logger.info(` - wuid: ${formattedWuid} - name: ${formattedName} - `); - - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp( - Events.CONNECTION_UPDATE, - { instanceName: this.instance.name }, - { - instance: this.instance.name, - status: 'open', - }, - ); - } - } - } - - private async getMessage(key: proto.IMessageKey, full = false) { - this.logger.verbose('Getting message with key: ' + JSON.stringify(key)); - try { - const webMessageInfo = (await this.repository.message.find({ - where: { owner: this.instance.name, key: { id: key.id } }, - })) as unknown as proto.IWebMessageInfo[]; - if (full) { - this.logger.verbose('Returning full message'); - return webMessageInfo[0]; - } - if (webMessageInfo[0].message?.pollCreationMessage) { - this.logger.verbose('Returning poll message'); - const messageSecretBase64 = webMessageInfo[0].message?.messageContextInfo?.messageSecret; - - if (typeof messageSecretBase64 === 'string') { - const messageSecret = Buffer.from(messageSecretBase64, 'base64'); - - const msg = { - messageContextInfo: { - messageSecret, - }, - pollCreationMessage: webMessageInfo[0].message?.pollCreationMessage, - }; - - return msg; - } - } - - this.logger.verbose('Returning message'); - return webMessageInfo[0].message; - } catch (error) { - return { conversation: '' }; - } - } - - private cleanStore() { + public cleanStore() { this.logger.verbose('Cronjob to clean store initialized'); const cleanStore = this.configService.get('CLEAN_STORE'); const database = this.configService.get('DATABASE'); @@ -1368,1049 +1023,8 @@ export class WAStartupService { } } - private async defineAuthState() { - this.logger.verbose('Defining auth state'); - const db = this.configService.get('DATABASE'); - const redis = this.configService.get('REDIS'); - - if (redis?.ENABLED) { - this.logger.verbose('Redis enabled'); - this.cache.reference = this.instance.name; - return await useMultiFileAuthStateRedisDb(this.cache); - } - - if (db.SAVE_DATA.INSTANCE && db.ENABLED) { - this.logger.verbose('Database enabled'); - return await useMultiFileAuthStateDb(this.instance.name); - } - - this.logger.verbose('Store file enabled'); - return await useMultiFileAuthState(join(INSTANCE_DIR, this.instance.name)); - } - - public async connectToWhatsapp(number?: string): Promise { - this.logger.verbose('Connecting to whatsapp'); - try { - this.loadWebhook(); - this.loadChatwoot(); - this.loadSettings(); - this.loadWebsocket(); - this.loadRabbitmq(); - this.loadSqs(); - this.loadTypebot(); - this.loadProxy(); - this.loadChamaai(); - - this.instance.authState = await this.defineAuthState(); - - const { version } = await fetchLatestBaileysVersion(); - this.logger.verbose('Baileys version: ' + version); - const session = this.configService.get('CONFIG_SESSION_PHONE'); - const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; - this.logger.verbose('Browser: ' + JSON.stringify(browser)); - - let options; - - if (this.localProxy.enabled) { - this.logger.info('Proxy enabled: ' + this.localProxy.proxy); - - if (this.localProxy.proxy.host.includes('proxyscrape')) { - try { - const response = await axios.get(this.localProxy.proxy.host); - const text = response.data; - const proxyUrls = text.split('\r\n'); - const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); - const proxyUrl = 'http://' + proxyUrls[rand]; - options = { - agent: makeProxyAgent(proxyUrl), - }; - } catch (error) { - this.localProxy.enabled = false; - } - } else { - options = { - agent: makeProxyAgent(this.localProxy.proxy), - }; - } - } - - const socketConfig: UserFacingSocketConfig = { - ...options, - auth: { - creds: this.instance.authState.state.creds, - keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), - }, - logger: P({ level: this.logBaileys }), - printQRInTerminal: false, - browser: number ? ['Chrome (Linux)', session.NAME, release()] : browser, - version, - markOnlineOnConnect: this.localSettings.always_online, - retryRequestDelayMs: 10, - connectTimeoutMs: 60_000, - qrTimeout: 40_000, - defaultQueryTimeoutMs: undefined, - emitOwnEvents: false, - shouldIgnoreJid: (jid) => { - const isGroupJid = this.localSettings.groups_ignore && isJidGroup(jid); - const isBroadcast = !this.localSettings.read_status && isJidBroadcast(jid); - - return isGroupJid || isBroadcast; - }, - msgRetryCounterCache: this.msgRetryCounterCache, - getMessage: async (key) => (await this.getMessage(key)) as Promise, - generateHighQualityLinkPreview: true, - syncFullHistory: this.localSettings.sync_full_history, - shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { - return this.historySyncNotification(msg); - }, - userDevicesCache: this.userDevicesCache, - transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 10 }, - patchMessageBeforeSending(message) { - if ( - message.deviceSentMessage?.message?.listMessage?.listType === - proto.Message.ListMessage.ListType.PRODUCT_LIST - ) { - message = JSON.parse(JSON.stringify(message)); - - message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } - - if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { - message = JSON.parse(JSON.stringify(message)); - - message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } - - return message; - }, - }; - - this.endSession = false; - - this.logger.verbose('Creating socket'); - - this.client = makeWASocket(socketConfig); - - this.logger.verbose('Socket created'); - - this.eventHandler(); - - this.logger.verbose('Socket event handler initialized'); - - this.phoneNumber = number; - - return this.client; - } catch (error) { - this.logger.error(error); - throw new InternalServerErrorException(error?.toString()); - } - } - - public async reloadConnection(): Promise { - try { - this.instance.authState = await this.defineAuthState(); - - const { version } = await fetchLatestBaileysVersion(); - const session = this.configService.get('CONFIG_SESSION_PHONE'); - const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; - - let options; - - if (this.localProxy.enabled) { - this.logger.verbose('Proxy enabled'); - options = { - agent: makeProxyAgent(this.localProxy.proxy), - fetchAgent: makeProxyAgent(this.localProxy.proxy), - }; - } - - const socketConfig: UserFacingSocketConfig = { - ...options, - auth: { - creds: this.instance.authState.state.creds, - keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), - }, - logger: P({ level: this.logBaileys }), - printQRInTerminal: false, - browser: this.phoneNumber ? ['Chrome (Linux)', session.NAME, release()] : browser, - version, - markOnlineOnConnect: this.localSettings.always_online, - retryRequestDelayMs: 10, - connectTimeoutMs: 60_000, - qrTimeout: 40_000, - defaultQueryTimeoutMs: undefined, - emitOwnEvents: false, - shouldIgnoreJid: (jid) => { - const isGroupJid = this.localSettings.groups_ignore && isJidGroup(jid); - const isBroadcast = !this.localSettings.read_status && isJidBroadcast(jid); - - return isGroupJid || isBroadcast; - }, - msgRetryCounterCache: this.msgRetryCounterCache, - getMessage: async (key) => (await this.getMessage(key)) as Promise, - generateHighQualityLinkPreview: true, - syncFullHistory: this.localSettings.sync_full_history, - shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { - return this.historySyncNotification(msg); - }, - userDevicesCache: this.userDevicesCache, - transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 10 }, - patchMessageBeforeSending(message) { - if ( - message.deviceSentMessage?.message?.listMessage?.listType === - proto.Message.ListMessage.ListType.PRODUCT_LIST - ) { - message = JSON.parse(JSON.stringify(message)); - - message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } - - if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { - message = JSON.parse(JSON.stringify(message)); - - message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } - - return message; - }, - }; - - this.client = makeWASocket(socketConfig); - - return this.client; - } catch (error) { - this.logger.error(error); - throw new InternalServerErrorException(error?.toString()); - } - } - - private readonly chatHandle = { - 'chats.upsert': async (chats: Chat[], database: Database) => { - this.logger.verbose('Event received: chats.upsert'); - - this.logger.verbose('Finding chats in database'); - const chatsRepository = await this.repository.chat.find({ - where: { owner: this.instance.name }, - }); - - this.logger.verbose('Verifying if chats exists in database to insert'); - const chatsRaw: ChatRaw[] = []; - for await (const chat of chats) { - if (chatsRepository.find((cr) => cr.id === chat.id)) { - continue; - } - - chatsRaw.push({ id: chat.id, owner: this.instance.wuid }); - } - - this.logger.verbose('Sending data to webhook in event CHATS_UPSERT'); - this.sendDataWebhook(Events.CHATS_UPSERT, chatsRaw); - - this.logger.verbose('Inserting chats in database'); - this.repository.chat.insert(chatsRaw, this.instance.name, database.SAVE_DATA.CHATS); - }, - - 'chats.update': async ( - chats: Partial< - proto.IConversation & { - lastMessageRecvTimestamp?: number; - } & { - conditional: (bufferedData: BufferedEventData) => boolean; - } - >[], - ) => { - this.logger.verbose('Event received: chats.update'); - const chatsRaw: ChatRaw[] = chats.map((chat) => { - return { id: chat.id, owner: this.instance.wuid }; - }); - - this.logger.verbose('Sending data to webhook in event CHATS_UPDATE'); - this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); - }, - - 'chats.delete': async (chats: string[]) => { - this.logger.verbose('Event received: chats.delete'); - - this.logger.verbose('Deleting chats in database'); - chats.forEach( - async (chat) => - await this.repository.chat.delete({ - where: { owner: this.instance.name, id: chat }, - }), - ); - - this.logger.verbose('Sending data to webhook in event CHATS_DELETE'); - this.sendDataWebhook(Events.CHATS_DELETE, [...chats]); - }, - }; - - private readonly contactHandle = { - 'contacts.upsert': async (contacts: Contact[], database: Database) => { - try { - this.logger.verbose('Event received: contacts.upsert'); - - this.logger.verbose('Finding contacts in database'); - const contactsRepository = new Set( - ( - await this.repository.contact.find({ - select: { id: 1, _id: 0 }, - where: { owner: this.instance.name }, - }) - ).map((contact) => contact.id), - ); - - this.logger.verbose('Verifying if contacts exists in database to insert'); - let contactsRaw: ContactRaw[] = []; - - for (const contact of contacts) { - if (contactsRepository.has(contact.id)) { - continue; - } - - contactsRaw.push({ - id: contact.id, - pushName: contact?.name || contact?.verifiedName || contact.id.split('@')[0], - profilePictureUrl: null, - owner: this.instance.name, - }); - } - - this.logger.verbose('Sending data to webhook in event CONTACTS_UPSERT'); - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw); - - this.logger.verbose('Inserting contacts in database'); - this.repository.contact.insert(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); - - if (this.localChatwoot.enabled && this.localChatwoot.import_contacts && contactsRaw.length) { - this.chatwootService.addHistoryContacts({ instanceName: this.instance.name }, contactsRaw); - chatwootImport.importHistoryContacts({ instanceName: this.instance.name }, this.localChatwoot); - } - - // Update profile pictures - contactsRaw = []; - for await (const contact of contacts) { - contactsRaw.push({ - id: contact.id, - pushName: contact?.name || contact?.verifiedName || contact.id.split('@')[0], - profilePictureUrl: (await this.profilePicture(contact.id)).profilePictureUrl, - owner: this.instance.name, - }); - } - - this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw); - - this.logger.verbose('Updating contacts in database'); - this.repository.contact.update(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); - } catch (error) { - this.logger.error(error); - } - }, - - 'contacts.update': async (contacts: Partial[], database: Database) => { - this.logger.verbose('Event received: contacts.update'); - - this.logger.verbose('Verifying if contacts exists in database to update'); - const contactsRaw: ContactRaw[] = []; - for await (const contact of contacts) { - contactsRaw.push({ - id: contact.id, - pushName: contact?.name ?? contact?.verifiedName, - profilePictureUrl: (await this.profilePicture(contact.id)).profilePictureUrl, - owner: this.instance.name, - }); - } - - this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); - this.sendDataWebhook(Events.CONTACTS_UPDATE, contactsRaw); - - this.logger.verbose('Updating contacts in database'); - this.repository.contact.update(contactsRaw, this.instance.name, database.SAVE_DATA.CONTACTS); - }, - }; - - private readonly messageHandle = { - 'messaging-history.set': async ( - { - messages, - chats, - contacts, - }: { - chats: Chat[]; - contacts: Contact[]; - messages: proto.IWebMessageInfo[]; - isLatest: boolean; - }, - database: Database, - ) => { - try { - this.logger.verbose('Event received: messaging-history.set'); - - const instance: InstanceDto = { instanceName: this.instance.name }; - - const daysLimitToImport = this.localChatwoot.enabled ? this.localChatwoot.days_limit_import_messages : 1000; - this.logger.verbose(`Param days limit import messages is: ${daysLimitToImport}`); - - const date = new Date(); - const timestampLimitToImport = new Date(date.setDate(date.getDate() - daysLimitToImport)).getTime() / 1000; - - const maxBatchTimestamp = Math.max(...messages.map((message) => message.messageTimestamp as number)); - - const processBatch = maxBatchTimestamp >= timestampLimitToImport; - - if (!processBatch) { - this.logger.verbose('Batch ignored by maxTimestamp in this batch'); - return; - } - - const chatsRaw: ChatRaw[] = []; - const chatsRepository = new Set( - ( - await this.repository.chat.find({ - select: { id: 1, _id: 0 }, - where: { owner: this.instance.name }, - }) - ).map((chat) => chat.id), - ); - - for (const chat of chats) { - if (chatsRepository.has(chat.id)) { - continue; - } - - chatsRaw.push({ - id: chat.id, - owner: this.instance.name, - lastMsgTimestamp: chat.lastMessageRecvTimestamp, - }); - } - - this.logger.verbose('Sending data to webhook in event CHATS_SET'); - this.sendDataWebhook(Events.CHATS_SET, chatsRaw); - - this.logger.verbose('Inserting chats in database'); - this.repository.chat.insert(chatsRaw, this.instance.name, database.SAVE_DATA.CHATS); - - const messagesRaw: MessageRaw[] = []; - const messagesRepository = new Set( - chatwootImport.getRepositoryMessagesCache(instance) ?? - ( - await this.repository.message.find({ - select: { key: { id: 1 }, _id: 0 }, - where: { owner: this.instance.name }, - }) - ).map((message) => message.key.id), - ); - - if (chatwootImport.getRepositoryMessagesCache(instance) === null) { - chatwootImport.setRepositoryMessagesCache(instance, messagesRepository); - } - - for (const m of messages) { - if (!m.message || !m.key || !m.messageTimestamp) { - continue; - } - - if (Long.isLong(m?.messageTimestamp)) { - m.messageTimestamp = m.messageTimestamp?.toNumber(); - } - - if (m.messageTimestamp <= timestampLimitToImport) { - continue; - } - - if (messagesRepository.has(m.key.id)) { - continue; - } - - messagesRaw.push({ - key: m.key, - pushName: m.pushName || m.key.remoteJid.split('@')[0], - participant: m.participant, - message: { ...m.message }, - messageType: getContentType(m.message), - messageTimestamp: m.messageTimestamp as number, - owner: this.instance.name, - }); - } - - this.logger.verbose('Sending data to webhook in event MESSAGES_SET'); - this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw]); - - this.logger.verbose('Inserting messages in database'); - await this.repository.message.insert(messagesRaw, this.instance.name, database.SAVE_DATA.NEW_MESSAGE); - - if (this.localChatwoot.enabled && this.localChatwoot.import_messages && messagesRaw.length > 0) { - this.chatwootService.addHistoryMessages( - instance, - messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), - ); - } - - await this.contactHandle['contacts.upsert']( - contacts - .filter((c) => !!c.notify ?? !!c.name) - .map((c) => ({ - id: c.id, - name: c.name ?? c.notify, - })), - database, - ); - - contacts = undefined; - messages = undefined; - chats = undefined; - } catch (error) { - this.logger.error(error); - } - }, - - 'messages.upsert': async ( - { - messages, - type, - }: { - messages: proto.IWebMessageInfo[]; - type: MessageUpsertType; - }, - database: Database, - settings: SettingsRaw, - ) => { - try { - this.logger.verbose('Event received: messages.upsert'); - for (const received of messages) { - if ( - (type !== 'notify' && type !== 'append') || - received.message?.protocolMessage || - received.message?.pollUpdateMessage - ) { - this.logger.verbose('message rejected'); - return; - } - - if (Long.isLong(received.messageTimestamp)) { - received.messageTimestamp = received.messageTimestamp?.toNumber(); - } - - if (settings?.groups_ignore && received.key.remoteJid.includes('@g.us')) { - this.logger.verbose('group ignored'); - return; - } - - let messageRaw: MessageRaw; - - const isMedia = - received?.message?.imageMessage || - received?.message?.videoMessage || - received?.message?.stickerMessage || - received?.message?.documentMessage || - received?.message?.audioMessage; - - const contentMsg = received.message[getContentType(received.message)] as any; - - if (this.localWebhook.webhook_base64 === true && isMedia) { - const buffer = await downloadMediaMessage( - { key: received.key, message: received?.message }, - 'buffer', - {}, - { - logger: P({ level: 'error' }) as any, - reuploadRequest: this.client.updateMediaMessage, - }, - ); - messageRaw = { - key: received.key, - pushName: received.pushName, - message: { - ...received.message, - base64: buffer ? buffer.toString('base64') : undefined, - }, - contextInfo: contentMsg?.contextInfo, - messageType: getContentType(received.message), - messageTimestamp: received.messageTimestamp as number, - owner: this.instance.name, - source: getDevice(received.key.id), - }; - } else { - messageRaw = { - key: received.key, - pushName: received.pushName, - message: { ...received.message }, - contextInfo: contentMsg?.contextInfo, - messageType: getContentType(received.message), - messageTimestamp: received.messageTimestamp as number, - owner: this.instance.name, - source: getDevice(received.key.id), - }; - } - - if (this.localSettings.read_messages && received.key.id !== 'status@broadcast') { - await this.client.readMessages([received.key]); - } - - if (this.localSettings.read_status && received.key.id === 'status@broadcast') { - await this.client.readMessages([received.key]); - } - - this.logger.log(messageRaw); - - this.logger.verbose('Sending data to webhook in event MESSAGES_UPSERT'); - this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); - - if (this.localChatwoot.enabled && !received.key.id.includes('@broadcast')) { - const chatwootSentMessage = await this.chatwootService.eventWhatsapp( - Events.MESSAGES_UPSERT, - { instanceName: this.instance.name }, - messageRaw, - ); - - if (chatwootSentMessage?.id) { - messageRaw.chatwoot = { - messageId: chatwootSentMessage.id, - inboxId: chatwootSentMessage.inbox_id, - conversationId: chatwootSentMessage.conversation_id, - }; - } - } - - const typebotSessionRemoteJid = this.localTypebot.sessions?.find( - (session) => session.remoteJid === received.key.remoteJid, - ); - - if ((this.localTypebot.enabled && type === 'notify') || typebotSessionRemoteJid) { - if (!(this.localTypebot.listening_from_me === false && messageRaw.key.fromMe === true)) { - if (messageRaw.messageType !== 'reactionMessage') - await this.typebotService.sendTypebot( - { instanceName: this.instance.name }, - messageRaw.key.remoteJid, - messageRaw, - ); - } - } - - if (this.localChamaai.enabled && messageRaw.key.fromMe === false && type === 'notify') { - await this.chamaaiService.sendChamaai( - { instanceName: this.instance.name }, - messageRaw.key.remoteJid, - messageRaw, - ); - } - - this.logger.verbose('Inserting message in database'); - await this.repository.message.insert([messageRaw], this.instance.name, database.SAVE_DATA.NEW_MESSAGE); - - this.logger.verbose('Verifying contact from message'); - const contact = await this.repository.contact.find({ - where: { owner: this.instance.name, id: received.key.remoteJid }, - }); - - const contactRaw: ContactRaw = { - id: received.key.remoteJid, - pushName: received.pushName, - profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, - owner: this.instance.name, - }; - - if (contactRaw.id === 'status@broadcast') { - this.logger.verbose('Contact is status@broadcast'); - return; - } - - if (contact?.length) { - this.logger.verbose('Contact found in database'); - const contactRaw: ContactRaw = { - id: received.key.remoteJid, - pushName: contact[0].pushName, - profilePictureUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, - owner: this.instance.name, - }; - - this.logger.verbose('Sending data to webhook in event CONTACTS_UPDATE'); - this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); - - if (this.localChatwoot.enabled) { - await this.chatwootService.eventWhatsapp( - Events.CONTACTS_UPDATE, - { instanceName: this.instance.name }, - contactRaw, - ); - } - - this.logger.verbose('Updating contact in database'); - await this.repository.contact.update([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); - return; - } - - this.logger.verbose('Contact not found in database'); - - this.logger.verbose('Sending data to webhook in event CONTACTS_UPSERT'); - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); - - this.logger.verbose('Inserting contact in database'); - this.repository.contact.insert([contactRaw], this.instance.name, database.SAVE_DATA.CONTACTS); - } - } catch (error) { - this.logger.error(error); - } - }, - - 'messages.update': async (args: WAMessageUpdate[], database: Database, settings: SettingsRaw) => { - this.logger.verbose('Event received: messages.update'); - const status: Record = { - 0: 'ERROR', - 1: 'PENDING', - 2: 'SERVER_ACK', - 3: 'DELIVERY_ACK', - 4: 'READ', - 5: 'PLAYED', - }; - for await (const { key, update } of args) { - if (settings?.groups_ignore && key.remoteJid.includes('@g.us')) { - this.logger.verbose('group ignored'); - return; - } - - if (status[update.status] === 'READ' && key.fromMe) { - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp('messages.read', { instanceName: this.instance.name }, { key: key }); - } - } - - // if (key.remoteJid !== 'status@broadcast' && !key?.remoteJid?.match(/(:\d+)/)) { - if (key.remoteJid !== 'status@broadcast') { - this.logger.verbose('Message update is valid'); - - let pollUpdates: any; - if (update.pollUpdates) { - this.logger.verbose('Poll update found'); - - this.logger.verbose('Getting poll message'); - const pollCreation = await this.getMessage(key); - this.logger.verbose(pollCreation); - - if (pollCreation) { - this.logger.verbose('Getting aggregate votes in poll message'); - pollUpdates = getAggregateVotesInPollMessage({ - message: pollCreation as proto.IMessage, - pollUpdates: update.pollUpdates, - }); - } - } - - if (status[update.status] === 'READ' && !key.fromMe) return; - - if (update.message === null && update.status === undefined) { - this.logger.verbose('Message deleted'); - - this.logger.verbose('Sending data to webhook in event MESSAGE_DELETE'); - this.sendDataWebhook(Events.MESSAGES_DELETE, key); - - const message: MessageUpdateRaw = { - ...key, - status: 'DELETED', - datetime: Date.now(), - owner: this.instance.name, - }; - - this.logger.verbose(message); - - this.logger.verbose('Inserting message in database'); - await this.repository.messageUpdate.insert( - [message], - this.instance.name, - database.SAVE_DATA.MESSAGE_UPDATE, - ); - - if (this.localChatwoot.enabled) { - this.chatwootService.eventWhatsapp( - Events.MESSAGES_DELETE, - { instanceName: this.instance.name }, - { key: key }, - ); - } - - return; - } - - const message: MessageUpdateRaw = { - ...key, - status: status[update.status], - datetime: Date.now(), - owner: this.instance.name, - pollUpdates, - }; - - this.logger.verbose(message); - - this.logger.verbose('Sending data to webhook in event MESSAGES_UPDATE'); - this.sendDataWebhook(Events.MESSAGES_UPDATE, message); - - this.logger.verbose('Inserting message in database'); - this.repository.messageUpdate.insert([message], this.instance.name, database.SAVE_DATA.MESSAGE_UPDATE); - } - } - }, - }; - - private readonly groupHandler = { - 'groups.upsert': (groupMetadata: GroupMetadata[]) => { - this.logger.verbose('Event received: groups.upsert'); - - this.logger.verbose('Sending data to webhook in event GROUPS_UPSERT'); - this.sendDataWebhook(Events.GROUPS_UPSERT, groupMetadata); - }, - - 'groups.update': (groupMetadataUpdate: Partial[]) => { - this.logger.verbose('Event received: groups.update'); - - this.logger.verbose('Sending data to webhook in event GROUPS_UPDATE'); - this.sendDataWebhook(Events.GROUPS_UPDATE, groupMetadataUpdate); - }, - - 'group-participants.update': (participantsUpdate: { - id: string; - participants: string[]; - action: ParticipantAction; - }) => { - this.logger.verbose('Event received: group-participants.update'); - - this.logger.verbose('Sending data to webhook in event GROUP_PARTICIPANTS_UPDATE'); - this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, participantsUpdate); - }, - }; - - private readonly labelHandle = { - [Events.LABELS_EDIT]: async (label: Label, database: Database) => { - this.logger.verbose('Event received: labels.edit'); - this.logger.verbose('Finding labels in database'); - const labelsRepository = await this.repository.labels.find({ - where: { owner: this.instance.name }, - }); - - const savedLabel = labelsRepository.find((l) => l.id === label.id); - if (label.deleted && savedLabel) { - this.logger.verbose('Sending data to webhook in event LABELS_EDIT'); - await this.repository.labels.delete({ - where: { owner: this.instance.name, id: label.id }, - }); - this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name }); - return; - } - - const labelName = label.name.replace(/[^\x20-\x7E]/g, ''); - if (!savedLabel || savedLabel.color !== label.color || savedLabel.name !== labelName) { - this.logger.verbose('Sending data to webhook in event LABELS_EDIT'); - await this.repository.labels.insert( - { - color: label.color, - name: labelName, - owner: this.instance.name, - id: label.id, - predefinedId: label.predefinedId, - }, - this.instance.name, - database.SAVE_DATA.LABELS, - ); - this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name }); - } - }, - - [Events.LABELS_ASSOCIATION]: async ( - data: { association: LabelAssociation; type: 'remove' | 'add' }, - database: Database, - ) => { - this.logger.verbose('Sending data to webhook in event LABELS_ASSOCIATION'); - - // Atualiza labels nos chats - if (database.SAVE_DATA.CHATS) { - const chats = await this.repository.chat.find({ - where: { - owner: this.instance.name, - }, - }); - const chat = chats.find((c) => c.id === data.association.chatId); - if (chat) { - let labels = [...chat.labels]; - if (data.type === 'remove') { - labels = labels.filter((label) => label !== data.association.labelId); - } else if (data.type === 'add') { - labels = [...labels, data.association.labelId]; - } - await this.repository.chat.update( - [{ id: chat.id, owner: this.instance.name, labels }], - this.instance.name, - database.SAVE_DATA.CHATS, - ); - } - } - - // Envia dados para o webhook - this.sendDataWebhook(Events.LABELS_ASSOCIATION, { - instance: this.instance.name, - type: data.type, - chatId: data.association.chatId, - labelId: data.association.labelId, - }); - }, - }; - - private eventHandler() { - this.logger.verbose('Initializing event handler'); - this.client.ev.process(async (events) => { - if (!this.endSession) { - this.logger.verbose(`Event received: ${Object.keys(events).join(', ')}`); - const database = this.configService.get('DATABASE'); - const settings = await this.findSettings(); - - if (events.call) { - this.logger.verbose('Listening event: call'); - const call = events.call[0]; - - if (settings?.reject_call && call.status == 'offer') { - this.logger.verbose('Rejecting call'); - this.client.rejectCall(call.id, call.from); - } - - if (settings?.msg_call?.trim().length > 0 && call.status == 'offer') { - this.logger.verbose('Sending message in call'); - const msg = await this.client.sendMessage(call.from, { - text: settings.msg_call, - }); - - this.logger.verbose('Sending data to event messages.upsert'); - this.client.ev.emit('messages.upsert', { - messages: [msg], - type: 'notify', - }); - } - - this.logger.verbose('Sending data to webhook in event CALL'); - this.sendDataWebhook(Events.CALL, call); - } - - if (events['connection.update']) { - this.logger.verbose('Listening event: connection.update'); - this.connectionUpdate(events['connection.update']); - } - - if (events['creds.update']) { - this.logger.verbose('Listening event: creds.update'); - this.instance.authState.saveCreds(); - } - - if (events['messaging-history.set']) { - this.logger.verbose('Listening event: messaging-history.set'); - const payload = events['messaging-history.set']; - this.messageHandle['messaging-history.set'](payload, database); - } - - if (events['messages.upsert']) { - this.logger.verbose('Listening event: messages.upsert'); - const payload = events['messages.upsert']; - if (payload.messages.find((a) => a?.messageStubType === 2)) { - const msg = payload.messages[0]; - retryCache[msg.key.id] = msg; - return; - } - this.messageHandle['messages.upsert'](payload, database, settings); - } - - if (events['messages.update']) { - this.logger.verbose('Listening event: messages.update'); - const payload = events['messages.update']; - payload.forEach((message) => { - if (retryCache[message.key.id]) { - this.client.ev.emit('messages.upsert', { - messages: [message], - type: 'notify', - }); - delete retryCache[message.key.id]; - return; - } - }); - this.messageHandle['messages.update'](payload, database, settings); - } - - if (events['presence.update']) { - this.logger.verbose('Listening event: presence.update'); - const payload = events['presence.update']; - - if (settings.groups_ignore && payload.id.includes('@g.us')) { - this.logger.verbose('group ignored'); - return; - } - this.sendDataWebhook(Events.PRESENCE_UPDATE, payload); - } - - if (!settings?.groups_ignore) { - if (events['groups.upsert']) { - this.logger.verbose('Listening event: groups.upsert'); - const payload = events['groups.upsert']; - this.groupHandler['groups.upsert'](payload); - } - - if (events['groups.update']) { - this.logger.verbose('Listening event: groups.update'); - const payload = events['groups.update']; - this.groupHandler['groups.update'](payload); - } - - if (events['group-participants.update']) { - this.logger.verbose('Listening event: group-participants.update'); - const payload = events['group-participants.update']; - this.groupHandler['group-participants.update'](payload); - } - } - - if (events['chats.upsert']) { - this.logger.verbose('Listening event: chats.upsert'); - const payload = events['chats.upsert']; - this.chatHandle['chats.upsert'](payload, database); - } - - if (events['chats.update']) { - this.logger.verbose('Listening event: chats.update'); - const payload = events['chats.update']; - this.chatHandle['chats.update'](payload); - } - - if (events['chats.delete']) { - this.logger.verbose('Listening event: chats.delete'); - const payload = events['chats.delete']; - this.chatHandle['chats.delete'](payload); - } - - if (events['contacts.upsert']) { - this.logger.verbose('Listening event: contacts.upsert'); - const payload = events['contacts.upsert']; - this.contactHandle['contacts.upsert'](payload, database); - } - - if (events['contacts.update']) { - this.logger.verbose('Listening event: contacts.update'); - const payload = events['contacts.update']; - this.contactHandle['contacts.update'](payload, database); - } - - if (events[Events.LABELS_ASSOCIATION]) { - this.logger.verbose('Listening event: labels.association'); - const payload = events[Events.LABELS_ASSOCIATION]; - this.labelHandle[Events.LABELS_ASSOCIATION](payload, database); - return; - } - - if (events[Events.LABELS_EDIT]) { - this.logger.verbose('Listening event: labels.edit'); - const payload = events[Events.LABELS_EDIT]; - this.labelHandle[Events.LABELS_EDIT](payload, database); - return; - } - } - }); - } - // Check if the number is MX or AR - private formatMXOrARNumber(jid: string): string { + public formatMXOrARNumber(jid: string): string { const countryCode = jid.substring(0, 2); if (Number(countryCode) === 52 || Number(countryCode) === 54) { @@ -2425,7 +1039,7 @@ export class WAStartupService { } // Check if the number is br - private formatBRNumber(jid: string) { + public formatBRNumber(jid: string) { const regexp = new RegExp(/^(\d{2})(\d{2})\d{1}(\d{8})$/); if (regexp.test(jid)) { const match = regexp.exec(jid); @@ -2443,36 +1057,7 @@ export class WAStartupService { } } - private historySyncNotification(msg: proto.Message.IHistorySyncNotification) { - const instance: InstanceDto = { instanceName: this.instance.name }; - - if ( - this.localChatwoot.enabled && - this.localChatwoot.import_messages && - this.isSyncNotificationFromUsedSyncType(msg) - ) { - if (msg.chunkOrder === 1) { - this.chatwootService.startImportHistoryMessages(instance); - } - - if (msg.progress === 100) { - setTimeout(() => { - this.chatwootService.importHistoryMessages(instance); - }, 10000); - } - } - - return true; - } - - private isSyncNotificationFromUsedSyncType(msg: proto.Message.IHistorySyncNotification) { - return ( - (this.localSettings.sync_full_history && msg?.syncType === 2) || - (!this.localSettings.sync_full_history && msg?.syncType === 3) - ); - } - - private createJid(number: string): string { + public createJid(number: string): string { this.logger.verbose('Creating jid with number: ' + number); if (number.includes('@g.us') || number.includes('@s.whatsapp.net') || number.includes('@lid')) { @@ -2515,1154 +1100,6 @@ export class WAStartupService { return `${number}@s.whatsapp.net`; } - public async profilePicture(number: string) { - const jid = this.createJid(number); - - this.logger.verbose('Getting profile picture with jid: ' + jid); - try { - this.logger.verbose('Getting profile picture url'); - return { - wuid: jid, - profilePictureUrl: await this.client.profilePictureUrl(jid, 'image'), - }; - } catch (error) { - this.logger.verbose('Profile picture not found'); - return { - wuid: jid, - profilePictureUrl: null, - }; - } - } - - public async getStatus(number: string) { - const jid = this.createJid(number); - - this.logger.verbose('Getting profile status with jid:' + jid); - try { - this.logger.verbose('Getting status'); - return { - wuid: jid, - status: (await this.client.fetchStatus(jid))?.status, - }; - } catch (error) { - this.logger.verbose('Status not found'); - return { - wuid: jid, - status: null, - }; - } - } - - public async fetchProfile(instanceName: string, number?: string) { - const jid = number ? this.createJid(number) : this.client?.user?.id; - - const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); - - if (!onWhatsapp.exists) { - throw new BadRequestException(onWhatsapp); - } - - this.logger.verbose('Getting profile with jid: ' + jid); - try { - this.logger.verbose('Getting profile info'); - - if (number) { - const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); - const picture = await this.profilePicture(info?.jid); - const status = await this.getStatus(info?.jid); - const business = await this.fetchBusinessProfile(info?.jid); - - return { - wuid: info?.jid || jid, - name: info?.name, - numberExists: info?.exists, - picture: picture?.profilePictureUrl, - status: status?.status, - isBusiness: business.isBusiness, - email: business?.email, - description: business?.description, - website: business?.website?.shift(), - }; - } else { - const info = await waMonitor.instanceInfo(instanceName); - const business = await this.fetchBusinessProfile(jid); - - return { - wuid: jid, - name: info?.instance?.profileName, - numberExists: true, - picture: info?.instance?.profilePictureUrl, - status: info?.instance?.profileStatus, - isBusiness: business.isBusiness, - email: business?.email, - description: business?.description, - website: business?.website?.shift(), - }; - } - } catch (error) { - this.logger.verbose('Profile not found'); - return { - wuid: jid, - name: null, - picture: null, - status: null, - os: null, - isBusiness: false, - }; - } - } - - private async sendMessageWithTyping( - number: string, - message: T, - options?: Options, - isChatwoot = false, - ) { - this.logger.verbose('Sending message with typing'); - - this.logger.verbose(`Check if number "${number}" is WhatsApp`); - const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); - - this.logger.verbose(`Exists: "${isWA.exists}" | jid: ${isWA.jid}`); - - if (!isWA.exists && !isJidGroup(isWA.jid) && !isWA.jid.includes('@broadcast')) { - if (this.localChatwoot.enabled) { - const body = { - key: { remoteJid: isWA.jid }, - }; - - this.chatwootService.eventWhatsapp('contact.is_not_in_wpp', { instanceName: this.instance.name }, body); - } - throw new BadRequestException(isWA); - } - - const sender = isWA.jid; - - try { - if (options?.delay) { - this.logger.verbose('Delaying message'); - - await this.client.presenceSubscribe(sender); - this.logger.verbose('Subscribing to presence'); - - await this.client.sendPresenceUpdate(options?.presence ?? 'composing', sender); - this.logger.verbose('Sending presence update: ' + options?.presence ?? 'composing'); - - await delay(options.delay); - this.logger.verbose('Set delay: ' + options.delay); - - await this.client.sendPresenceUpdate('paused', sender); - this.logger.verbose('Sending presence update: paused'); - } - - const linkPreview = options?.linkPreview != false ? undefined : false; - - let quoted: WAMessage; - - if (options?.quoted) { - const m = options?.quoted; - - const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); - - if (!msg) { - throw 'Message not found'; - } - - quoted = msg; - this.logger.verbose('Quoted message'); - } - - let mentions: string[]; - if (isJidGroup(sender)) { - try { - const group = await this.findGroup({ groupJid: sender }, 'inner'); - - if (!group) { - throw new NotFoundException('Group not found'); - } - - if (options?.mentions) { - this.logger.verbose('Mentions defined'); - - if (options.mentions?.everyOne) { - this.logger.verbose('Mentions everyone'); - - this.logger.verbose('Getting group metadata'); - mentions = group.participants.map((participant) => participant.id); - this.logger.verbose('Getting group metadata for mentions'); - } else if (options.mentions?.mentioned?.length) { - this.logger.verbose('Mentions manually defined'); - mentions = options.mentions.mentioned.map((mention) => { - const jid = this.createJid(mention); - if (isJidGroup(jid)) { - return null; - } - return jid; - }); - } - } - } catch (error) { - throw new NotFoundException('Group not found'); - } - } - - const messageSent = await (async () => { - const option = { - quoted, - }; - - if ( - !message['audio'] && - !message['poll'] && - !message['sticker'] && - !message['conversation'] && - sender !== 'status@broadcast' - ) { - if (message['reactionMessage']) { - this.logger.verbose('Sending reaction'); - return await this.client.sendMessage( - sender, - { - react: { - text: message['reactionMessage']['text'], - key: message['reactionMessage']['key'], - }, - } as unknown as AnyMessageContent, - option as unknown as MiscMessageGenerationOptions, - ); - } - } - if (message['conversation']) { - this.logger.verbose('Sending message'); - return await this.client.sendMessage( - sender, - { - text: message['conversation'], - mentions, - linkPreview: linkPreview, - } as unknown as AnyMessageContent, - option as unknown as MiscMessageGenerationOptions, - ); - } - - if (!message['audio'] && !message['poll'] && sender != 'status@broadcast') { - this.logger.verbose('Sending message'); - return await this.client.sendMessage( - sender, - { - forward: { - key: { remoteJid: this.instance.wuid, fromMe: true }, - message, - }, - mentions, - }, - option as unknown as MiscMessageGenerationOptions, - ); - } - - if (sender === 'status@broadcast') { - this.logger.verbose('Sending message'); - return await this.client.sendMessage( - sender, - message['status'].content as unknown as AnyMessageContent, - { - backgroundColor: message['status'].option.backgroundColor, - font: message['status'].option.font, - statusJidList: message['status'].option.statusJidList, - } as unknown as MiscMessageGenerationOptions, - ); - } - - this.logger.verbose('Sending message'); - return await this.client.sendMessage( - sender, - message as unknown as AnyMessageContent, - option as unknown as MiscMessageGenerationOptions, - ); - })(); - - const contentMsg = messageSent.message[getContentType(messageSent.message)] as any; - - const messageRaw: MessageRaw = { - key: messageSent.key, - pushName: messageSent.pushName, - message: { ...messageSent.message }, - contextInfo: contentMsg?.contextInfo, - messageType: getContentType(messageSent.message), - messageTimestamp: messageSent.messageTimestamp as number, - owner: this.instance.name, - source: getDevice(messageSent.key.id), - }; - - this.logger.log(messageRaw); - - this.logger.verbose('Sending data to webhook in event SEND_MESSAGE'); - this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); - - if (this.localChatwoot.enabled && !isChatwoot) { - this.chatwootService.eventWhatsapp(Events.SEND_MESSAGE, { instanceName: this.instance.name }, messageRaw); - } - - this.logger.verbose('Inserting message in database'); - await this.repository.message.insert( - [messageRaw], - this.instance.name, - this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE, - ); - - return messageSent; - } catch (error) { - this.logger.error(error); - throw new BadRequestException(error.toString()); - } - } - - // Instance Controller - public get connectionStatus() { - this.logger.verbose('Getting connection status'); - return this.stateConnection; - } - - public async sendPresence(data: SendPresenceDto) { - try { - const { number } = data; - - this.logger.verbose(`Check if number "${number}" is WhatsApp`); - const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); - - this.logger.verbose(`Exists: "${isWA.exists}" | jid: ${isWA.jid}`); - if (!isWA.exists && !isJidGroup(isWA.jid) && !isWA.jid.includes('@broadcast')) { - throw new BadRequestException(isWA); - } - - const sender = isWA.jid; - - this.logger.verbose('Sending presence'); - await this.client.presenceSubscribe(sender); - this.logger.verbose('Subscribing to presence'); - - await this.client.sendPresenceUpdate(data.options?.presence ?? 'composing', sender); - this.logger.verbose('Sending presence update: ' + data.options?.presence ?? 'composing'); - - await delay(data.options.delay); - this.logger.verbose('Set delay: ' + data.options.delay); - - await this.client.sendPresenceUpdate('paused', sender); - this.logger.verbose('Sending presence update: paused'); - } catch (error) { - this.logger.error(error); - throw new BadRequestException(error.toString()); - } - } - - // Send Message Controller - public async textMessage(data: SendTextDto, isChatwoot = false) { - this.logger.verbose('Sending text message'); - return await this.sendMessageWithTyping( - data.number, - { - conversation: data.textMessage.text, - }, - data?.options, - isChatwoot, - ); - } - - public async pollMessage(data: SendPollDto) { - this.logger.verbose('Sending poll message'); - return await this.sendMessageWithTyping( - data.number, - { - poll: { - name: data.pollMessage.name, - selectableCount: data.pollMessage.selectableCount, - values: data.pollMessage.values, - }, - }, - data?.options, - ); - } - - private async formatStatusMessage(status: StatusMessage) { - this.logger.verbose('Formatting status message'); - - if (!status.type) { - throw new BadRequestException('Type is required'); - } - - if (!status.content) { - throw new BadRequestException('Content is required'); - } - - if (status.allContacts) { - this.logger.verbose('All contacts defined as true'); - - this.logger.verbose('Getting contacts from database'); - const contacts = await this.repository.contact.find({ - where: { owner: this.instance.name }, - }); - - if (!contacts.length) { - throw new BadRequestException('Contacts not found'); - } - - this.logger.verbose('Getting contacts with push name'); - status.statusJidList = contacts.filter((contact) => contact.pushName).map((contact) => contact.id); - - this.logger.verbose(status.statusJidList); - } - - if (!status.statusJidList?.length && !status.allContacts) { - throw new BadRequestException('StatusJidList is required'); - } - - if (status.type === 'text') { - this.logger.verbose('Type defined as text'); - - if (!status.backgroundColor) { - throw new BadRequestException('Background color is required'); - } - - if (!status.font) { - throw new BadRequestException('Font is required'); - } - - return { - content: { - text: status.content, - }, - option: { - backgroundColor: status.backgroundColor, - font: status.font, - statusJidList: status.statusJidList, - }, - }; - } - if (status.type === 'image') { - this.logger.verbose('Type defined as image'); - - return { - content: { - image: { - url: status.content, - }, - caption: status.caption, - }, - option: { - statusJidList: status.statusJidList, - }, - }; - } - if (status.type === 'video') { - this.logger.verbose('Type defined as video'); - - return { - content: { - video: { - url: status.content, - }, - caption: status.caption, - }, - option: { - statusJidList: status.statusJidList, - }, - }; - } - if (status.type === 'audio') { - this.logger.verbose('Type defined as audio'); - - this.logger.verbose('Processing audio'); - const convert = await this.processAudio(status.content, 'status@broadcast'); - if (typeof convert === 'string') { - this.logger.verbose('Audio processed'); - const audio = fs.readFileSync(convert).toString('base64'); - - const result = { - content: { - audio: Buffer.from(audio, 'base64'), - ptt: true, - mimetype: 'audio/mp4', - }, - option: { - statusJidList: status.statusJidList, - }, - }; - - fs.unlinkSync(convert); - - return result; - } else { - throw new InternalServerErrorException(convert); - } - } - - throw new BadRequestException('Type not found'); - } - - public async statusMessage(data: SendStatusDto) { - this.logger.verbose('Sending status message'); - const status = await this.formatStatusMessage(data.statusMessage); - - return await this.sendMessageWithTyping('status@broadcast', { - status, - }); - } - - private async prepareMediaMessage(mediaMessage: MediaMessage) { - try { - this.logger.verbose('Preparing media message'); - const prepareMedia = await prepareWAMessageMedia( - { - [mediaMessage.mediatype]: isURL(mediaMessage.media) - ? { url: mediaMessage.media } - : Buffer.from(mediaMessage.media, 'base64'), - } as any, - { upload: this.client.waUploadToServer }, - ); - - const mediaType = mediaMessage.mediatype + 'Message'; - this.logger.verbose('Media type: ' + mediaType); - - if (mediaMessage.mediatype === 'document' && !mediaMessage.fileName) { - this.logger.verbose('If media type is document and file name is not defined then'); - const regex = new RegExp(/.*\/(.+?)\./); - const arrayMatch = regex.exec(mediaMessage.media); - mediaMessage.fileName = arrayMatch[1]; - this.logger.verbose('File name: ' + mediaMessage.fileName); - } - - if (mediaMessage.mediatype === 'image' && !mediaMessage.fileName) { - mediaMessage.fileName = 'image.png'; - } - - if (mediaMessage.mediatype === 'video' && !mediaMessage.fileName) { - mediaMessage.fileName = 'video.mp4'; - } - - let mimetype: string; - - if (mediaMessage.mimetype) { - mimetype = mediaMessage.mimetype; - } else { - if (isURL(mediaMessage.media)) { - const response = await axios.get(mediaMessage.media, { responseType: 'arraybuffer' }); - - mimetype = response.headers['content-type']; - } else { - mimetype = getMIMEType(mediaMessage.fileName); - } - } - - this.logger.verbose('Mimetype: ' + mimetype); - - prepareMedia[mediaType].caption = mediaMessage?.caption; - prepareMedia[mediaType].mimetype = mimetype; - prepareMedia[mediaType].fileName = mediaMessage.fileName; - - if (mediaMessage.mediatype === 'video') { - this.logger.verbose('Is media type video then set gif playback as false'); - prepareMedia[mediaType].jpegThumbnail = Uint8Array.from( - readFileSync(join(process.cwd(), 'public', 'images', 'video-cover.png')), - ); - prepareMedia[mediaType].gifPlayback = false; - } - - this.logger.verbose('Generating wa message from content'); - return generateWAMessageFromContent( - '', - { [mediaType]: { ...prepareMedia[mediaType] } }, - { userJid: this.instance.wuid }, - ); - } catch (error) { - this.logger.error(error); - throw new InternalServerErrorException(error?.toString() || error); - } - } - - private async convertToWebP(image: string, number: string) { - try { - this.logger.verbose('Converting image to WebP to sticker'); - - let imagePath: string; - const hash = `${number}-${new Date().getTime()}`; - this.logger.verbose('Hash to image name: ' + hash); - - const outputPath = `${join(this.storePath, 'temp', `${hash}.webp`)}`; - this.logger.verbose('Output path: ' + outputPath); - - if (isBase64(image)) { - this.logger.verbose('Image is base64'); - - const base64Data = image.replace(/^data:image\/(jpeg|png|gif);base64,/, ''); - const imageBuffer = Buffer.from(base64Data, 'base64'); - imagePath = `${join(this.storePath, 'temp', `temp-${hash}.png`)}`; - this.logger.verbose('Image path: ' + imagePath); - - await sharp(imageBuffer).toFile(imagePath); - this.logger.verbose('Image created'); - } else { - this.logger.verbose('Image is url'); - - const timestamp = new Date().getTime(); - const url = `${image}?timestamp=${timestamp}`; - this.logger.verbose('including timestamp in url: ' + url); - - const response = await axios.get(url, { responseType: 'arraybuffer' }); - this.logger.verbose('Getting image from url'); - - const imageBuffer = Buffer.from(response.data, 'binary'); - imagePath = `${join(this.storePath, 'temp', `temp-${hash}.png`)}`; - this.logger.verbose('Image path: ' + imagePath); - - await sharp(imageBuffer).toFile(imagePath); - this.logger.verbose('Image created'); - } - - await sharp(imagePath).webp().toFile(outputPath); - this.logger.verbose('Image converted to WebP'); - - fs.unlinkSync(imagePath); - this.logger.verbose('Temp image deleted'); - - return outputPath; - } catch (error) { - console.error('Erro ao converter a imagem para WebP:', error); - } - } - - public async mediaSticker(data: SendStickerDto) { - this.logger.verbose('Sending media sticker'); - const convert = await this.convertToWebP(data.stickerMessage.image, data.number); - const result = await this.sendMessageWithTyping( - data.number, - { - sticker: { url: convert }, - }, - data?.options, - ); - - fs.unlinkSync(convert); - this.logger.verbose('Converted image deleted'); - - return result; - } - - public async mediaMessage(data: SendMediaDto, isChatwoot = false) { - this.logger.verbose('Sending media message'); - const generate = await this.prepareMediaMessage(data.mediaMessage); - - return await this.sendMessageWithTyping(data.number, { ...generate.message }, data?.options, isChatwoot); - } - - public async processAudio(audio: string, number: string) { - this.logger.verbose('Processing audio'); - let tempAudioPath: string; - let outputAudio: string; - - number = number.replace(/\D/g, ''); - const hash = `${number}-${new Date().getTime()}`; - this.logger.verbose('Hash to audio name: ' + hash); - - if (isURL(audio)) { - this.logger.verbose('Audio is url'); - - outputAudio = `${join(this.storePath, 'temp', `${hash}.mp4`)}`; - tempAudioPath = `${join(this.storePath, 'temp', `temp-${hash}.mp3`)}`; - - this.logger.verbose('Output audio path: ' + outputAudio); - this.logger.verbose('Temp audio path: ' + tempAudioPath); - - const timestamp = new Date().getTime(); - const url = `${audio}?timestamp=${timestamp}`; - - this.logger.verbose('Including timestamp in url: ' + url); - - const response = await axios.get(url, { responseType: 'arraybuffer' }); - this.logger.verbose('Getting audio from url'); - - fs.writeFileSync(tempAudioPath, response.data); - } else { - this.logger.verbose('Audio is base64'); - - outputAudio = `${join(this.storePath, 'temp', `${hash}.mp4`)}`; - tempAudioPath = `${join(this.storePath, 'temp', `temp-${hash}.mp3`)}`; - - this.logger.verbose('Output audio path: ' + outputAudio); - this.logger.verbose('Temp audio path: ' + tempAudioPath); - - const audioBuffer = Buffer.from(audio, 'base64'); - fs.writeFileSync(tempAudioPath, audioBuffer); - this.logger.verbose('Temp audio created'); - } - - this.logger.verbose('Converting audio to mp4'); - return new Promise((resolve, reject) => { - exec(`${ffmpegPath.path} -i ${tempAudioPath} -vn -ab 128k -ar 44100 -f ipod ${outputAudio} -y`, (error) => { - fs.unlinkSync(tempAudioPath); - this.logger.verbose('Temp audio deleted'); - - if (error) reject(error); - - this.logger.verbose('Audio converted to mp4'); - resolve(outputAudio); - }); - }); - } - - public async audioWhatsapp(data: SendAudioDto, isChatwoot = false) { - this.logger.verbose('Sending audio whatsapp'); - - if (!data.options?.encoding && data.options?.encoding !== false) { - data.options.encoding = true; - } - - if (data.options?.encoding) { - const convert = await this.processAudio(data.audioMessage.audio, data.number); - if (typeof convert === 'string') { - const audio = fs.readFileSync(convert).toString('base64'); - const result = this.sendMessageWithTyping( - data.number, - { - audio: Buffer.from(audio, 'base64'), - ptt: true, - mimetype: 'audio/mp4', - }, - { presence: 'recording', delay: data?.options?.delay }, - isChatwoot, - ); - - fs.unlinkSync(convert); - this.logger.verbose('Converted audio deleted'); - - return result; - } else { - throw new InternalServerErrorException(convert); - } - } - - return await this.sendMessageWithTyping( - data.number, - { - audio: isURL(data.audioMessage.audio) - ? { url: data.audioMessage.audio } - : Buffer.from(data.audioMessage.audio, 'base64'), - ptt: true, - mimetype: 'audio/ogg; codecs=opus', - }, - { presence: 'recording', delay: data?.options?.delay }, - isChatwoot, - ); - } - - public async buttonMessage(data: SendButtonDto) { - this.logger.verbose('Sending button message'); - const embeddedMedia: any = {}; - let mediatype = 'TEXT'; - - if (data.buttonMessage?.mediaMessage) { - mediatype = data.buttonMessage.mediaMessage?.mediatype.toUpperCase() ?? 'TEXT'; - embeddedMedia.mediaKey = mediatype.toLowerCase() + 'Message'; - const generate = await this.prepareMediaMessage(data.buttonMessage.mediaMessage); - embeddedMedia.message = generate.message[embeddedMedia.mediaKey]; - embeddedMedia.contentText = `*${data.buttonMessage.title}*\n\n${data.buttonMessage.description}`; - } - - const btnItems = { - text: data.buttonMessage.buttons.map((btn) => btn.buttonText), - ids: data.buttonMessage.buttons.map((btn) => btn.buttonId), - }; - - if (!arrayUnique(btnItems.text) || !arrayUnique(btnItems.ids)) { - throw new BadRequestException('Button texts cannot be repeated', 'Button IDs cannot be repeated.'); - } - - return await this.sendMessageWithTyping( - data.number, - { - buttonsMessage: { - text: !embeddedMedia?.mediaKey ? data.buttonMessage.title : undefined, - contentText: embeddedMedia?.contentText ?? data.buttonMessage.description, - footerText: data.buttonMessage?.footerText, - buttons: data.buttonMessage.buttons.map((button) => { - return { - buttonText: { - displayText: button.buttonText, - }, - buttonId: button.buttonId, - type: 1, - }; - }), - headerType: proto.Message.ButtonsMessage.HeaderType[mediatype], - [embeddedMedia?.mediaKey]: embeddedMedia?.message, - }, - }, - data?.options, - ); - } - - public async locationMessage(data: SendLocationDto) { - this.logger.verbose('Sending location message'); - return await this.sendMessageWithTyping( - data.number, - { - locationMessage: { - degreesLatitude: data.locationMessage.latitude, - degreesLongitude: data.locationMessage.longitude, - name: data.locationMessage?.name, - address: data.locationMessage?.address, - }, - }, - data?.options, - ); - } - - public async listMessage(data: SendListDto) { - this.logger.verbose('Sending list message'); - return await this.sendMessageWithTyping( - data.number, - { - listMessage: { - title: data.listMessage.title, - description: data.listMessage.description, - buttonText: data.listMessage?.buttonText, - footerText: data.listMessage?.footerText, - sections: data.listMessage.sections, - listType: 2, - }, - }, - data?.options, - ); - } - - public async contactMessage(data: SendContactDto) { - this.logger.verbose('Sending contact message'); - const message: proto.IMessage = {}; - - const vcard = (contact: ContactMessage) => { - this.logger.verbose('Creating vcard'); - let result = 'BEGIN:VCARD\n' + 'VERSION:3.0\n' + `N:${contact.fullName}\n` + `FN:${contact.fullName}\n`; - - if (contact.organization) { - this.logger.verbose('Organization defined'); - result += `ORG:${contact.organization};\n`; - } - - if (contact.email) { - this.logger.verbose('Email defined'); - result += `EMAIL:${contact.email}\n`; - } - - if (contact.url) { - this.logger.verbose('Url defined'); - result += `URL:${contact.url}\n`; - } - - if (!contact.wuid) { - this.logger.verbose('Wuid defined'); - contact.wuid = this.createJid(contact.phoneNumber); - } - - result += `item1.TEL;waid=${contact.wuid}:${contact.phoneNumber}\n` + 'item1.X-ABLabel:Celular\n' + 'END:VCARD'; - - this.logger.verbose('Vcard created'); - return result; - }; - - if (data.contactMessage.length === 1) { - message.contactMessage = { - displayName: data.contactMessage[0].fullName, - vcard: vcard(data.contactMessage[0]), - }; - } else { - message.contactsArrayMessage = { - displayName: `${data.contactMessage.length} contacts`, - contacts: data.contactMessage.map((contact) => { - return { - displayName: contact.fullName, - vcard: vcard(contact), - }; - }), - }; - } - - return await this.sendMessageWithTyping(data.number, { ...message }, data?.options); - } - - public async reactionMessage(data: SendReactionDto) { - this.logger.verbose('Sending reaction message'); - return await this.sendMessageWithTyping(data.reactionMessage.key.remoteJid, { - reactionMessage: { - key: data.reactionMessage.key, - text: data.reactionMessage.reaction, - }, - }); - } - - // Chat Controller - public async whatsappNumber(data: WhatsAppNumberDto) { - this.logger.verbose('Getting whatsapp number'); - - const jids: { - groups: { number: string; jid: string }[]; - broadcast: { number: string; jid: string }[]; - users: { number: string; jid: string; name?: string }[]; - } = { - groups: [], - broadcast: [], - users: [], - }; - - data.numbers.forEach((number) => { - const jid = this.createJid(number); - - if (isJidGroup(jid)) { - jids.groups.push({ number, jid }); - } else if (jid === 'status@broadcast') { - jids.broadcast.push({ number, jid }); - } else { - jids.users.push({ number, jid }); - } - }); - - const onWhatsapp: OnWhatsAppDto[] = []; - - // BROADCAST - onWhatsapp.push(...jids.broadcast.map(({ jid, number }) => new OnWhatsAppDto(jid, false, number))); - - // GROUPS - const groups = await Promise.all( - jids.groups.map(async ({ jid, number }) => { - const group = await this.findGroup({ groupJid: jid }, 'inner'); - - if (!group) { - new OnWhatsAppDto(jid, false, number); - } - - return new OnWhatsAppDto(group.id, !!group?.id, number, group?.subject); - }), - ); - onWhatsapp.push(...groups); - - // USERS - const contacts: ContactRaw[] = await this.repository.contact.findManyById({ - owner: this.instance.name, - ids: jids.users.map(({ jid }) => (jid.startsWith('+') ? jid.substring(1) : jid)), - }); - const verify = await this.client.onWhatsApp( - ...jids.users.map(({ jid }) => (!jid.startsWith('+') ? `+${jid}` : jid)), - ); - const users: OnWhatsAppDto[] = await Promise.all( - jids.users.map(async (user) => { - const MAX_SIMILARITY_THRESHOLD = 0.01; - const isBrWithDigit = user.jid.startsWith('55') && user.jid.slice(4, 5) === '9' && user.jid.length === 28; - const jid = isBrWithDigit ? user.jid.slice(0, 4) + user.jid.slice(5) : user.jid; - - const numberVerified = verify.find((v) => { - const mainJidSimilarity = levenshtein.get(user.jid, v.jid) / Math.max(user.jid.length, v.jid.length); - const jidSimilarity = levenshtein.get(jid, v.jid) / Math.max(jid.length, v.jid.length); - return mainJidSimilarity <= MAX_SIMILARITY_THRESHOLD || jidSimilarity <= MAX_SIMILARITY_THRESHOLD; - }); - return { - exists: !!numberVerified?.exists, - jid: numberVerified?.jid || user.jid, - name: contacts.find((c) => c.id === jid)?.pushName, - number: user.number, - }; - }), - ); - - onWhatsapp.push(...users); - - return onWhatsapp; - } - - public async markMessageAsRead(data: ReadMessageDto) { - this.logger.verbose('Marking message as read'); - - try { - const keys: proto.IMessageKey[] = []; - data.read_messages.forEach((read) => { - if (isJidGroup(read.remoteJid) || isJidUser(read.remoteJid)) { - keys.push({ - remoteJid: read.remoteJid, - fromMe: read.fromMe, - id: read.id, - }); - } - }); - await this.client.readMessages(keys); - return { message: 'Read messages', read: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Read messages fail', error.toString()); - } - } - - public async getLastMessage(number: string) { - const messages = await this.fetchMessages({ - where: { - key: { - remoteJid: number, - }, - owner: this.instance.name, - }, - }); - - let lastMessage = messages.pop(); - - for (const message of messages) { - if (message.messageTimestamp >= lastMessage.messageTimestamp) { - lastMessage = message; - } - } - - return lastMessage as unknown as LastMessage; - } - - public async archiveChat(data: ArchiveChatDto) { - this.logger.verbose('Archiving chat'); - try { - let last_message = data.lastMessage; - let number = data.chat; - - if (!last_message && number) { - last_message = await this.getLastMessage(number); - } else { - last_message = data.lastMessage; - last_message.messageTimestamp = last_message?.messageTimestamp ?? Date.now(); - number = last_message?.key?.remoteJid; - } - - if (!last_message || Object.keys(last_message).length === 0) { - throw new NotFoundException('Last message not found'); - } - - await this.client.chatModify( - { - archive: data.archive, - lastMessages: [last_message], - }, - this.createJid(number), - ); - - return { - chatId: number, - archived: true, - }; - } catch (error) { - throw new InternalServerErrorException({ - archived: false, - message: ['An error occurred while archiving the chat. Open a calling.', error.toString()], - }); - } - } - - public async deleteMessage(del: DeleteMessage) { - this.logger.verbose('Deleting message'); - try { - return await this.client.sendMessage(del.remoteJid, { delete: del }); - } catch (error) { - throw new InternalServerErrorException('Error while deleting message for everyone', error?.toString()); - } - } - - public async getBase64FromMediaMessage(data: getBase64FromMediaMessageDto) { - this.logger.verbose('Getting base64 from media message'); - try { - const m = data?.message; - const convertToMp4 = data?.convertToMp4 ?? false; - - const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); - - if (!msg) { - throw 'Message not found'; - } - - for (const subtype of MessageSubtype) { - if (msg.message[subtype]) { - msg.message = msg.message[subtype].message; - } - } - - let mediaMessage: any; - let mediaType: string; - - for (const type of TypeMediaMessage) { - mediaMessage = msg.message[type]; - if (mediaMessage) { - mediaType = type; - break; - } - } - - if (!mediaMessage) { - throw 'The message is not of the media type'; - } - - if (typeof mediaMessage['mediaKey'] === 'object') { - msg.message = JSON.parse(JSON.stringify(msg.message)); - } - - this.logger.verbose('Downloading media message'); - const buffer = await downloadMediaMessage( - { key: msg?.key, message: msg?.message }, - 'buffer', - {}, - { - logger: P({ level: 'error' }) as any, - reuploadRequest: this.client.updateMediaMessage, - }, - ); - const typeMessage = getContentType(msg.message); - - if (convertToMp4 && typeMessage === 'audioMessage') { - this.logger.verbose('Converting audio to mp4'); - const number = msg.key.remoteJid.split('@')[0]; - const convert = await this.processAudio(buffer.toString('base64'), number); - - if (typeof convert === 'string') { - const audio = fs.readFileSync(convert).toString('base64'); - this.logger.verbose('Audio converted to mp4'); - - const result = { - mediaType, - fileName: mediaMessage['fileName'], - caption: mediaMessage['caption'], - size: { - fileLength: mediaMessage['fileLength'], - height: mediaMessage['height'], - width: mediaMessage['width'], - }, - mimetype: 'audio/mp4', - base64: Buffer.from(audio, 'base64').toString('base64'), - }; - - fs.unlinkSync(convert); - this.logger.verbose('Converted audio deleted'); - - this.logger.verbose('Media message downloaded'); - return result; - } - } - - this.logger.verbose('Media message downloaded'); - return { - mediaType, - fileName: mediaMessage['fileName'], - caption: mediaMessage['caption'], - size: { - fileLength: mediaMessage['fileLength'], - height: mediaMessage['height'], - width: mediaMessage['width'], - }, - mimetype: mediaMessage['mimetype'], - base64: buffer.toString('base64'), - }; - } catch (error) { - this.logger.error(error); - throw new BadRequestException(error.toString()); - } - } - public async fetchContacts(query: ContactQuery) { this.logger.verbose('Fetching contacts'); if (query?.where) { @@ -3720,497 +1157,4 @@ export class WAStartupService { this.logger.verbose('Fetching chats'); return await this.repository.chat.find({ where: { owner: this.instance.name } }); } - - public async fetchPrivacySettings() { - this.logger.verbose('Fetching privacy settings'); - const privacy = await this.client.fetchPrivacySettings(); - - return { - readreceipts: privacy.readreceipts, - profile: privacy.profile, - status: privacy.status, - online: privacy.online, - last: privacy.last, - groupadd: privacy.groupadd, - }; - } - - public async updatePrivacySettings(settings: PrivacySettingDto) { - this.logger.verbose('Updating privacy settings'); - try { - await this.client.updateReadReceiptsPrivacy(settings.privacySettings.readreceipts); - this.logger.verbose('Read receipts privacy updated'); - - await this.client.updateProfilePicturePrivacy(settings.privacySettings.profile); - this.logger.verbose('Profile picture privacy updated'); - - await this.client.updateStatusPrivacy(settings.privacySettings.status); - this.logger.verbose('Status privacy updated'); - - await this.client.updateOnlinePrivacy(settings.privacySettings.online); - this.logger.verbose('Online privacy updated'); - - await this.client.updateLastSeenPrivacy(settings.privacySettings.last); - this.logger.verbose('Last seen privacy updated'); - - await this.client.updateGroupsAddPrivacy(settings.privacySettings.groupadd); - this.logger.verbose('Groups add privacy updated'); - - this.reloadConnection(); - - return { - update: 'success', - data: { - readreceipts: settings.privacySettings.readreceipts, - profile: settings.privacySettings.profile, - status: settings.privacySettings.status, - online: settings.privacySettings.online, - last: settings.privacySettings.last, - groupadd: settings.privacySettings.groupadd, - }, - }; - } catch (error) { - throw new InternalServerErrorException('Error updating privacy settings', error.toString()); - } - } - - public async fetchBusinessProfile(number: string): Promise { - this.logger.verbose('Fetching business profile'); - try { - const jid = number ? this.createJid(number) : this.instance.wuid; - - const profile = await this.client.getBusinessProfile(jid); - this.logger.verbose('Trying to get business profile'); - - if (!profile) { - const info = await this.whatsappNumber({ numbers: [jid] }); - - return { - isBusiness: false, - message: 'Not is business profile', - ...info?.shift(), - }; - } - - this.logger.verbose('Business profile fetched'); - return { - isBusiness: true, - ...profile, - }; - } catch (error) { - throw new InternalServerErrorException('Error updating profile name', error.toString()); - } - } - - public async updateProfileName(name: string) { - this.logger.verbose('Updating profile name to ' + name); - try { - await this.client.updateProfileName(name); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error updating profile name', error.toString()); - } - } - - public async updateProfileStatus(status: string) { - this.logger.verbose('Updating profile status to: ' + status); - try { - await this.client.updateProfileStatus(status); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error updating profile status', error.toString()); - } - } - - public async updateProfilePicture(picture: string) { - this.logger.verbose('Updating profile picture'); - try { - let pic: WAMediaUpload; - if (isURL(picture)) { - this.logger.verbose('Picture is url'); - - const timestamp = new Date().getTime(); - const url = `${picture}?timestamp=${timestamp}`; - this.logger.verbose('Including timestamp in url: ' + url); - - pic = (await axios.get(url, { responseType: 'arraybuffer' })).data; - this.logger.verbose('Getting picture from url'); - } else if (isBase64(picture)) { - this.logger.verbose('Picture is base64'); - pic = Buffer.from(picture, 'base64'); - this.logger.verbose('Getting picture from base64'); - } else { - throw new BadRequestException('"profilePicture" must be a url or a base64'); - } - - await this.client.updateProfilePicture(this.instance.wuid, pic); - this.logger.verbose('Profile picture updated'); - - this.reloadConnection(); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error updating profile picture', error.toString()); - } - } - - public async removeProfilePicture() { - this.logger.verbose('Removing profile picture'); - try { - await this.client.removeProfilePicture(this.instance.wuid); - - this.reloadConnection(); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error removing profile picture', error.toString()); - } - } - - public async updateMessage(data: UpdateMessageDto) { - try { - const jid = this.createJid(data.number); - - this.logger.verbose('Updating message'); - return await this.client.sendMessage(jid, { - text: data.text, - edit: data.key, - }); - } catch (error) { - this.logger.error(error); - throw new BadRequestException(error.toString()); - } - } - - // Group - public async createGroup(create: CreateGroupDto) { - this.logger.verbose('Creating group: ' + create.subject); - try { - const participants = (await this.whatsappNumber({ numbers: create.participants })) - .filter((participant) => participant.exists) - .map((participant) => participant.jid); - const { id } = await this.client.groupCreate(create.subject, participants); - this.logger.verbose('Group created: ' + id); - - if (create?.description) { - this.logger.verbose('Updating group description: ' + create.description); - await this.client.groupUpdateDescription(id, create.description); - } - - if (create?.promoteParticipants) { - this.logger.verbose('Prometing group participants: ' + participants); - await this.updateGParticipant({ - groupJid: id, - action: 'promote', - participants: participants, - }); - } - - this.logger.verbose('Getting group metadata'); - const group = await this.client.groupMetadata(id); - - return group; - } catch (error) { - this.logger.error(error); - throw new InternalServerErrorException('Error creating group', error.toString()); - } - } - - public async updateGroupPicture(picture: GroupPictureDto) { - this.logger.verbose('Updating group picture'); - try { - let pic: WAMediaUpload; - if (isURL(picture.image)) { - this.logger.verbose('Picture is url'); - - const timestamp = new Date().getTime(); - const url = `${picture.image}?timestamp=${timestamp}`; - this.logger.verbose('Including timestamp in url: ' + url); - - pic = (await axios.get(url, { responseType: 'arraybuffer' })).data; - this.logger.verbose('Getting picture from url'); - } else if (isBase64(picture.image)) { - this.logger.verbose('Picture is base64'); - pic = Buffer.from(picture.image, 'base64'); - this.logger.verbose('Getting picture from base64'); - } else { - throw new BadRequestException('"profilePicture" must be a url or a base64'); - } - await this.client.updateProfilePicture(picture.groupJid, pic); - this.logger.verbose('Group picture updated'); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error update group picture', error.toString()); - } - } - - public async updateGroupSubject(data: GroupSubjectDto) { - this.logger.verbose('Updating group subject to: ' + data.subject); - try { - await this.client.groupUpdateSubject(data.groupJid, data.subject); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error updating group subject', error.toString()); - } - } - - public async updateGroupDescription(data: GroupDescriptionDto) { - this.logger.verbose('Updating group description to: ' + data.description); - try { - await this.client.groupUpdateDescription(data.groupJid, data.description); - - return { update: 'success' }; - } catch (error) { - throw new InternalServerErrorException('Error updating group description', error.toString()); - } - } - - public async findGroup(id: GroupJid, reply: 'inner' | 'out' = 'out') { - this.logger.verbose('Fetching group'); - try { - const group = await this.client.groupMetadata(id.groupJid); - - const picture = await this.profilePicture(group.id); - - return { - id: group.id, - subject: group.subject, - subjectOwner: group.subjectOwner, - subjectTime: group.subjectTime, - pictureUrl: picture.profilePictureUrl, - size: group.participants.length, - creation: group.creation, - owner: group.owner, - desc: group.desc, - descId: group.descId, - restrict: group.restrict, - announce: group.announce, - participants: group.participants, - }; - } catch (error) { - if (reply === 'inner') { - return; - } - throw new NotFoundException('Error fetching group', error.toString()); - } - } - - public async fetchAllGroups(getParticipants: GetParticipant) { - this.logger.verbose('Fetching all groups'); - try { - const fetch = Object.values(await this.client.groupFetchAllParticipating()); - let groups = []; - for (const group of fetch) { - const picture = await this.profilePicture(group.id); - - const result = { - id: group.id, - subject: group.subject, - subjectOwner: group.subjectOwner, - subjectTime: group.subjectTime, - pictureUrl: picture.profilePictureUrl, - size: group.participants.length, - creation: group.creation, - owner: group.owner, - desc: group.desc, - descId: group.descId, - restrict: group.restrict, - announce: group.announce, - }; - - if (getParticipants.getParticipants == 'true') { - result['participants'] = group.participants; - } - - groups = [...groups, result]; - } - - return groups; - } catch (error) { - throw new NotFoundException('Error fetching group', error.toString()); - } - } - - public async inviteCode(id: GroupJid) { - this.logger.verbose('Fetching invite code for group: ' + id.groupJid); - try { - const code = await this.client.groupInviteCode(id.groupJid); - return { inviteUrl: `https://chat.whatsapp.com/${code}`, inviteCode: code }; - } catch (error) { - throw new NotFoundException('No invite code', error.toString()); - } - } - - public async inviteInfo(id: GroupInvite) { - this.logger.verbose('Fetching invite info for code: ' + id.inviteCode); - try { - return await this.client.groupGetInviteInfo(id.inviteCode); - } catch (error) { - throw new NotFoundException('No invite info', id.inviteCode); - } - } - - public async sendInvite(id: GroupSendInvite) { - this.logger.verbose('Sending invite for group: ' + id.groupJid); - try { - const inviteCode = await this.inviteCode({ groupJid: id.groupJid }); - this.logger.verbose('Getting invite code: ' + inviteCode.inviteCode); - - const inviteUrl = inviteCode.inviteUrl; - this.logger.verbose('Invite url: ' + inviteUrl); - - const numbers = id.numbers.map((number) => this.createJid(number)); - const description = id.description ?? ''; - - const msg = `${description}\n\n${inviteUrl}`; - - const message = { - conversation: msg, - }; - - for await (const number of numbers) { - await this.sendMessageWithTyping(number, message); - } - - this.logger.verbose('Invite sent for numbers: ' + numbers.join(', ')); - - return { send: true, inviteUrl }; - } catch (error) { - throw new NotFoundException('No send invite'); - } - } - - public async acceptInviteCode(id: AcceptGroupInvite) { - this.logger.verbose('Joining the group by invitation code: ' + id.inviteCode); - try { - const groupJid = await this.client.groupAcceptInvite(id.inviteCode); - return { accepted: true, groupJid: groupJid }; - } catch (error) { - throw new NotFoundException('Accept invite error', error.toString()); - } - } - - public async revokeInviteCode(id: GroupJid) { - this.logger.verbose('Revoking invite code for group: ' + id.groupJid); - try { - const inviteCode = await this.client.groupRevokeInvite(id.groupJid); - return { revoked: true, inviteCode }; - } catch (error) { - throw new NotFoundException('Revoke error', error.toString()); - } - } - - public async findParticipants(id: GroupJid) { - this.logger.verbose('Fetching participants for group: ' + id.groupJid); - try { - const participants = (await this.client.groupMetadata(id.groupJid)).participants; - const contacts = await this.repository.contact.findManyById({ - owner: this.instance.name, - ids: participants.map((p) => p.id), - }); - const parsedParticipants = participants.map((participant) => { - const contact = contacts.find((c) => c.id === participant.id); - return { - ...participant, - name: participant.name ?? contact?.pushName, - imgUrl: participant.imgUrl ?? contact?.profilePictureUrl, - }; - }); - return { participants: parsedParticipants }; - } catch (error) { - throw new NotFoundException('No participants', error.toString()); - } - } - - public async updateGParticipant(update: GroupUpdateParticipantDto) { - this.logger.verbose('Updating participants'); - try { - const participants = update.participants.map((p) => this.createJid(p)); - const updateParticipants = await this.client.groupParticipantsUpdate( - update.groupJid, - participants, - update.action, - ); - return { updateParticipants: updateParticipants }; - } catch (error) { - throw new BadRequestException('Error updating participants', error.toString()); - } - } - - public async updateGSetting(update: GroupUpdateSettingDto) { - this.logger.verbose('Updating setting for group: ' + update.groupJid); - try { - const updateSetting = await this.client.groupSettingUpdate(update.groupJid, update.action); - return { updateSetting: updateSetting }; - } catch (error) { - throw new BadRequestException('Error updating setting', error.toString()); - } - } - - public async toggleEphemeral(update: GroupToggleEphemeralDto) { - this.logger.verbose('Toggling ephemeral for group: ' + update.groupJid); - try { - await this.client.groupToggleEphemeral(update.groupJid, update.expiration); - return { success: true }; - } catch (error) { - throw new BadRequestException('Error updating setting', error.toString()); - } - } - - public async leaveGroup(id: GroupJid) { - this.logger.verbose('Leaving group: ' + id.groupJid); - try { - await this.client.groupLeave(id.groupJid); - return { groupJid: id.groupJid, leave: true }; - } catch (error) { - throw new BadRequestException('Unable to leave the group', error.toString()); - } - } - - public async fetchLabels(): Promise { - this.logger.verbose('Fetching labels'); - const labels = await this.repository.labels.find({ - where: { - owner: this.instance.name, - }, - }); - - return labels.map((label) => ({ - color: label.color, - name: label.name, - id: label.id, - predefinedId: label.predefinedId, - })); - } - - public async handleLabel(data: HandleLabelDto) { - this.logger.verbose('Adding label'); - const whatsappContact = await this.whatsappNumber({ numbers: [data.number] }); - if (whatsappContact.length === 0) { - throw new NotFoundException('Number not found'); - } - const contact = whatsappContact[0]; - if (!contact.exists) { - throw new NotFoundException('Number is not on WhatsApp'); - } - - try { - if (data.action === 'add') { - await this.client.addChatLabel(contact.jid, data.labelId); - - return { numberJid: contact.jid, labelId: data.labelId, add: true }; - } - if (data.action === 'remove') { - await this.client.removeChatLabel(contact.jid, data.labelId); - - return { numberJid: contact.jid, labelId: data.labelId, remove: true }; - } - } catch (error) { - throw new BadRequestException(`Unable to ${data.action} label to chat`, error.toString()); - } - } } diff --git a/src/whatsapp/types/wa.types.ts b/src/whatsapp/types/wa.types.ts index 21f5ee31..066691e4 100644 --- a/src/whatsapp/types/wa.types.ts +++ b/src/whatsapp/types/wa.types.ts @@ -138,6 +138,12 @@ export declare namespace wa { answerByAudio?: boolean; }; + export type LocalIntegration = { + integration?: string; + number?: string; + token?: string; + }; + export type StateConnection = { instance?: string; state?: WAConnectionState | 'refused'; @@ -155,3 +161,8 @@ export const MessageSubtype = [ 'viewOnceMessage', 'viewOnceMessageV2', ]; + +export const Integration = { + WHATSAPP_BUSINESS: 'WHATSAPP-BUSINESS', + WHATSAPP_BAILEYS: 'WHATSAPP-BAILEYS', +}; diff --git a/src/whatsapp/whatsapp.module.ts b/src/whatsapp/whatsapp.module.ts index 49beadfe..9fa18991 100644 --- a/src/whatsapp/whatsapp.module.ts +++ b/src/whatsapp/whatsapp.module.ts @@ -24,6 +24,7 @@ import { ChatModel, ChatwootModel, ContactModel, + IntegrationModel, MessageModel, MessageUpModel, ProxyModel, @@ -40,6 +41,7 @@ import { ChamaaiRepository } from './repository/chamaai.repository'; import { ChatRepository } from './repository/chat.repository'; import { ChatwootRepository } from './repository/chatwoot.repository'; import { ContactRepository } from './repository/contact.repository'; +import { IntegrationRepository } from './repository/integration.repository'; import { LabelRepository } from './repository/label.repository'; import { MessageRepository } from './repository/message.repository'; import { MessageUpRepository } from './repository/messageUp.repository'; @@ -55,6 +57,7 @@ import { AuthService } from './services/auth.service'; import { CacheService } from './services/cache.service'; import { ChamaaiService } from './services/chamaai.service'; import { ChatwootService } from './services/chatwoot.service'; +import { IntegrationService } from './services/integration.service'; import { WAMonitoringService } from './services/monitor.service'; import { ProxyService } from './services/proxy.service'; import { RabbitmqService } from './services/rabbitmq.service'; @@ -77,6 +80,7 @@ const proxyRepository = new ProxyRepository(ProxyModel, configService); const chamaaiRepository = new ChamaaiRepository(ChamaaiModel, configService); const rabbitmqRepository = new RabbitmqRepository(RabbitmqModel, configService); const sqsRepository = new SqsRepository(SqsModel, configService); +const integrationRepository = new IntegrationRepository(IntegrationModel, configService); const chatwootRepository = new ChatwootRepository(ChatwootModel, configService); const settingsRepository = new SettingsRepository(SettingsModel, configService); const authRepository = new AuthRepository(AuthModel, configService); @@ -96,6 +100,7 @@ export const repository = new RepositoryBroker( typebotRepository, proxyRepository, chamaaiRepository, + integrationRepository, authRepository, labelRepository, configService, @@ -138,6 +143,8 @@ const sqsService = new SqsService(waMonitor); export const sqsController = new SqsController(sqsService); +const integrationService = new IntegrationService(waMonitor); + const chatwootService = new ChatwootService(waMonitor, configService, repository, chatwootCache); export const chatwootController = new ChatwootController(chatwootService, configService, repository); @@ -159,6 +166,7 @@ export const instanceController = new InstanceController( rabbitmqService, sqsService, typebotService, + integrationService, cache, chatwootCache, );