diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml new file mode 100644 index 00000000..8419d7dc --- /dev/null +++ b/.github/workflows/publish_docker_image.yml @@ -0,0 +1,64 @@ +name: Build Docker image + +on: + push: + tags: ['v*'] + +jobs: + build-amd: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Extract existing image metadata + id: image-meta + uses: docker/metadata-action@v4 + with: + images: atendai/evolution-api + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push AMD image + uses: docker/build-push-action@v4 + with: + context: . + labels: ${{ steps.image-meta.outputs.labels }} + platforms: linux/amd64 + push: true + + build-arm: + runs-on: buildjet-4vcpu-ubuntu-2204-arm + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Extract existing image metadata + id: image-meta + uses: docker/metadata-action@v4 + with: + images: atendai/evolution-api + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push ARM image + uses: docker/build-push-action@v4 + with: + context: . + labels: ${{ steps.image-meta.outputs.labels }} + platforms: linux/arm64 + push: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7f016d..9033f10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 1.6.2 (develop) + +### Feature + +* Added update message endpoint + +### Fixed + +* Proxy configuration improvements +* Correction in sending lists +* Adjust in webhook_base64 +* Correction in typebot text formatting +* Correction in chatwoot text formatting and render list message +* Only use a axios request to get file mimetype if necessary +* When possible use the original file extension +* When receiving a file from whatsapp, use the original filename in chatwoot if possible +* Remove message ids cache in chatwoot to use chatwoot's api itself +* Adjusts the quoted message, now has contextInfo in the message Raw + # 1.6.1 (2023-12-22 11:43) ### Fixed diff --git a/Dockerfile b/Dockerfile index cf9bccf4..9bc23317 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:20.7.0-alpine AS builder -LABEL version="1.6.1" description="Api to control whatsapp features through http requests." +LABEL version="1.6.2" description="Api to control whatsapp features through http requests." LABEL maintainer="Davidson Gomes" git="https://github.com/DavidsonGomes" LABEL contact="contato@agenciadgcode.com" diff --git a/package.json b/package.json index e177bc0e..0c02126c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evolution-api", - "version": "1.6.1", + "version": "1.6.2", "description": "Rest api for communication with WhatsApp", "main": "./dist/src/main.js", "scripts": { @@ -46,7 +46,7 @@ "@figuro/chatwoot-sdk": "^1.1.16", "@hapi/boom": "^10.0.1", "@sentry/node": "^7.59.2", - "@whiskeysockets/baileys": "github:PurpShell/Baileys#combined", + "@whiskeysockets/baileys": "^6.5.0", "amqplib": "^0.10.3", "aws-sdk": "^2.1499.0", "axios": "^1.3.5", diff --git a/src/config/env.config.ts b/src/config/env.config.ts index cdd2e46a..c0716b97 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -136,11 +136,22 @@ export type GlobalWebhook = { ENABLED: boolean; WEBHOOK_BY_EVENTS: boolean; }; +export type CacheConfRedis = { + ENABLED: boolean; + URI: string; + PREFIX_KEY: string; + TTL: number; +}; +export type CacheConfLocal = { + ENABLED: boolean; + TTL: number; +}; export type SslConf = { PRIVKEY: string; FULLCHAIN: string }; export type Webhook = { GLOBAL?: GlobalWebhook; EVENTS: EventsWebhook }; export type ConfigSessionPhone = { CLIENT: string; NAME: string }; export type QrCode = { LIMIT: number; COLOR: string }; export type Typebot = { API_VERSION: string; KEEP_OPEN: boolean }; +export type CacheConf = { REDIS: CacheConfRedis; LOCAL: CacheConfLocal }; export type Production = boolean; export interface Env { @@ -160,6 +171,7 @@ export interface Env { CONFIG_SESSION_PHONE: ConfigSessionPhone; QRCODE: QrCode; TYPEBOT: Typebot; + CACHE: CacheConf; AUTHENTICATION: Auth; PRODUCTION?: Production; WABUSSINESS: WABussiness; @@ -326,6 +338,18 @@ export class ConfigService { API_VERSION: process.env?.TYPEBOT_API_VERSION || 'old', KEEP_OPEN: process.env.TYPEBOT_KEEP_OPEN === 'true', }, + CACHE: { + REDIS: { + ENABLED: process.env?.CACHE_REDIS_ENABLED === 'true', + URI: process.env?.CACHE_REDIS_URI || '', + PREFIX_KEY: process.env?.CACHE_REDIS_PREFIX_KEY || 'evolution-cache', + TTL: Number.parseInt(process.env?.CACHE_REDIS_TTL) || 604800, + }, + LOCAL: { + ENABLED: process.env?.CACHE_LOCAL_ENABLED === 'true', + TTL: Number.parseInt(process.env?.CACHE_REDIS_TTL) || 86400, + }, + }, AUTHENTICATION: { TYPE: process.env.AUTHENTICATION_TYPE as 'apikey', API_KEY: { diff --git a/src/dev-env.yml b/src/dev-env.yml index 35709057..023ec7cc 100644 --- a/src/dev-env.yml +++ b/src/dev-env.yml @@ -162,6 +162,17 @@ TYPEBOT: API_VERSION: 'old' # old | latest KEEP_OPEN: false +# Cache to optimize application performance +CACHE: + REDIS: + ENABLED: false + URI: "redis://localhost:6379" + PREFIX_KEY: "evolution-cache" + TTL: 604800 + LOCAL: + ENABLED: false + TTL: 86400 + # Defines an authentication type for the api # We recommend using the apikey because it will allow you to use a custom token, # if you use jwt, a random token will be generated and may be expired and you will have to generate a new token diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml index 603aa0e0..924434e9 100644 --- a/src/docs/swagger.yaml +++ b/src/docs/swagger.yaml @@ -25,7 +25,7 @@ info: [![Run in Postman](https://run.pstmn.io/button.svg)](https://god.gw.postman.com/run-collection/26869335-5546d063-156b-4529-915f-909dd628c090?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D26869335-5546d063-156b-4529-915f-909dd628c090%26entityType%3Dcollection%26workspaceId%3D339a4ee7-378b-45c9-b5b8-fd2c0a9c2442) - version: 1.6.1 + version: 1.6.2 contact: name: DavidsonGomes email: contato@agenciadgcode.com diff --git a/src/libs/amqp.server.ts b/src/libs/amqp.server.ts index fc95b33c..c861916b 100644 --- a/src/libs/amqp.server.ts +++ b/src/libs/amqp.server.ts @@ -27,6 +27,7 @@ export const initAMQP = () => { channel.assertExchange(exchangeName, 'topic', { durable: true, autoDelete: false, + assert: true, }); amqpChannel = channel; @@ -43,7 +44,7 @@ export const getAMQP = (): amqp.Channel | null => { }; export const initQueues = (instanceName: string, events: string[]) => { - if (!events || !events.length) return; + if (!instanceName || !events || !events.length) return; const queues = events.map((event) => { return `${event.replace(/_/g, '.').toLowerCase()}`; @@ -56,6 +57,7 @@ export const initQueues = (instanceName: string, events: string[]) => { amqp.assertExchange(exchangeName, 'topic', { durable: true, autoDelete: false, + assert: true, }); const queueName = `${instanceName}.${event}`; @@ -89,6 +91,7 @@ export const removeQueues = (instanceName: string, events: string[]) => { amqp.assertExchange(exchangeName, 'topic', { durable: true, autoDelete: false, + assert: true, }); const queueName = `${instanceName}.${event}`; diff --git a/src/libs/cacheengine.ts b/src/libs/cacheengine.ts new file mode 100644 index 00000000..a22d7e68 --- /dev/null +++ b/src/libs/cacheengine.ts @@ -0,0 +1,22 @@ +import { CacheConf, ConfigService } from '../config/env.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; +import { LocalCache } from './localcache'; +import { RedisCache } from './rediscache'; + +export class CacheEngine { + private engine: ICache; + + constructor(private readonly configService: ConfigService, module: string) { + const cacheConf = configService.get('CACHE'); + + if (cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') { + this.engine = new RedisCache(configService, module); + } else if (cacheConf?.LOCAL?.ENABLED) { + this.engine = new LocalCache(configService, module); + } + } + + public getEngine() { + return this.engine; + } +} diff --git a/src/libs/localcache.ts b/src/libs/localcache.ts new file mode 100644 index 00000000..fe1f295f --- /dev/null +++ b/src/libs/localcache.ts @@ -0,0 +1,48 @@ +import NodeCache from 'node-cache'; + +import { CacheConf, CacheConfLocal, ConfigService } from '../config/env.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; + +export class LocalCache implements ICache { + private conf: CacheConfLocal; + static localCache = new NodeCache(); + + constructor(private readonly configService: ConfigService, private readonly module: string) { + this.conf = this.configService.get('CACHE')?.LOCAL; + } + + async get(key: string): Promise { + return LocalCache.localCache.get(this.buildKey(key)); + } + + async set(key: string, value: any, ttl?: number) { + return LocalCache.localCache.set(this.buildKey(key), value, ttl || this.conf.TTL); + } + + async has(key: string) { + return LocalCache.localCache.has(this.buildKey(key)); + } + + async delete(key: string) { + return LocalCache.localCache.del(this.buildKey(key)); + } + + async deleteAll(appendCriteria?: string) { + const keys = await this.keys(appendCriteria); + if (!keys?.length) { + return 0; + } + + return LocalCache.localCache.del(keys); + } + + async keys(appendCriteria?: string) { + const filter = `${this.buildKey('')}${appendCriteria ? `${appendCriteria}:` : ''}`; + + return LocalCache.localCache.keys().filter((key) => key.substring(0, filter.length) === filter); + } + + buildKey(key: string) { + return `${this.module}:${key}`; + } +} diff --git a/src/libs/rediscache.client.ts b/src/libs/rediscache.client.ts new file mode 100644 index 00000000..b3f8dead --- /dev/null +++ b/src/libs/rediscache.client.ts @@ -0,0 +1,59 @@ +import { createClient, RedisClientType } from 'redis'; + +import { CacheConf, CacheConfRedis, configService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; + +class Redis { + private logger = new Logger(Redis.name); + private client: RedisClientType = null; + private conf: CacheConfRedis; + private connected = false; + + constructor() { + this.conf = configService.get('CACHE')?.REDIS; + } + + getConnection(): RedisClientType { + if (this.connected) { + return this.client; + } else { + this.client = createClient({ + url: this.conf.URI, + }); + + this.client.on('connect', () => { + this.logger.verbose('redis connecting'); + }); + + this.client.on('ready', () => { + this.logger.verbose('redis ready'); + this.connected = true; + }); + + this.client.on('error', () => { + this.logger.error('redis disconnected'); + this.connected = false; + }); + + this.client.on('end', () => { + this.logger.verbose('redis connection ended'); + this.connected = false; + }); + + try { + this.logger.verbose('connecting new redis client'); + this.client.connect(); + this.connected = true; + this.logger.verbose('connected to new redis client'); + } catch (e) { + this.connected = false; + this.logger.error('redis connect exception caught: ' + e); + return null; + } + + return this.client; + } + } +} + +export const redisClient = new Redis(); diff --git a/src/libs/rediscache.ts b/src/libs/rediscache.ts new file mode 100644 index 00000000..cd0b1283 --- /dev/null +++ b/src/libs/rediscache.ts @@ -0,0 +1,83 @@ +import { RedisClientType } from 'redis'; + +import { CacheConf, CacheConfRedis, ConfigService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; +import { redisClient } from './rediscache.client'; + +export class RedisCache implements ICache { + private readonly logger = new Logger(RedisCache.name); + private client: RedisClientType; + private conf: CacheConfRedis; + + constructor(private readonly configService: ConfigService, private readonly module: string) { + this.conf = this.configService.get('CACHE')?.REDIS; + this.client = redisClient.getConnection(); + } + + async get(key: string): Promise { + try { + return JSON.parse(await this.client.get(this.buildKey(key))); + } catch (error) { + this.logger.error(error); + } + } + + async set(key: string, value: any, ttl?: number) { + try { + await this.client.setEx(this.buildKey(key), ttl || this.conf?.TTL, JSON.stringify(value)); + } catch (error) { + this.logger.error(error); + } + } + + async has(key: string) { + try { + return (await this.client.exists(this.buildKey(key))) > 0; + } catch (error) { + this.logger.error(error); + } + } + + async delete(key: string) { + try { + return await this.client.del(this.buildKey(key)); + } catch (error) { + this.logger.error(error); + } + } + + async deleteAll(appendCriteria?: string) { + try { + const keys = await this.keys(appendCriteria); + if (!keys?.length) { + return 0; + } + + return await this.client.del(keys); + } catch (error) { + this.logger.error(error); + } + } + + async keys(appendCriteria?: string) { + try { + const match = `${this.buildKey('')}${appendCriteria ? `${appendCriteria}:` : ''}*`; + const keys = []; + for await (const key of this.client.scanIterator({ + MATCH: match, + COUNT: 100, + })) { + keys.push(key); + } + + return [...new Set(keys)]; + } catch (error) { + this.logger.error(error); + } + } + + buildKey(key: string) { + return `${this.conf?.PREFIX_KEY}:${this.module}:${key}`; + } +} diff --git a/src/validate/validate.schema.ts b/src/validate/validate.schema.ts index 0f1eb582..a61875af 100644 --- a/src/validate/validate.schema.ts +++ b/src/validate/validate.schema.ts @@ -611,6 +611,26 @@ export const profileStatusSchema: JSONSchema7 = { ...isNotEmpty('status'), }; +export const updateMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { type: 'string' }, + text: { type: 'string' }, + key: { + type: 'object', + properties: { + id: { type: 'string' }, + remoteJid: { type: 'string' }, + fromMe: { type: 'boolean', enum: [true, false] }, + }, + required: ['id', 'fromMe', 'remoteJid'], + ...isNotEmpty('id', 'remoteJid'), + }, + }, + ...isNotEmpty('number', 'text', 'key'), +}; + export const profilePictureSchema: JSONSchema7 = { $id: v4(), type: 'object', @@ -1127,7 +1147,18 @@ export const proxySchema: JSONSchema7 = { type: 'object', properties: { enabled: { type: 'boolean', enum: [true, false] }, - proxy: { type: 'string' }, + proxy: { + type: 'object', + properties: { + host: { type: 'string' }, + port: { type: 'string' }, + protocol: { type: 'string' }, + username: { type: 'string' }, + password: { type: 'string' }, + }, + required: ['host', 'port', 'protocol'], + ...isNotEmpty('host', 'port', 'protocol'), + }, }, required: ['enabled', 'proxy'], ...isNotEmpty('enabled', 'proxy'), diff --git a/src/whatsapp/abstract/abstract.cache.ts b/src/whatsapp/abstract/abstract.cache.ts new file mode 100644 index 00000000..caad2691 --- /dev/null +++ b/src/whatsapp/abstract/abstract.cache.ts @@ -0,0 +1,13 @@ +export interface ICache { + get(key: string): Promise; + + set(key: string, value: any, ttl?: number): void; + + has(key: string): Promise; + + keys(appendCriteria?: string): Promise; + + delete(key: string | string[]): Promise; + + deleteAll(appendCriteria?: string): Promise; +} diff --git a/src/whatsapp/controllers/chat.controller.ts b/src/whatsapp/controllers/chat.controller.ts index 4d97256b..ee608069 100644 --- a/src/whatsapp/controllers/chat.controller.ts +++ b/src/whatsapp/controllers/chat.controller.ts @@ -10,6 +10,7 @@ import { ProfileStatusDto, ReadMessageDto, SendPresenceDto, + UpdateMessageDto, WhatsAppNumberDto, NumberBusiness, } from '../dto/chat.dto'; @@ -123,4 +124,9 @@ export class ChatController { logger.verbose('requested removeProfilePicture from ' + instanceName + ' instance'); return await this.waMonitor.waInstances[instanceName].setWhatsappBusinessProfile(data); } + + public async updateMessage({ instanceName }: InstanceDto, data: UpdateMessageDto) { + logger.verbose('requested updateMessage from ' + instanceName + ' instance'); + return await this.waMonitor.waInstances[instanceName].updateMessage(data); + } } diff --git a/src/whatsapp/controllers/chatwoot.controller.ts b/src/whatsapp/controllers/chatwoot.controller.ts index 8f59ccac..2de472ff 100644 --- a/src/whatsapp/controllers/chatwoot.controller.ts +++ b/src/whatsapp/controllers/chatwoot.controller.ts @@ -3,9 +3,11 @@ import { isURL } from 'class-validator'; import { ConfigService, HttpServer } from '../../config/env.config'; import { Logger } from '../../config/logger.config'; import { BadRequestException } from '../../exceptions'; +import { CacheEngine } from '../../libs/cacheengine'; import { ChatwootDto } from '../dto/chatwoot.dto'; import { InstanceDto } from '../dto/instance.dto'; import { RepositoryBroker } from '../repository/repository.manager'; +import { CacheService } from '../services/cache.service'; import { ChatwootService } from '../services/chatwoot.service'; import { waMonitor } from '../whatsapp.module'; @@ -94,7 +96,9 @@ export class ChatwootController { public async receiveWebhook(instance: InstanceDto, data: any) { logger.verbose('requested receiveWebhook from ' + instance.instanceName + ' instance'); - const chatwootService = new ChatwootService(waMonitor, this.configService, this.repository); + + const chatwootCache = new CacheService(new CacheEngine(this.configService, ChatwootService.name).getEngine()); + const chatwootService = new ChatwootService(waMonitor, this.configService, this.repository, chatwootCache); return chatwootService.receiveWebhook(instance, data); } diff --git a/src/whatsapp/controllers/instance.controller.ts b/src/whatsapp/controllers/instance.controller.ts index a6bed9ba..23370fe2 100644 --- a/src/whatsapp/controllers/instance.controller.ts +++ b/src/whatsapp/controllers/instance.controller.ts @@ -10,9 +10,9 @@ import { RedisCache } from '../../libs/redis.client'; import { InstanceDto } from '../dto/instance.dto'; 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 { WAMonitoringService } from '../services/monitor.service'; -import { ProxyService } from '../services/proxy.service'; import { RabbitmqService } from '../services/rabbitmq.service'; import { SettingsService } from '../services/settings.service'; import { SqsService } from '../services/sqs.service'; @@ -34,10 +34,10 @@ export class InstanceController { private readonly settingsService: SettingsService, private readonly websocketService: WebsocketService, private readonly rabbitmqService: RabbitmqService, - private readonly proxyService: ProxyService, private readonly sqsService: SqsService, private readonly typebotService: TypebotService, private readonly cache: RedisCache, + private readonly chatwootCache: CacheService, ) {} private readonly logger = new Logger(InstanceController.name); @@ -77,7 +77,6 @@ export class InstanceController { typebot_delay_message, typebot_unknown_message, typebot_listening_from_me, - proxy, }: InstanceDto) { try { this.logger.verbose('requested createInstance from ' + instanceName + ' instance'); @@ -86,7 +85,15 @@ export class InstanceController { await this.authService.checkDuplicateToken(token); this.logger.verbose('creating instance'); - const instance = new WAStartupClass[integration](this.configService, this.eventEmitter, this.repository, this.cache); + + const instance = new WAStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); + instance.instanceName = instanceName; instance.instanceNumber = number; instance.instanceToken = token; @@ -261,22 +268,6 @@ export class InstanceController { } } - if (proxy) { - this.logger.verbose('creating proxy'); - try { - this.proxyService.create( - instance, - { - enabled: true, - proxy, - }, - false, - ); - } catch (error) { - this.logger.log(error); - } - } - let sqsEvents: string[]; if (sqs_enabled) { @@ -419,7 +410,6 @@ export class InstanceController { settings, webhook_url: webhook_url, qrcode: getQrcode, - proxy, }; this.logger.verbose('instance created'); @@ -525,7 +515,6 @@ export class InstanceController { name_inbox: instance.instanceName, webhook_url: `${urlServer}/chatwoot/webhook/${encodeURIComponent(instance.instanceName)}`, }, - proxy, }; } catch (error) { this.logger.error(error.message[0]); @@ -584,6 +573,7 @@ export class InstanceController { switch (state) { case 'open': this.logger.verbose('logging out instance: ' + instanceName); + instance.clearCacheChatwoot(); await instance.reloadConnection(); await delay(2000); @@ -649,6 +639,7 @@ export class InstanceController { } try { this.waMonitor.waInstances[instanceName]?.removeRabbitmqQueues(); + this.waMonitor.waInstances[instanceName]?.clearCacheChatwoot(); if (instance.state === 'connecting') { this.logger.verbose('logging out instance: ' + instanceName); @@ -658,10 +649,15 @@ export class InstanceController { this.logger.verbose('deleting instance: ' + instanceName); - this.waMonitor.waInstances[instanceName].sendDataWebhook(Events.INSTANCE_DELETE, { - instanceName, - instanceId: (await this.repository.auth.find(instanceName))?.instanceId, - }); + try { + this.waMonitor.waInstances[instanceName].sendDataWebhook(Events.INSTANCE_DELETE, { + instanceName, + instanceId: (await this.repository.auth.find(instanceName))?.instanceId, + }); + } catch (error) { + this.logger.error(error); + } + delete this.waMonitor.waInstances[instanceName]; this.eventEmitter.emit('remove.instance', instanceName, 'inner'); return { status: 'SUCCESS', error: false, response: { message: 'Instance deleted' } }; diff --git a/src/whatsapp/controllers/proxy.controller.ts b/src/whatsapp/controllers/proxy.controller.ts index 1656d830..555c5975 100644 --- a/src/whatsapp/controllers/proxy.controller.ts +++ b/src/whatsapp/controllers/proxy.controller.ts @@ -1,4 +1,7 @@ +import axios from 'axios'; + import { Logger } from '../../config/logger.config'; +import { BadRequestException } from '../../exceptions'; import { InstanceDto } from '../dto/instance.dto'; import { ProxyDto } from '../dto/proxy.dto'; import { ProxyService } from '../services/proxy.service'; @@ -13,7 +16,16 @@ export class ProxyController { if (!data.enabled) { logger.verbose('proxy disabled'); - data.proxy = ''; + data.proxy = null; + } + + if (data.proxy) { + logger.verbose('proxy enabled'); + const { host, port, protocol, username, password } = data.proxy; + const testProxy = await this.testProxy(host, port, protocol, username, password); + if (!testProxy) { + throw new BadRequestException('Invalid proxy'); + } } return this.proxyService.create(instance, data); @@ -23,4 +35,36 @@ export class ProxyController { logger.verbose('requested findProxy from ' + instance.instanceName + ' instance'); return this.proxyService.find(instance); } + + private async testProxy(host: string, port: string, protocol: string, username?: string, password?: string) { + logger.verbose('requested testProxy'); + try { + let proxyConfig: any = { + host: host, + port: parseInt(port), + protocol: protocol, + }; + + if (username && password) { + proxyConfig = { + ...proxyConfig, + auth: { + username: username, + password: password, + }, + }; + } + const serverIp = await axios.get('http://meuip.com/api/meuip.php'); + + const response = await axios.get('http://meuip.com/api/meuip.php', { + proxy: proxyConfig, + }); + + logger.verbose('testProxy response: ' + response.data); + return response.data !== serverIp.data; + } catch (error) { + logger.error('testProxy error: ' + error); + return false; + } + } } diff --git a/src/whatsapp/dto/chat.dto.ts b/src/whatsapp/dto/chat.dto.ts index 49396500..cc7b2aa1 100644 --- a/src/whatsapp/dto/chat.dto.ts +++ b/src/whatsapp/dto/chat.dto.ts @@ -103,3 +103,9 @@ export class SendPresenceDto extends Metadata { delay: number; }; } + +export class UpdateMessageDto extends Metadata { + number: string; + key: proto.IMessageKey; + text: string; +} diff --git a/src/whatsapp/dto/proxy.dto.ts b/src/whatsapp/dto/proxy.dto.ts index 0b6b2e70..7f3e7c06 100644 --- a/src/whatsapp/dto/proxy.dto.ts +++ b/src/whatsapp/dto/proxy.dto.ts @@ -1,4 +1,12 @@ +class Proxy { + host: string; + port: string; + protocol: string; + username?: string; + password?: string; +} + export class ProxyDto { enabled: boolean; - proxy: string; + proxy: Proxy; } diff --git a/src/whatsapp/models/message.model.ts b/src/whatsapp/models/message.model.ts index 2b59f3a5..9c7ac9dc 100644 --- a/src/whatsapp/models/message.model.ts +++ b/src/whatsapp/models/message.model.ts @@ -14,6 +14,7 @@ class ChatwootMessage { messageId?: number; inboxId?: number; conversationId?: number; + contactInbox?: { sourceId: string }; } export class MessageRaw { @@ -29,6 +30,7 @@ export class MessageRaw { source_id?: string; source_reply_id?: string; chatwoot?: ChatwootMessage; + contextInfo?: any; } const messageSchema = new Schema({ @@ -50,6 +52,7 @@ const messageSchema = new Schema({ messageId: { type: Number }, inboxId: { type: Number }, conversationId: { type: Number }, + contactInbox: { type: Object }, }, }); diff --git a/src/whatsapp/models/proxy.model.ts b/src/whatsapp/models/proxy.model.ts index 3dea4f0c..4096f58f 100644 --- a/src/whatsapp/models/proxy.model.ts +++ b/src/whatsapp/models/proxy.model.ts @@ -2,16 +2,30 @@ import { Schema } from 'mongoose'; import { dbserver } from '../../libs/db.connect'; +class Proxy { + host?: string; + port?: string; + protocol?: string; + username?: string; + password?: string; +} + export class ProxyRaw { _id?: string; enabled?: boolean; - proxy?: string; + proxy?: Proxy; } const proxySchema = new Schema({ _id: { type: String, _id: true }, enabled: { type: Boolean, required: true }, - proxy: { type: String, required: true }, + proxy: { + host: { type: String, required: true }, + port: { type: String, required: true }, + protocol: { type: String, required: true }, + username: { type: String, required: false }, + password: { type: String, required: false }, + }, }); export const ProxyModel = dbserver?.model(ProxyRaw.name, proxySchema, 'proxy'); diff --git a/src/whatsapp/repository/message.repository.ts b/src/whatsapp/repository/message.repository.ts index cd102f08..594c757b 100644 --- a/src/whatsapp/repository/message.repository.ts +++ b/src/whatsapp/repository/message.repository.ts @@ -1,4 +1,4 @@ -import { opendirSync, readFileSync } from 'fs'; +import { opendirSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { ConfigService, StoreConf } from '../../config/env.config'; @@ -18,6 +18,19 @@ export class MessageRepository extends Repository { private readonly logger = new Logger('MessageRepository'); + public buildQuery(query: MessageQuery): MessageQuery { + for (const [o, p] of Object.entries(query?.where)) { + if (typeof p === 'object' && p !== null && !Array.isArray(p)) { + for (const [k, v] of Object.entries(p)) { + query.where[`${o}.${k}`] = v; + } + delete query.where[o]; + } + } + + return query; + } + public async insert(data: MessageRaw[], instanceName: string, saveDb = false): Promise { this.logger.verbose('inserting messages'); @@ -91,14 +104,7 @@ export class MessageRepository extends Repository { this.logger.verbose('finding messages'); if (this.dbSettings.ENABLED) { this.logger.verbose('finding messages in db'); - for (const [o, p] of Object.entries(query?.where)) { - if (typeof p === 'object' && p !== null && !Array.isArray(p)) { - for (const [k, v] of Object.entries(p)) { - query.where[`${o}.${k}`] = v; - } - delete query.where[o]; - } - } + query = this.buildQuery(query); return await this.messageModel .find({ ...query.where }) @@ -198,15 +204,23 @@ export class MessageRepository extends Repository { } } - public async delete(query: any) { + public async delete(query: MessageQuery) { try { - this.logger.verbose('deleting messages'); + this.logger.verbose('deleting message'); if (this.dbSettings.ENABLED) { - this.logger.verbose('deleting messages in db'); - return await this.messageModel.deleteMany(query); + this.logger.verbose('deleting message in db'); + query = this.buildQuery(query); + + return await this.messageModel.deleteOne({ ...query.where }); } - return { deleted: { chatId: query.where.messageTimestamp } }; + this.logger.verbose('deleting message in store'); + rmSync(join(this.storePath, 'messages', query.where.owner, query.where.key.id + '.json'), { + force: true, + recursive: true, + }); + + return { deleted: { messageId: query.where.key.id } }; } catch (error) { return { error: error?.toString() }; } diff --git a/src/whatsapp/routers/chat.router.ts b/src/whatsapp/routers/chat.router.ts index b1583a89..6d0bc7f3 100644 --- a/src/whatsapp/routers/chat.router.ts +++ b/src/whatsapp/routers/chat.router.ts @@ -14,6 +14,7 @@ import { profileSchema, profileStatusSchema, readMessageSchema, + updateMessageSchema, whatsappNumberSchema, profileBusinessSchema, } from '../../validate/validate.schema'; @@ -29,6 +30,7 @@ import { ProfileStatusDto, ReadMessageDto, SendPresenceDto, + UpdateMessageDto, WhatsAppNumberDto, NumberBusiness, } from '../dto/chat.dto'; @@ -383,6 +385,23 @@ export class ChatRouter extends RouterBroker { execute: (instance) => chatController.removeProfilePicture(instance), }); + return res.status(HttpStatus.OK).json(response); + }) + .put(this.routerPath('updateMessage'), ...guards, async (req, res) => { + logger.verbose('request received in updateMessage'); + logger.verbose('request body: '); + logger.verbose(req.body); + + logger.verbose('request query: '); + logger.verbose(req.query); + + const response = await this.dataValidate({ + request: req, + schema: updateMessageSchema, + ClassRef: UpdateMessageDto, + execute: (instance, data) => chatController.updateMessage(instance, data), + }); + return res.status(HttpStatus.OK).json(response); }); } diff --git a/src/whatsapp/services/cache.service.ts b/src/whatsapp/services/cache.service.ts new file mode 100644 index 00000000..0db39a44 --- /dev/null +++ b/src/whatsapp/services/cache.service.ts @@ -0,0 +1,62 @@ +import { Logger } from '../../config/logger.config'; +import { ICache } from '../abstract/abstract.cache'; + +export class CacheService { + private readonly logger = new Logger(CacheService.name); + + constructor(private readonly cache: ICache) { + if (cache) { + this.logger.verbose(`cacheservice created using cache engine: ${cache.constructor?.name}`); + } else { + this.logger.verbose(`cacheservice disabled`); + } + } + + async get(key: string): Promise { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice getting key: ${key}`); + return this.cache.get(key); + } + + async set(key: string, value: any) { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice setting key: ${key}`); + this.cache.set(key, value); + } + + async has(key: string) { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice has key: ${key}`); + return this.cache.has(key); + } + + async delete(key: string) { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice deleting key: ${key}`); + return this.cache.delete(key); + } + + async deleteAll(appendCriteria?: string) { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice deleting all keys`); + return this.cache.deleteAll(appendCriteria); + } + + async keys(appendCriteria?: string) { + if (!this.cache) { + return; + } + this.logger.verbose(`cacheservice getting all keys`); + return this.cache.keys(appendCriteria); + } +} diff --git a/src/whatsapp/services/chatwoot.service.ts b/src/whatsapp/services/chatwoot.service.ts index e3563302..f965a7e6 100644 --- a/src/whatsapp/services/chatwoot.service.ts +++ b/src/whatsapp/services/chatwoot.service.ts @@ -1,26 +1,24 @@ -import ChatwootClient from '@figuro/chatwoot-sdk'; +import ChatwootClient, { ChatwootAPIConfig, contact, conversation, inbox } from '@figuro/chatwoot-sdk'; +import { request as chatwootRequest } from '@figuro/chatwoot-sdk/dist/core/request'; import axios from 'axios'; import FormData from 'form-data'; -import { createReadStream, readFileSync, unlinkSync, writeFileSync } from 'fs'; +import { createReadStream, unlinkSync, writeFileSync } from 'fs'; import Jimp from 'jimp'; import mimeTypes from 'mime-types'; import path from 'path'; import { ConfigService, HttpServer, WABussiness } from '../../config/env.config'; import { Logger } from '../../config/logger.config'; -import { ROOT_DIR } from '../../config/path.config'; +import { ICache } from '../abstract/abstract.cache'; import { ChatwootDto } from '../dto/chatwoot.dto'; import { InstanceDto } from '../dto/instance.dto'; import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto, SendTemplateDto } from '../dto/sendMessage.dto'; -import { MessageRaw } from '../models'; +import { ChatwootRaw, MessageRaw } from '../models'; import { RepositoryBroker } from '../repository/repository.manager'; import { Events } from '../types/wa.types'; import { WAMonitoringService } from './monitor.service'; export class ChatwootService { - private messageCacheFile: string; - private messageCache: Set; - private readonly logger = new Logger(ChatwootService.name); private provider: any; @@ -29,35 +27,15 @@ export class ChatwootService { private readonly waMonitor: WAMonitoringService, private readonly configService: ConfigService, private readonly repository: RepositoryBroker, - ) { - this.messageCache = new Set(); - } - - private loadMessageCache(): Set { - this.logger.verbose('load message cache'); - try { - const cacheData = readFileSync(this.messageCacheFile, 'utf-8'); - const cacheArray = cacheData.split('\n'); - return new Set(cacheArray); - } catch (error) { - return new Set(); - } - } - - private saveMessageCache() { - this.logger.verbose('save message cache'); - const cacheData = Array.from(this.messageCache).join('\n'); - writeFileSync(this.messageCacheFile, cacheData, 'utf-8'); - this.logger.verbose('message cache saved'); - } - - private clearMessageCache() { - this.logger.verbose('clear message cache'); - this.messageCache.clear(); - this.saveMessageCache(); - } + private readonly cache: ICache, + ) {} private async getProvider(instance: InstanceDto) { + const cacheKey = `${instance.instanceName}:getProvider`; + if (await this.cache.has(cacheKey)) { + return (await this.cache.get(cacheKey)) as ChatwootRaw; + } + this.logger.verbose('get provider to instance: ' + instance.instanceName); const provider = await this.waMonitor.waInstances[instance.instanceName]?.findChatwoot(); @@ -68,6 +46,8 @@ export class ChatwootService { this.logger.verbose('provider found'); + this.cache.set(cacheKey, provider); + return provider; // try { // } catch (error) { @@ -92,12 +72,7 @@ export class ChatwootService { this.logger.verbose('create client to instance: ' + instance.instanceName); const client = new ChatwootClient({ - config: { - basePath: provider.url, - with_credentials: true, - credentials: 'include', - token: provider.token, - }, + config: this.getClientCwConfig(), }); this.logger.verbose('client created'); @@ -105,6 +80,19 @@ export class ChatwootService { return client; } + public getClientCwConfig(): ChatwootAPIConfig { + return { + basePath: this.provider.url, + with_credentials: true, + credentials: 'include', + token: this.provider.token, + }; + } + + public getCache() { + return this.cache; + } + public async create(instance: InstanceDto, data: ChatwootDto) { this.logger.verbose('create chatwoot: ' + instance.instanceName); @@ -419,6 +407,26 @@ export class ChatwootService { return null; } + const cacheKey = `${instance.instanceName}:createConversation-${body.key.remoteJid}`; + if (await this.cache.has(cacheKey)) { + const conversationId = (await this.cache.get(cacheKey)) as number; + let conversationExists: conversation | boolean; + try { + conversationExists = await client.conversations.get({ + accountId: this.provider.account_id, + conversationId: conversationId, + }); + } catch (error) { + conversationExists = false; + } + if (!conversationExists) { + this.cache.delete(cacheKey); + return await this.createConversation(instance, body); + } + + return conversationId; + } + const isGroup = body.key.remoteJid.includes('@g.us'); this.logger.verbose('is group: ' + isGroup); @@ -569,6 +577,7 @@ export class ChatwootService { if (conversation) { this.logger.verbose('conversation found'); + this.cache.set(cacheKey, conversation.id); return conversation.id; } } @@ -594,6 +603,7 @@ export class ChatwootService { } this.logger.verbose('conversation created'); + this.cache.set(cacheKey, conversation.id); return conversation.id; } catch (error) { this.logger.error(error); @@ -603,6 +613,11 @@ export class ChatwootService { public async getInbox(instance: InstanceDto) { this.logger.verbose('get inbox to instance: ' + instance.instanceName); + const cacheKey = `${instance.instanceName}:getInbox`; + if (await this.cache.has(cacheKey)) { + return (await this.cache.get(cacheKey)) as inbox; + } + const client = await this.clientCw(instance); if (!client) { @@ -629,6 +644,7 @@ export class ChatwootService { } this.logger.verbose('return inbox'); + this.cache.set(cacheKey, findByName); return findByName; } @@ -644,6 +660,7 @@ export class ChatwootService { filename: string; }[], messageBody?: any, + sourceId?: string, ) { this.logger.verbose('create message to instance: ' + instance.instanceName); @@ -665,6 +682,7 @@ export class ChatwootService { message_type: messageType, attachments: attachments, private: privateMessage || false, + source_id: sourceId, content_attributes: { ...replyToIds, }, @@ -765,6 +783,7 @@ export class ChatwootService { content?: string, instance?: InstanceDto, messageBody?: any, + sourceId?: string, ) { this.logger.verbose('send data to chatwoot'); @@ -791,6 +810,10 @@ export class ChatwootService { } } + if (sourceId) { + data.append('source_id', sourceId); + } + this.logger.verbose('get client to instance: ' + this.provider.instanceName); const config = { method: 'post', @@ -916,17 +939,21 @@ export class ChatwootService { try { this.logger.verbose('get media type'); - const parts = media.split('/'); + const parsedMedia = path.parse(decodeURIComponent(media)); + let mimeType = mimeTypes.lookup(parsedMedia?.ext) || ''; + let fileName = parsedMedia?.name + parsedMedia?.ext; - const fileName = decodeURIComponent(parts[parts.length - 1]); - this.logger.verbose('file name: ' + fileName); + if (!mimeType) { + const parts = media.split('/'); + fileName = decodeURIComponent(parts[parts.length - 1]); + this.logger.verbose('file name: ' + fileName); - const response = await axios.get(media, { - responseType: 'arraybuffer', - }); - - const mimeType = response.headers['content-type']; - this.logger.verbose('mime type: ' + mimeType); + const response = await axios.get(media, { + responseType: 'arraybuffer', + }); + mimeType = response.headers['content-type']; + this.logger.verbose('mime type: ' + mimeType); + } let type = 'document'; @@ -1008,6 +1035,17 @@ export class ChatwootService { return null; } + // invalidate the conversation cache if reopen_conversation is false and the conversation was resolved + if ( + this.provider.reopen_conversation === false && + body.event === 'conversation_status_changed' && + body.status === 'resolved' && + body.meta?.sender?.identifier + ) { + const keyToDelete = `${instance.instanceName}:createConversation-${body.meta.sender.identifier}`; + this.cache.delete(keyToDelete); + } + this.logger.verbose('check if is bot'); if ( !body?.conversation || @@ -1029,7 +1067,7 @@ export class ChatwootService { .replaceAll(/(? { if (key.startsWith('item') && key.includes('TEL')) { const phoneNumber = contactInfo[key]; - formattedContact += `\n**number ${numberCount}:** ${phoneNumber}`; + formattedContact += '\n_Number (' + numberCount + '):_ ' + phoneNumber; numberCount++; - } - if (key.includes('TEL')) { + } else if (key.includes('TEL')) { const phoneNumber = contactInfo[key]; - formattedContact += `\n**number:** ${phoneNumber}`; + formattedContact += '\n_Number (' + numberCount + '):_ ' + phoneNumber; numberCount++; } }); @@ -1468,19 +1528,17 @@ export class ChatwootService { } }); - let formattedContact = `**Contact:** - **name:** ${contact.displayName}`; + let formattedContact = '*Contact:*\n\n' + '_Name:_ ' + contact.displayName; let numberCount = 1; Object.keys(contactInfo).forEach((key) => { if (key.startsWith('item') && key.includes('TEL')) { const phoneNumber = contactInfo[key]; - formattedContact += `\n**number ${numberCount}:** ${phoneNumber}`; + formattedContact += '\n_Number (' + numberCount + '):_ ' + phoneNumber; numberCount++; - } - if (key.includes('TEL')) { + } else if (key.includes('TEL')) { const phoneNumber = contactInfo[key]; - formattedContact += `\n**number:** ${phoneNumber}`; + formattedContact += '\n_Number (' + numberCount + '):_ ' + phoneNumber; numberCount++; } }); @@ -1495,6 +1553,62 @@ export class ChatwootService { return formattedContactsArray; } + if (typeKey === 'listMessage') { + const listTitle = result?.title || 'Unknown'; + const listDescription = result?.description || 'Unknown'; + const listFooter = result?.footerText || 'Unknown'; + + let formattedList = + '*List Menu:*\n\n' + + '_Title_: ' + + listTitle + + '\n' + + '_Description_: ' + + listDescription + + '\n' + + '_Footer_: ' + + listFooter; + + if (result.sections && result.sections.length > 0) { + result.sections.forEach((section, sectionIndex) => { + formattedList += '\n\n*Section ' + (sectionIndex + 1) + ':* ' + section.title || 'Unknown\n'; + + if (section.rows && section.rows.length > 0) { + section.rows.forEach((row, rowIndex) => { + formattedList += '\n*Line ' + (rowIndex + 1) + ':*\n'; + formattedList += '_▪️ Title:_ ' + (row.title || 'Unknown') + '\n'; + formattedList += '_▪️ Description:_ ' + (row.description || 'Unknown') + '\n'; + formattedList += '_▪️ ID:_ ' + (row.rowId || 'Unknown') + '\n'; + }); + } else { + formattedList += '\nNo lines found in this section.\n'; + } + }); + } else { + formattedList += '\nNo sections found.\n'; + } + + return formattedList; + } + + if (typeKey === 'listResponseMessage') { + const responseTitle = result?.title || 'Unknown'; + const responseDescription = result?.description || 'Unknown'; + const responseRowId = result?.singleSelectReply?.selectedRowId || 'Unknown'; + + const formattedResponseList = + '*List Response:*\n\n' + + '_Title_: ' + + responseTitle + + '\n' + + '_Description_: ' + + responseDescription + + '\n' + + '_ID_: ' + + responseRowId; + return formattedResponseList; + } + this.logger.verbose('message content: ' + result); return result; @@ -1591,8 +1705,21 @@ export class ChatwootService { }, }); - const random = Math.random().toString(36).substring(7); - const nameFile = `${random}.${mimeTypes.extension(downloadBase64.mimetype)}`; + let nameFile: string; + const messageBody = body?.message[body?.messageType]; + const originalFilename = messageBody?.fileName || messageBody?.message?.documentMessage?.fileName; + if (originalFilename) { + const parsedFile = path.parse(originalFilename); + if (parsedFile.name && parsedFile.ext) { + nameFile = `${parsedFile.name}-${Math.floor(Math.random() * (99 - 10 + 1) + 10)}${parsedFile.ext}`; + } + } + + if (!nameFile) { + nameFile = `${Math.random().toString(36).substring(7)}.${ + mimeTypes.extension(downloadBase64.mimetype) || '' + }`; + } const fileData = Buffer.from(downloadBase64.base64, 'base64'); @@ -1620,43 +1747,41 @@ export class ChatwootService { } this.logger.verbose('send data to chatwoot'); - const send = await this.sendData(getConversation, fileName, messageType, content, instance, body); + const send = await this.sendData( + getConversation, + fileName, + messageType, + content, + instance, + body, + 'WAID:' + body.key.id, + ); if (!send) { this.logger.warn('message not sent'); return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - - this.messageCache = this.loadMessageCache(); - - this.messageCache.add(send.id.toString()); - - this.logger.verbose('save message cache'); - this.saveMessageCache(); - return send; } else { this.logger.verbose('message is not group'); this.logger.verbose('send data to chatwoot'); - const send = await this.sendData(getConversation, fileName, messageType, bodyMessage, instance, body); + const send = await this.sendData( + getConversation, + fileName, + messageType, + bodyMessage, + instance, + body, + 'WAID:' + body.key.id, + ); if (!send) { this.logger.warn('message not sent'); return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - - this.messageCache = this.loadMessageCache(); - - this.messageCache.add(send.id.toString()); - - this.logger.verbose('save message cache'); - this.saveMessageCache(); - return send; } } @@ -1675,16 +1800,12 @@ export class ChatwootService { { message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, }, + 'WAID:' + body.key.id, ); if (!send) { this.logger.warn('message not sent'); return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - this.messageCache = this.loadMessageCache(); - this.messageCache.add(send.id.toString()); - this.logger.verbose('save message cache'); - this.saveMessageCache(); } return; @@ -1734,6 +1855,7 @@ export class ChatwootService { `${bodyMessage}\n\n\n**${title}**\n${description}\n${adsMessage.sourceUrl}`, instance, body, + 'WAID:' + body.key.id, ); if (!send) { @@ -1741,15 +1863,6 @@ export class ChatwootService { return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - - this.messageCache = this.loadMessageCache(); - - this.messageCache.add(send.id.toString()); - - this.logger.verbose('save message cache'); - this.saveMessageCache(); - return send; } @@ -1769,43 +1882,43 @@ export class ChatwootService { } this.logger.verbose('send data to chatwoot'); - const send = await this.createMessage(instance, getConversation, content, messageType, false, [], body); + const send = await this.createMessage( + instance, + getConversation, + content, + messageType, + false, + [], + body, + 'WAID:' + body.key.id, + ); if (!send) { this.logger.warn('message not sent'); return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - - this.messageCache = this.loadMessageCache(); - - this.messageCache.add(send.id.toString()); - - this.logger.verbose('save message cache'); - this.saveMessageCache(); - return send; } else { this.logger.verbose('message is not group'); this.logger.verbose('send data to chatwoot'); - const send = await this.createMessage(instance, getConversation, bodyMessage, messageType, false, [], body); + const send = await this.createMessage( + instance, + getConversation, + bodyMessage, + messageType, + false, + [], + body, + 'WAID:' + body.key.id, + ); if (!send) { this.logger.warn('message not sent'); return; } - this.messageCacheFile = path.join(ROOT_DIR, 'store', 'chatwoot', `${instance.instanceName}_cache.txt`); - - this.messageCache = this.loadMessageCache(); - - this.messageCache.add(send.id.toString()); - - this.logger.verbose('save message cache'); - this.saveMessageCache(); - return send; } } @@ -1820,6 +1933,16 @@ export class ChatwootService { const message = await this.getMessageByKeyId(instance, body.key.id); if (message?.chatwoot?.messageId && message?.chatwoot?.conversationId) { + this.logger.verbose('deleting message in repository. Message id: ' + body.key.id); + this.repository.message.delete({ + where: { + key: { + id: body.key.id, + }, + owner: instance.instanceName, + }, + }); + this.logger.verbose('deleting message in chatwoot. Message id: ' + body.key.id); return await client.messages.delete({ accountId: this.provider.account_id, @@ -1829,6 +1952,44 @@ export class ChatwootService { } } + if (event === 'messages.read') { + this.logger.verbose('read message from instance: ' + instance.instanceName); + + if (!body?.key?.id || !body?.key?.remoteJid) { + this.logger.warn('message id not found'); + return; + } + + const message = await this.getMessageByKeyId(instance, body.key.id); + const { conversationId, contactInbox } = message?.chatwoot || {}; + if (conversationId) { + let sourceId = contactInbox?.sourceId; + const inbox = (await this.getInbox(instance)) as inbox & { + inbox_identifier?: string; + }; + + if (!sourceId && inbox) { + const contact = (await this.findContact( + instance, + this.getNumberFromRemoteJid(body.key.remoteJid), + )) as contact; + const contactInbox = contact?.contact_inboxes?.find((contactInbox) => contactInbox?.inbox?.id === inbox.id); + sourceId = contactInbox?.source_id; + } + + if (sourceId && inbox?.inbox_identifier) { + const url = + `/public/api/v1/inboxes/${inbox.inbox_identifier}/contacts/${sourceId}` + + `/conversations/${conversationId}/update_last_seen`; + chatwootRequest(this.getClientCwConfig(), { + method: 'POST', + url: url, + }); + } + } + return; + } + if (event === 'status.instance') { this.logger.verbose('event status.instance'); const data = body; @@ -1854,6 +2015,7 @@ export class ChatwootService { const msgConnection = `🚀 Connection successfully established!`; this.logger.verbose('send message to chatwoot'); await this.createBotMessage(instance, msgConnection, 'incoming'); + this.waMonitor.waInstances[instance.instanceName].qrCode.count = 0; } } } @@ -1899,4 +2061,8 @@ export class ChatwootService { this.logger.error(error); } } + + public getNumberFromRemoteJid(remoteJid: string) { + return remoteJid.replace(/:\d+/, '').split('@')[0]; + } } diff --git a/src/whatsapp/services/monitor.service.ts b/src/whatsapp/services/monitor.service.ts index 3562afba..7a0c1d2a 100644 --- a/src/whatsapp/services/monitor.service.ts +++ b/src/whatsapp/services/monitor.service.ts @@ -26,6 +26,7 @@ import { WebsocketModel, } from '../models'; import { RepositoryBroker } from '../repository/repository.manager'; +import { CacheService } from './cache.service'; import { WAStartupService } from './whatsapp.service'; import { WAStartupClass } from '../whatsapp.module'; @@ -35,6 +36,7 @@ export class WAMonitoringService { private readonly configService: ConfigService, private readonly repository: RepositoryBroker, private readonly cache: RedisCache, + private readonly chatwootCache: CacheService, ) { this.logger.verbose('instance created'); @@ -360,14 +362,13 @@ export class WAMonitoringService { } private async setInstance(name: string) { - const path = join(INSTANCE_DIR, name); - let values: any; - if(this.db.ENABLED ) - values = await this.dbInstance.collection(name).findOne({ _id: 'integration' }) - else - values = JSON.parse(readFileSync(path + '/integration.json', 'utf8')); - const instance = new WAStartupClass[values.integration] - (this.configService, this.eventEmitter, this.repository, this.cache); + const instance = new WAStartupService( + this.configService, + this.eventEmitter, + this.repository, + this.cache, + this.chatwootCache, + ); instance.instanceName = name; instance.instanceNumber = values.number; instance.instanceToken = values.token; @@ -451,6 +452,7 @@ export class WAMonitoringService { this.eventEmitter.on('logout.instance', async (instanceName: string) => { this.logger.verbose('logout instance: ' + instanceName); try { + this.waInstances[instanceName]?.clearCacheChatwoot(); this.logger.verbose('request cleaning up instance: ' + instanceName); this.cleaningUp(instanceName); } finally { diff --git a/src/whatsapp/services/proxy.service.ts b/src/whatsapp/services/proxy.service.ts index 1039fd5c..66dc5342 100644 --- a/src/whatsapp/services/proxy.service.ts +++ b/src/whatsapp/services/proxy.service.ts @@ -27,7 +27,7 @@ export class ProxyService { return result; } catch (error) { - return { enabled: false, proxy: '' }; + return { enabled: false, proxy: null }; } } } diff --git a/src/whatsapp/services/typebot.service.ts b/src/whatsapp/services/typebot.service.ts index 90d18745..95d5b49d 100644 --- a/src/whatsapp/services/typebot.service.ts +++ b/src/whatsapp/services/typebot.service.ts @@ -389,6 +389,7 @@ export class TypebotService { input, clientSideActions, this.eventEmitter, + applyFormatting, ).catch((err) => { console.error('Erro ao processar mensagens:', err); }); @@ -403,85 +404,71 @@ export class TypebotService { } return null; } + + function applyFormatting(element) { + let text = ''; - async function processMessages(instance, messages, input, clientSideActions, eventEmitter) { - let qtdMessages = 0, buttonText = ''; + if (element.text) { + text += element.text; + } + + if (element.type === 'p' || element.type === 'inline-variable' || element.type === 'a') { + for (const child of element.children) { + text += applyFormatting(child); + } + } + + let formats = ''; + + if (element.bold) { + formats += '*'; + } + + if (element.italic) { + formats += '_'; + } + + if (element.underline) { + formats += '~'; + } + + let formattedText = `${formats}${text}${formats.split('').reverse().join('')}`; + + if (element.url) { + formattedText = element.children[0]?.text ? `[${formattedText}]\n(${element.url})` : `${element.url}`; + } + + return formattedText; + } + + async function processMessages(instance, messages, input, clientSideActions, eventEmitter, applyFormatting) { for (const message of messages) { const wait = findItemAndGetSecondsToWait(clientSideActions, message.id); if (message.type === 'text') { let formattedText = ''; - let linkPreview = false; - for (const richText of message.content.richText) { - if (richText.type === 'variable') { - for (const child of richText.children) { - for (const grandChild of child.children) { - formattedText += grandChild.text; - } - } - } else { - for (const element of richText.children) { - let text = ''; - - if (element.type === 'inline-variable') { - for (const child of element.children) { - for (const grandChild of child.children) { - text += grandChild.text; - } - } - } else if (element.text) { - text = element.text; - } - - // if (element.text) { - // text = element.text; - // } - - if (element.bold) { - text = `*${text}*`; - } - - if (element.italic) { - text = `_${text}_`; - } - - if (element.underline) { - text = `*${text}*`; - } - - if (element.url) { - const linkText = element.children[0].text; - text = `[${linkText}](${element.url})`; - linkPreview = true; - } - - formattedText += text; - } + for (const element of richText.children) { + formattedText += applyFormatting(element); } formattedText += '\n'; } - formattedText = formattedText.replace(/\n$/, ''); - qtdMessages++; - if (instance?.constructor.name == Integration.WABussinessService && - input?.type === 'choice input' && messages.length == qtdMessages) { - buttonText = formattedText; - } else { - await instance.textMessage({ - number: remoteJid.split('@')[0], - options: { - delay: wait ? wait * 1000 : instance.localTypebot.delay_message || 1000, - presence: 'composing', - linkPreview: linkPreview, - }, - textMessage: { - text: formattedText, - }, - }); - } + formattedText = formattedText.replace(/\*\*/g, '').replace(/__/, '').replace(/~~/, '').replace(/\n$/, ''); + + await instance.textMessage({ + number: remoteJid.split('@')[0], + options: { + delay: wait ? wait * 1000 : instance.localTypebot.delay_message || 1000, + presence: 'composing', + }, + textMessage: { + text: formattedText, + }, + }); } + if (message.type === 'image') { await instance.mediaMessage({ number: remoteJid.split('@')[0], @@ -570,6 +557,19 @@ export class TypebotService { }, }); } + + formattedText = formattedText.replace(/\n$/, ''); + + await instance.textMessage({ + number: remoteJid.split('@')[0], + options: { + delay: 1200, + presence: 'composing', + }, + textMessage: { + text: formattedText, + }, + }); } } else { eventEmitter.emit('typebot:end', { diff --git a/src/whatsapp/services/whatsapp.service.ts b/src/whatsapp/services/whatsapp.service.ts index 6a590835..1b4c5a5d 100644 --- a/src/whatsapp/services/whatsapp.service.ts +++ b/src/whatsapp/services/whatsapp.service.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { execSync } from 'child_process'; -import { isURL } from 'class-validator'; +import { isURL } from 'class-validator import EventEmitter2 from 'eventemitter2'; import Long from 'long'; import { join } from 'path'; @@ -18,6 +18,42 @@ import { Websocket, Redis, } 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 { 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 { 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 { + CreateGroupDto, + GetParticipant, + GroupDescriptionDto, + GroupInvite, + GroupJid, + GroupPictureDto, + GroupSendInvite, + GroupSubjectDto, + GroupToggleEphemeralDto, + GroupUpdateParticipantDto, + GroupUpdateSettingDto, +} from '../dto/group.dto'; import { ContactMessage, MediaMessage, @@ -65,6 +101,7 @@ import { WebsocketRaw } from '../models/websocket.model'; import { RepositoryBroker } from '../repository/repository.manager'; 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'; @@ -75,10 +112,11 @@ import { MessageUpQuery } from '../repository/messageUp.repository'; export class WAStartupService { constructor( - protected readonly configService: ConfigService, - protected readonly eventEmitter: EventEmitter2, - protected readonly repository: RepositoryBroker, - protected readonly cache: RedisCache, + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + private readonly repository: RepositoryBroker, + private readonly cache: RedisCache, + private readonly chatwootCache: CacheService, ) { this.logger.verbose('WAStartupService initialized'); this.cleanStore(); @@ -159,8 +197,15 @@ export class WAStartupService { public async leaveGroup(id: GroupJid): Promise {} public async sendInvite(id: GroupSendInvite): Promise {} public async closeClient(): Promise {} + + 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; + private chatwootService = new ChatwootService(waMonitor, this.configService, this.repository, this.chatwootCache); public set instanceName(name: string) { @@ -321,6 +366,8 @@ export class WAStartupService { Object.assign(this.localChatwoot, { ...data, sign_delimiter: data.sign_msg ? data.sign_delimiter : null }); + this.clearCacheChatwoot(); + this.logger.verbose('Chatwoot set'); } @@ -354,6 +401,14 @@ export class WAStartupService { conversation_pending: data.conversation_pending, }; } + + public clearCacheChatwoot() { + this.logger.verbose('Removing cache from chatwoot'); + + if (this.localChatwoot.enabled) { + this.chatwootService.getCache()?.deleteAll(this.instanceName); + } + } protected async loadSettings() { this.logger.verbose('Loading settings'); @@ -727,6 +782,7 @@ export class WAStartupService { amqp.assertExchange(exchangeName, 'topic', { durable: true, autoDelete: false, + assert: true, }); const queueName = `${this.instanceName}.${event}`; @@ -1102,12 +1158,2443 @@ export class WAStartupService { this.loadSqs(); this.loadTypebot(); this.loadChamaai(); - - return + + 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')) { + 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: new ProxyAgent(proxyUrl as any), + }; + } else { + let proxyUri = + this.localProxy.proxy.protocol + '://' + this.localProxy.proxy.host + ':' + this.localProxy.proxy.port; + + if (this.localProxy.proxy.username && this.localProxy.proxy.password) { + proxyUri = `${this.localProxy.proxy.username}:${this.localProxy.proxy.password}@${proxyUri}`; + } + + options = { + agent: new ProxyAgent(proxyUri as any), + }; + } + } + + 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: false, + 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()); } } -} \ No newline at end of file + 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: new ProxyAgent(this.localProxy.proxy as any), + fetchAgent: new ProxyAgent(this.localProxy.proxy as any), + }; + } + + 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: false, + 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) => { + this.logger.verbose('Event received: contacts.upsert'); + + this.logger.verbose('Finding contacts in database'); + const contactsRepository = await this.repository.contact.find({ + where: { owner: this.instance.name }, + }); + + this.logger.verbose('Verifying if contacts exists in database to insert'); + const contactsRaw: ContactRaw[] = []; + for await (const contact of contacts) { + if (contactsRepository.find((cr) => cr.id === contact.id)) { + continue; + } + + 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_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); + }, + + '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, + isLatest, + }: { + chats: Chat[]; + contacts: Contact[]; + messages: proto.IWebMessageInfo[]; + isLatest: boolean; + }, + database: Database, + ) => { + this.logger.verbose('Event received: messaging-history.set'); + if (isLatest) { + this.logger.verbose('isLatest defined as true'); + const chatsRaw: ChatRaw[] = chats.map((chat) => { + return { + 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 = await this.repository.message.find({ + where: { owner: this.instance.name }, + }); + for await (const [, m] of Object.entries(messages)) { + if (!m.message) { + continue; + } + if (messagesRepository.find((mr) => mr.owner === this.instance.name && mr.key.id === m.key.id)) { + continue; + } + + if (Long.isLong(m?.messageTimestamp)) { + m.messageTimestamp = m.messageTimestamp?.toNumber(); + } + + messagesRaw.push({ + key: m.key, + pushName: m.pushName, + 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]); + + messages = undefined; + }, + + '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 eventHandler() { + this.logger.verbose('Initializing event handler'); + this.client.ev.process(async (events) => { + if (!this.endSession) { + 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); + } + } + }); + } + + // Check if the number is MX or AR + private formatMXOrARNumber(jid: string): string { + const countryCode = jid.substring(0, 2); + + if (Number(countryCode) === 52 || Number(countryCode) === 54) { + if (jid.length === 13) { + const number = countryCode + jid.substring(3); + return number; + } + + return jid; + } + return jid; + } + + // Check if the number is br + private formatBRNumber(jid: string) { + const regexp = new RegExp(/^(\d{2})(\d{2})\d{1}(\d{8})$/); + if (regexp.test(jid)) { + const match = regexp.exec(jid); + if (match && match[1] === '55') { + const joker = Number.parseInt(match[3][0]); + const ddd = Number.parseInt(match[2]); + if (joker < 7 || ddd < 31) { + return match[0]; + } + return match[1] + match[2] + match[3]; + } + return jid; + } else { + return jid; + } + } + + private 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')) { + this.logger.verbose('Number already contains @g.us or @s.whatsapp.net or @lid'); + return number; + } + + if (number.includes('@broadcast')) { + this.logger.verbose('Number already contains @broadcast'); + return number; + } + + number = number + ?.replace(/\s/g, '') + .replace(/\+/g, '') + .replace(/\(/g, '') + .replace(/\)/g, '') + .split(':')[0] + .split('@')[0]; + + if (number.includes('-') && number.length >= 24) { + this.logger.verbose('Jid created is group: ' + `${number}@g.us`); + number = number.replace(/[^\d-]/g, ''); + return `${number}@g.us`; + } + + number = number.replace(/\D/g, ''); + + if (number.length >= 18) { + this.logger.verbose('Jid created is group: ' + `${number}@g.us`); + number = number.replace(/[^\d-]/g, ''); + return `${number}@g.us`; + } + + this.logger.verbose('Jid created is whatsapp: ' + `${number}@s.whatsapp.net`); + 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; + + 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')) { + 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'] && 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 onWhatsapp: OnWhatsAppDto[] = []; + for await (const number of data.numbers) { + let jid = this.createJid(number); + + if (isJidGroup(jid)) { + const group = await this.findGroup({ groupJid: jid }, 'inner'); + + if (!group) throw new BadRequestException('Group not found'); + + onWhatsapp.push(new OnWhatsAppDto(group.id, !!group?.id, group?.subject)); + } else if (jid === 'status@broadcast') { + onWhatsapp.push(new OnWhatsAppDto(jid, false)); + } else { + jid = !jid.startsWith('+') ? `+${jid}` : jid; + const verify = await this.client.onWhatsApp(jid); + + const result = verify[0]; + + if (!result) { + onWhatsapp.push(new OnWhatsAppDto(jid, false)); + } else { + onWhatsapp.push(new OnWhatsAppDto(result.jid, result.exists)); + } + } + } + + 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) { + query.where.owner = this.instance.name; + if (query.where?.id) { + query.where.id = this.createJid(query.where.id); + } + } else { + query = { + where: { + owner: this.instance.name, + }, + }; + } + return await this.repository.contact.find(query); + } + + public async fetchMessages(query: MessageQuery) { + this.logger.verbose('Fetching messages'); + if (query?.where) { + if (query.where?.key?.remoteJid) { + query.where.key.remoteJid = this.createJid(query.where.key.remoteJid); + } + query.where.owner = this.instance.name; + } else { + query = { + where: { + owner: this.instance.name, + }, + limit: query?.limit, + }; + } + return await this.repository.message.find(query); + } + + public async fetchStatusMessage(query: MessageUpQuery) { + this.logger.verbose('Fetching status messages'); + if (query?.where) { + if (query.where?.remoteJid) { + query.where.remoteJid = this.createJid(query.where.remoteJid); + } + query.where.owner = this.instance.name; + } else { + query = { + where: { + owner: this.instance.name, + }, + limit: query?.limit, + }; + } + return await this.repository.messageUpdate.find(query); + } + + public async fetchChats() { + 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 { + return await this.client.groupMetadata(id.groupJid); + } 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()); + + const groups = fetch.map((group) => { + const result = { + id: group.id, + subject: group.subject, + subjectOwner: group.subjectOwner, + subjectTime: group.subjectTime, + 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; + } + + return 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 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; + return { participants }; + } 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()); + } + } +} diff --git a/src/whatsapp/types/wa.types.ts b/src/whatsapp/types/wa.types.ts index cc1f8da8..5bf7faaa 100644 --- a/src/whatsapp/types/wa.types.ts +++ b/src/whatsapp/types/wa.types.ts @@ -112,9 +112,17 @@ export declare namespace wa { sessions?: Session[]; }; + type Proxy = { + host?: string; + port?: string; + protocol?: string; + username?: string; + password?: string; + }; + export type LocalProxy = { enabled?: boolean; - proxy?: string; + proxy?: Proxy; }; export type LocalChamaai = { diff --git a/src/whatsapp/whatsapp.module.ts b/src/whatsapp/whatsapp.module.ts index 407ffba5..d6d5665f 100644 --- a/src/whatsapp/whatsapp.module.ts +++ b/src/whatsapp/whatsapp.module.ts @@ -1,6 +1,7 @@ import { configService } from '../config/env.config'; import { eventEmitter } from '../config/event.config'; import { Logger } from '../config/logger.config'; +import { CacheEngine } from '../libs/cacheengine'; import { dbserver } from '../libs/db.connect'; import { RedisCache } from '../libs/redis.client'; import { ChamaaiController } from './controllers/chamaai.controller'; @@ -48,6 +49,7 @@ import { TypebotRepository } from './repository/typebot.repository'; import { WebhookRepository } from './repository/webhook.repository'; import { WebsocketRepository } from './repository/websocket.repository'; 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 { WAMonitoringService } from './services/monitor.service'; @@ -102,7 +104,9 @@ export const repository = new RepositoryBroker( export const cache = new RedisCache(); -export const waMonitor = new WAMonitoringService(eventEmitter, configService, repository, cache); +const chatwootCache = new CacheService(new CacheEngine(configService, ChatwootService.name).getEngine()); + +export const waMonitor = new WAMonitoringService(eventEmitter, configService, repository, cache, chatwootCache); const authService = new AuthService(configService, waMonitor, repository); @@ -134,7 +138,7 @@ const sqsService = new SqsService(waMonitor); export const sqsController = new SqsController(sqsService); -const chatwootService = new ChatwootService(waMonitor, configService, repository); +const chatwootService = new ChatwootService(waMonitor, configService, repository, chatwootCache); export const chatwootController = new ChatwootController(chatwootService, configService, repository); @@ -153,10 +157,10 @@ export const instanceController = new InstanceController( settingsService, websocketService, rabbitmqService, - proxyService, sqsService, typebotService, cache, + chatwootCache, ); export const sendMessageController = new SendMessageController(waMonitor); export const chatController = new ChatController(waMonitor);