From a6304d3eecacbd82a4169e38c8259312007f11d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Thu, 10 Apr 2025 18:44:57 -0300 Subject: [PATCH] Ajustes da sincronizacao --- BuildImage.ps1 | 11 + Dockerfile | 2 +- package-lock.json | 4 +- package.json | 2 +- .../whatsapp/whatsapp.baileys.service.ts | 166 +- .../chatwoot/services/chatwoot.service.ts | 4386 +++++++++-------- .../chatwoot/utils/chatwoot-import-helper.ts | 345 +- 7 files changed, 2579 insertions(+), 2337 deletions(-) create mode 100644 BuildImage.ps1 diff --git a/BuildImage.ps1 b/BuildImage.ps1 new file mode 100644 index 00000000..1b3490fd --- /dev/null +++ b/BuildImage.ps1 @@ -0,0 +1,11 @@ +(Get-ECRLoginCommand).Password | docker login --username AWS --password-stdin 130811782740.dkr.ecr.us-east-2.amazonaws.com +# + +$ErrorActionPreference = "Stop" + + + + +docker build -t evolution -f .\Dockerfile . +docker tag evolution:latest 130811782740.dkr.ecr.us-east-2.amazonaws.com/evolution +docker push 130811782740.dkr.ecr.us-east-2.amazonaws.com/evolution \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ca61b39a..f2878316 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine AS builder RUN apk update && \ apk add git ffmpeg wget curl bash openssl -LABEL version="2.2.3" description="Api to control whatsapp features through http requests." +LABEL version="2.2.3.3" description="Api to control whatsapp features through http requests." LABEL maintainer="Davidson Gomes" git="https://github.com/DavidsonGomes" LABEL contact="contato@atendai.com" diff --git a/package-lock.json b/package-lock.json index 83bd4d16..f942a6f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evolution-api", - "version": "2.2.3", + "version": "2.2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evolution-api", - "version": "2.2.3", + "version": "2.2.3.3", "license": "Apache-2.0", "dependencies": { "@adiwajshing/keyed-db": "^0.2.4", diff --git a/package.json b/package.json index 7e948028..8920c25e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evolution-api", - "version": "2.2.3", + "version": "2.2.3.3", "description": "Rest api for communication with WhatsApp", "main": "./dist/main.js", "type": "commonjs", diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 10feb7ce..0a56305f 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -146,6 +146,12 @@ import { v4 } from 'uuid'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; + +type DownloadMediaMessageContext = { + reuploadRequest: (msg: WAMessage) => Promise; + logger: P.Logger; +}; + const groupMetadataCache = new CacheService(new CacheEngine(configService, 'groups').getEngine()); // Adicione a função getVideoDuration no início do arquivo @@ -3601,94 +3607,145 @@ export class BaileysStartupService extends ChannelStartupService { } } - public async getBase64FromMediaMessage(data: getBase64FromMediaMessageDto, getBuffer = false) { + + + + + + + + + + + + + + + + + + + + + public async getBase64FromMediaMessage( + data: getBase64FromMediaMessageDto, + getBuffer = false + ) { try { const m = data?.message; const convertToMp4 = data?.convertToMp4 ?? false; - - const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); - + + // Se já houver propriedade "message", usa-o; senão, busca-o via key + const msg: proto.IWebMessageInfo = m?.message + ? m + : (await this.getMessage(m.key, true)) as proto.IWebMessageInfo; if (!msg) { - throw 'Message not found'; + throw new Error('Message not found'); } - + + // Verifica se o conteúdo está aninhado em algum subtipo (ex.: extendedTextMessage) for (const subtype of MessageSubtype) { if (msg.message[subtype]) { msg.message = msg.message[subtype].message; + break; } } - + + // Identifica o tipo de mídia contido na mensagem let mediaMessage: any; - let mediaType: string; - + let mediaType = ''; for (const type of TypeMediaMessage) { - mediaMessage = msg.message[type]; - if (mediaMessage) { + if (msg.message[type]) { + mediaMessage = msg.message[type]; mediaType = type; break; } } - if (!mediaMessage) { - throw 'The message is not of the media type'; + throw new Error('The message is not of the media type'); } - - if (typeof mediaMessage['mediaKey'] === 'object') { + + // Se o mediaKey for um objeto, forçamos a serialização para “descolar” possíveis problemas + if (typeof mediaMessage.mediaKey === 'object') { msg.message = JSON.parse(JSON.stringify(msg.message)); } - - const buffer = await downloadMediaMessage( - { key: msg?.key, message: msg?.message }, - 'buffer', - {}, - { - logger: P({ level: 'error' }) as any, - reuploadRequest: this.client.updateMediaMessage, + + // Define um contexto completo conforme DownloadMediaMessageContext + const downloadContext: DownloadMediaMessageContext = { + logger: P({ level: 'error' }), + reuploadRequest: async (message: WAMessage): Promise => { + // Aqui chamamos explicitamente o método que atualiza a mídia; + // Se o método updateMediaMessage não retornar nada (void), retornamos a própria mensagem. + const updatedMsg = await this.client.updateMediaMessage(message); + return updatedMsg ? updatedMsg : message; }, - ); + }; + + let buffer: Buffer; + try { + // Tenta baixar a mídia usando o contexto com reuploadRequest + buffer = (await downloadMediaMessage( + { key: msg.key, message: msg.message }, + 'buffer', + {}, + downloadContext + )) as Buffer; + } catch (initialError) { + this.logger.warn( + 'Initial downloadMediaMessage failed, updating media and retrying...' + ); + // Se a tentativa falhar (possivelmente por URL expirada), atualiza a mídia e refaz o download + await this.client.updateMediaMessage(msg); + buffer = (await downloadMediaMessage( + { key: msg.key, message: msg.message }, + 'buffer', + {}, + { logger: P({ level: 'error' }), reuploadRequest: async (m: WAMessage) => m } // Contexto “vazio” + )) as Buffer; + } + const typeMessage = getContentType(msg.message); - - const ext = mimeTypes.extension(mediaMessage?.['mimetype']); - const fileName = mediaMessage?.['fileName'] || `${msg.key.id}.${ext}` || `${v4()}.${ext}`; - + const ext = mimeTypes.extension(mediaMessage?.mimetype); + const fileName = + mediaMessage?.fileName || `${msg.key.id}.${ext}` || `${v4()}.${ext}`; + + // Se for áudio e for pedido converter para mp4, processa a conversão if (convertToMp4 && typeMessage === 'audioMessage') { try { - const convert = await this.processAudioMp4(buffer.toString('base64')); - - if (Buffer.isBuffer(convert)) { - const result = { + const converted = await this.processAudioMp4(buffer.toString('base64')); + if (Buffer.isBuffer(converted)) { + return { mediaType, fileName, - caption: mediaMessage['caption'], + caption: mediaMessage.caption, size: { - fileLength: mediaMessage['fileLength'], - height: mediaMessage['height'], - width: mediaMessage['width'], + fileLength: mediaMessage.fileLength, + height: mediaMessage.height, + width: mediaMessage.width, }, mimetype: 'audio/mp4', - base64: convert.toString('base64'), - buffer: getBuffer ? convert : null, + base64: converted.toString('base64'), + buffer: getBuffer ? converted : null, }; - - return result; } - } catch (error) { + } catch (convertError) { this.logger.error('Error converting audio to mp4:'); - this.logger.error(error); + this.logger.error(convertError); throw new BadRequestException('Failed to convert audio to MP4'); } } - + + // Retorna os dados da mídia return { mediaType, fileName, - caption: mediaMessage['caption'], + caption: mediaMessage.caption, size: { - fileLength: mediaMessage['fileLength'], - height: mediaMessage['height'], - width: mediaMessage['width'], + fileLength: mediaMessage.fileLength, + height: mediaMessage.height, + width: mediaMessage.width, }, - mimetype: mediaMessage['mimetype'], + mimetype: mediaMessage.mimetype, base64: buffer.toString('base64'), buffer: getBuffer ? buffer : null, }; @@ -3699,6 +3756,19 @@ export class BaileysStartupService extends ChannelStartupService { } } + + + + + + + + + + + + + public async fetchPrivacySettings() { const privacy = await this.client.fetchPrivacySettings(); diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 77b58bbe..782a4eb2 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1,1182 +1,773 @@ -import { InstanceDto } from '@api/dto/instance.dto'; -import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; -import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; -import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client'; -import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper'; -import { PrismaRepository } from '@api/repository/repository.service'; -import { CacheService } from '@api/services/cache.service'; -import { WAMonitoringService } from '@api/services/monitor.service'; -import { Events } from '@api/types/wa.types'; -import { Chatwoot, ConfigService, Database, HttpServer } from '@config/env.config'; -import { Logger } from '@config/logger.config'; -import ChatwootClient, { - ChatwootAPIConfig, - contact, - contact_inboxes, - conversation, - conversation_show, - generic_id, - inbox, -} from '@figuro/chatwoot-sdk'; -import { request as chatwootRequest } from '@figuro/chatwoot-sdk/dist/core/request'; -import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageModel } from '@prisma/client'; -import i18next from '@utils/i18n'; -import { sendTelemetry } from '@utils/sendTelemetry'; -import axios from 'axios'; -import { proto } from 'baileys'; -import dayjs from 'dayjs'; -import FormData from 'form-data'; -import Jimp from 'jimp'; -import Long from 'long'; -import mimeTypes from 'mime-types'; -import path from 'path'; -import { Readable } from 'stream'; + import { InstanceDto } from '@api/dto/instance.dto'; + import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; + import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; + import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client'; + import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper'; + import { PrismaRepository } from '@api/repository/repository.service'; + import { CacheService } from '@api/services/cache.service'; + import { WAMonitoringService } from '@api/services/monitor.service'; + import { Events } from '@api/types/wa.types'; + import { Chatwoot, ConfigService, Database, HttpServer } from '@config/env.config'; + import { Logger } from '@config/logger.config'; + import ChatwootClient, { + ChatwootAPIConfig, + contact, + contact_inboxes, + conversation, + conversation_show, + generic_id, + inbox, + } from '@figuro/chatwoot-sdk'; + import { request as chatwootRequest } from '@figuro/chatwoot-sdk/dist/core/request'; + import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageModel } from '@prisma/client'; + import i18next from '@utils/i18n'; + import { sendTelemetry } from '@utils/sendTelemetry'; + import axios from 'axios'; + import { proto } from 'baileys'; + import dayjs from 'dayjs'; + import FormData from 'form-data'; + import Jimp from 'jimp'; + import Long from 'long'; + import mimeTypes from 'mime-types'; + import path from 'path'; + import { Readable } from 'stream'; -interface ChatwootMessage { - messageId?: number; - inboxId?: number; - conversationId?: number; - contactInboxSourceId?: string; - isRead?: boolean; -} + interface ChatwootMessage { + messageId?: number; + inboxId?: number; + conversationId?: number; + contactInboxSourceId?: string; + isRead?: boolean; + } -export class ChatwootService { - private readonly logger = new Logger('ChatwootService'); + export class ChatwootService { + private readonly logger = new Logger('ChatwootService'); - private provider: any; + private provider: any; - constructor( - private readonly waMonitor: WAMonitoringService, - private readonly configService: ConfigService, - private readonly prismaRepository: PrismaRepository, - private readonly cache: CacheService, - ) {} + constructor( + private readonly waMonitor: WAMonitoringService, + private readonly configService: ConfigService, + private readonly prismaRepository: PrismaRepository, + private readonly cache: CacheService, + ) {} - private pgClient = postgresClient.getChatwootConnection(); + private pgClient = postgresClient.getChatwootConnection(); - private async getProvider(instance: InstanceDto): Promise { - const cacheKey = `${instance.instanceName}:getProvider`; - if (await this.cache.has(cacheKey)) { - const provider = (await this.cache.get(cacheKey)) as ChatwootModel; + private async getProvider(instance: InstanceDto): Promise { + const cacheKey = `${instance.instanceName}:getProvider`; + if (await this.cache.has(cacheKey)) { + const provider = (await this.cache.get(cacheKey)) as ChatwootModel; + + return provider; + } + + const provider = await this.waMonitor.waInstances[instance.instanceName]?.findChatwoot(); + + if (!provider) { + this.logger.warn('provider not found'); + return null; + } + + this.cache.set(cacheKey, provider); return provider; } - const provider = await this.waMonitor.waInstances[instance.instanceName]?.findChatwoot(); + private async clientCw(instance: InstanceDto) { + const provider = await this.getProvider(instance); - if (!provider) { - this.logger.warn('provider not found'); - return null; - } - - this.cache.set(cacheKey, provider); - - return provider; - } - - private async clientCw(instance: InstanceDto) { - const provider = await this.getProvider(instance); - - if (!provider) { - this.logger.error('provider not found'); - return null; - } - - this.provider = provider; - - const client = new ChatwootClient({ - config: this.getClientCwConfig(), - }); - - return client; - } - - public getClientCwConfig(): ChatwootAPIConfig & { nameInbox: string; mergeBrazilContacts: boolean } { - return { - basePath: this.provider.url, - with_credentials: true, - credentials: 'include', - token: this.provider.token, - nameInbox: this.provider.nameInbox, - mergeBrazilContacts: this.provider.mergeBrazilContacts, - }; - } - - public getCache() { - return this.cache; - } - - public async create(instance: InstanceDto, data: ChatwootDto) { - await this.waMonitor.waInstances[instance.instanceName].setChatwoot(data); - - if (data.autoCreate) { - this.logger.log('Auto create chatwoot instance'); - const urlServer = this.configService.get('SERVER').URL; - - await this.initInstanceChatwoot( - instance, - data.nameInbox ?? instance.instanceName.split('-cwId-')[0], - `${urlServer}/chatwoot/webhook/${encodeURIComponent(instance.instanceName)}`, - true, - data.number, - data.organization, - data.logo, - ); - } - return data; - } - - public async find(instance: InstanceDto): Promise { - try { - return await this.waMonitor.waInstances[instance.instanceName].findChatwoot(); - } catch (error) { - this.logger.error('chatwoot not found'); - return { enabled: null, url: '' }; - } - } - - public async getContact(instance: InstanceDto, id: number) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - if (!id) { - this.logger.warn('id is required'); - return null; - } - - const contact = await client.contact.getContactable({ - accountId: this.provider.accountId, - id, - }); - - if (!contact) { - this.logger.warn('contact not found'); - return null; - } - - return contact; - } - - public async initInstanceChatwoot( - instance: InstanceDto, - inboxName: string, - webhookUrl: string, - qrcode: boolean, - number: string, - organization?: string, - logo?: string, - ) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - const findInbox: any = await client.inboxes.list({ - accountId: this.provider.accountId, - }); - - const checkDuplicate = findInbox.payload.map((inbox) => inbox.name).includes(inboxName); - - let inboxId: number; - - this.logger.log('Creating chatwoot inbox'); - if (!checkDuplicate) { - const data = { - type: 'api', - webhook_url: webhookUrl, - }; - - const inbox = await client.inboxes.create({ - accountId: this.provider.accountId, - data: { - name: inboxName, - channel: data as any, - }, - }); - - if (!inbox) { - this.logger.warn('inbox not found'); + if (!provider) { + this.logger.error('provider not found'); return null; } - inboxId = inbox.id; - } else { - const inbox = findInbox.payload.find((inbox) => inbox.name === inboxName); + this.provider = provider; - if (!inbox) { - this.logger.warn('inbox not found'); - return null; - } - - inboxId = inbox.id; - } - this.logger.log(`Inbox created - inboxId: ${inboxId}`); - - if (!this.configService.get('CHATWOOT').BOT_CONTACT) { - this.logger.log('Chatwoot bot contact is disabled'); - - return true; - } - - this.logger.log('Creating chatwoot bot contact'); - const contact = - (await this.findContact(instance, '123456')) || - ((await this.createContact( - instance, - '123456', - inboxId, - false, - organization ? organization : 'EvolutionAPI', - logo ? logo : 'https://evolution-api.com/files/evolution-api-favicon.png', - )) as any); - - if (!contact) { - this.logger.warn('contact not found'); - return null; - } - - const contactId = contact.id || contact.payload.contact.id; - this.logger.log(`Contact created - contactId: ${contactId}`); - - if (qrcode) { - this.logger.log('QR code enabled'); - const data = { - contact_id: contactId.toString(), - inbox_id: inboxId.toString(), - }; - - const conversation = await client.conversations.create({ - accountId: this.provider.accountId, - data, + const client = new ChatwootClient({ + config: this.getClientCwConfig(), }); - if (!conversation) { - this.logger.warn('conversation not found'); - return null; - } - - let contentMsg = 'init'; - - if (number) { - contentMsg = `init:${number}`; - } - - const message = await client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation.id, - data: { - content: contentMsg, - message_type: 'outgoing', - }, - }); - - if (!message) { - this.logger.warn('conversation not found'); - return null; - } - this.logger.log('Init message sent'); + return client; } - return true; - } - - public async createContact( - instance: InstanceDto, - phoneNumber: string, - inboxId: number, - isGroup: boolean, - name?: string, - avatar_url?: string, - jid?: string, - ) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - let data: any = {}; - if (!isGroup) { - data = { - inbox_id: inboxId, - name: name || phoneNumber, - identifier: jid, - avatar_url: avatar_url, - }; - - if ((jid && jid.includes('@')) || !jid) { - data['phone_number'] = `+${phoneNumber}`; - } - } else { - data = { - inbox_id: inboxId, - name: name || phoneNumber, - identifier: phoneNumber, - avatar_url: avatar_url, + public getClientCwConfig(): ChatwootAPIConfig & { nameInbox: string; mergeBrazilContacts: boolean } { + return { + basePath: this.provider.url, + with_credentials: true, + credentials: 'include', + token: this.provider.token, + nameInbox: this.provider.nameInbox, + mergeBrazilContacts: this.provider.mergeBrazilContacts, }; } - const contact = await client.contacts.create({ - accountId: this.provider.accountId, - data, - }); - - if (!contact) { - this.logger.warn('contact not found'); - return null; + public getCache() { + return this.cache; } - const findContact = await this.findContact(instance, phoneNumber); + public async create(instance: InstanceDto, data: ChatwootDto) { + await this.waMonitor.waInstances[instance.instanceName].setChatwoot(data); - const contactId = findContact?.id; + if (data.autoCreate) { + this.logger.log('Auto create chatwoot instance'); + const urlServer = this.configService.get('SERVER').URL; - await this.addLabelToContact(this.provider.nameInbox, contactId); - - return contact; - } - - public async updateContact(instance: InstanceDto, id: number, data: any) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - if (!id) { - this.logger.warn('id is required'); - return null; - } - - try { - const contact = await client.contacts.update({ - accountId: this.provider.accountId, - id, - data, - }); - - return contact; - } catch (error) { - return null; - } - } - - public async addLabelToContact(nameInbox: string, contactId: number) { - try { - const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; - - if (!uri) return false; - - const sqlTags = `SELECT id, taggings_count FROM tags WHERE name = $1 LIMIT 1`; - const tagData = (await this.pgClient.query(sqlTags, [nameInbox]))?.rows[0]; - let tagId = tagData?.id; - const taggingsCount = tagData?.taggings_count || 0; - - const sqlTag = `INSERT INTO tags (name, taggings_count) - VALUES ($1, $2) - ON CONFLICT (name) - DO UPDATE SET taggings_count = tags.taggings_count + 1 - RETURNING id`; - - tagId = (await this.pgClient.query(sqlTag, [nameInbox, taggingsCount + 1]))?.rows[0]?.id; - - const sqlCheckTagging = `SELECT 1 FROM taggings - WHERE tag_id = $1 AND taggable_type = 'Contact' AND taggable_id = $2 AND context = 'labels' LIMIT 1`; - - const taggingExists = (await this.pgClient.query(sqlCheckTagging, [tagId, contactId]))?.rowCount > 0; - - if (!taggingExists) { - const sqlInsertLabel = `INSERT INTO taggings (tag_id, taggable_type, taggable_id, context, created_at) - VALUES ($1, 'Contact', $2, 'labels', NOW())`; - - await this.pgClient.query(sqlInsertLabel, [tagId, contactId]); + await this.initInstanceChatwoot( + instance, + data.nameInbox ?? instance.instanceName.split('-cwId-')[0], + `${urlServer}/chatwoot/webhook/${encodeURIComponent(instance.instanceName)}`, + true, + data.number, + data.organization, + data.logo, + ); } - - return true; - } catch (error) { - return false; - } - } - - public async findContact(instance: InstanceDto, phoneNumber: string) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; + return data; } - let query: any; - const isGroup = phoneNumber.includes('@g.us'); - - if (!isGroup) { - query = `+${phoneNumber}`; - } else { - query = phoneNumber; - } - - let contact: any; - - if (isGroup) { - contact = await client.contacts.search({ - accountId: this.provider.accountId, - q: query, - }); - } else { - contact = await chatwootRequest(this.getClientCwConfig(), { - method: 'POST', - url: `/api/v1/accounts/${this.provider.accountId}/contacts/filter`, - body: { - payload: this.getFilterPayload(query), - }, - }); - } - - if (!contact && contact?.payload?.length === 0) { - this.logger.warn('contact not found'); - return null; - } - - if (!isGroup) { - return contact.payload.length > 1 ? this.findContactInContactList(contact.payload, query) : contact.payload[0]; - } else { - return contact.payload.find((contact) => contact.identifier === query); - } - } - - private async mergeBrazilianContacts(contacts: any[]) { - try { - const contact = await chatwootRequest(this.getClientCwConfig(), { - method: 'POST', - url: `/api/v1/accounts/${this.provider.accountId}/actions/contact_merge`, - body: { - base_contact_id: contacts.find((contact) => contact.phone_number.length === 14)?.id, - mergee_contact_id: contacts.find((contact) => contact.phone_number.length === 13)?.id, - }, - }); - - return contact; - } catch { - this.logger.error('Error merging contacts'); - return null; - } - } - - private findContactInContactList(contacts: any[], query: string) { - const phoneNumbers = this.getNumbers(query); - const searchableFields = this.getSearchableFields(); - - // eslint-disable-next-line prettier/prettier - if (contacts.length === 2 && this.getClientCwConfig().mergeBrazilContacts && query.startsWith('+55')) { - const contact = this.mergeBrazilianContacts(contacts); - if (contact) { - return contact; + public async find(instance: InstanceDto): Promise { + try { + return await this.waMonitor.waInstances[instance.instanceName].findChatwoot(); + } catch (error) { + this.logger.error('chatwoot not found'); + return { enabled: null, url: '' }; } } - const phone = phoneNumbers.reduce( - (savedNumber, number) => (number.length > savedNumber.length ? number : savedNumber), - '', - ); - - const contact_with9 = contacts.find((contact) => contact.phone_number === phone); - if (contact_with9) { - return contact_with9; - } - - for (const contact of contacts) { - for (const field of searchableFields) { - if (contact[field] && phoneNumbers.includes(contact[field])) { - return contact; - } - } - } - - return null; - } - - private getNumbers(query: string) { - const numbers = []; - numbers.push(query); - - if (query.startsWith('+55') && query.length === 14) { - const withoutNine = query.slice(0, 5) + query.slice(6); - numbers.push(withoutNine); - } else if (query.startsWith('+55') && query.length === 13) { - const withNine = query.slice(0, 5) + '9' + query.slice(5); - numbers.push(withNine); - } - - return numbers; - } - - private getSearchableFields() { - return ['phone_number']; - } - - private getFilterPayload(query: string) { - const filterPayload = []; - - const numbers = this.getNumbers(query); - const fieldsToSearch = this.getSearchableFields(); - - fieldsToSearch.forEach((field, index1) => { - numbers.forEach((number, index2) => { - const queryOperator = fieldsToSearch.length - 1 === index1 && numbers.length - 1 === index2 ? null : 'OR'; - filterPayload.push({ - attribute_key: field, - filter_operator: 'equal_to', - values: [number.replace('+', '')], - query_operator: queryOperator, - }); - }); - }); - - return filterPayload; - } - - public async createConversation(instance: InstanceDto, body: any) { - try { - this.logger.verbose('--- Start createConversation ---'); - this.logger.verbose(`Instance: ${JSON.stringify(instance)}`); - + public async getContact(instance: InstanceDto, id: number) { const client = await this.clientCw(instance); if (!client) { - this.logger.warn(`Client not found for instance: ${JSON.stringify(instance)}`); + this.logger.warn('client not found'); return null; } - const cacheKey = `${instance.instanceName}:createConversation-${body.key.remoteJid}`; - this.logger.verbose(`Cache key: ${cacheKey}`); - - if (await this.cache.has(cacheKey)) { - this.logger.verbose(`Cache hit for key: ${cacheKey}`); - const conversationId = (await this.cache.get(cacheKey)) as number; - this.logger.verbose(`Cached conversation ID: ${conversationId}`); - let conversationExists: conversation | boolean; - try { - conversationExists = await client.conversations.get({ - accountId: this.provider.accountId, - conversationId: conversationId, - }); - this.logger.verbose(`Conversation exists: ${JSON.stringify(conversationExists)}`); - } catch (error) { - this.logger.error(`Error getting conversation: ${error}`); - conversationExists = false; - } - if (!conversationExists) { - this.logger.verbose('Conversation does not exist, re-calling createConversation'); - 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}`); - - const chatId = isGroup ? body.key.remoteJid : body.key.remoteJid.split('@')[0]; - this.logger.verbose(`Chat ID: ${chatId}`); - - let nameContact: string; - - nameContact = !body.key.fromMe ? body.pushName : chatId; - this.logger.verbose(`Name contact: ${nameContact}`); - - const filterInbox = await this.getInbox(instance); - - if (!filterInbox) { - this.logger.warn(`Inbox not found for instance: ${JSON.stringify(instance)}`); + if (!id) { + this.logger.warn('id is required'); return null; } - if (isGroup) { - this.logger.verbose('Processing group conversation'); - const group = await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId); - this.logger.verbose(`Group metadata: ${JSON.stringify(group)}`); - - nameContact = `${group.subject} (GROUP)`; - - const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture( - body.key.participant.split('@')[0], - ); - this.logger.verbose(`Participant profile picture URL: ${JSON.stringify(picture_url)}`); - - const findParticipant = await this.findContact(instance, body.key.participant.split('@')[0]); - this.logger.verbose(`Found participant: ${JSON.stringify(findParticipant)}`); - - if (findParticipant) { - if (!findParticipant.name || findParticipant.name === chatId) { - await this.updateContact(instance, findParticipant.id, { - name: body.pushName, - avatar_url: picture_url.profilePictureUrl || null, - }); - } - } else { - await this.createContact( - instance, - body.key.participant.split('@')[0], - filterInbox.id, - false, - body.pushName, - picture_url.profilePictureUrl || null, - body.key.participant, - ); - } - } - - const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId); - this.logger.verbose(`Contact profile picture URL: ${JSON.stringify(picture_url)}`); - - let contact = await this.findContact(instance, chatId); - this.logger.verbose(`Found contact: ${JSON.stringify(contact)}`); - - if (contact) { - if (!body.key.fromMe) { - const waProfilePictureFile = - picture_url?.profilePictureUrl?.split('#')[0].split('?')[0].split('/').pop() || ''; - const chatwootProfilePictureFile = contact?.thumbnail?.split('#')[0].split('?')[0].split('/').pop() || ''; - const pictureNeedsUpdate = waProfilePictureFile !== chatwootProfilePictureFile; - const nameNeedsUpdate = - !contact.name || - contact.name === chatId || - (`+${chatId}`.startsWith('+55') - ? this.getNumbers(`+${chatId}`).some( - (v) => contact.name === v || contact.name === v.substring(3) || contact.name === v.substring(1), - ) - : false); - - this.logger.verbose(`Picture needs update: ${pictureNeedsUpdate}`); - this.logger.verbose(`Name needs update: ${nameNeedsUpdate}`); - - if (pictureNeedsUpdate || nameNeedsUpdate) { - contact = await this.updateContact(instance, contact.id, { - ...(nameNeedsUpdate && { name: nameContact }), - ...(waProfilePictureFile === '' && { avatar: null }), - ...(pictureNeedsUpdate && { avatar_url: picture_url?.profilePictureUrl }), - }); - } - } - } else { - const jid = body.key.remoteJid; - contact = await this.createContact( - instance, - chatId, - filterInbox.id, - isGroup, - nameContact, - picture_url.profilePictureUrl || null, - jid, - ); - } + const contact = await client.contact.getContactable({ + accountId: this.provider.accountId, + id, + }); if (!contact) { - this.logger.warn('Contact not created or found'); + this.logger.warn('contact not found'); return null; } - const contactId = contact?.payload?.id || contact?.payload?.contact?.id || contact?.id; - this.logger.verbose(`Contact ID: ${contactId}`); + return contact; + } - const contactConversations = (await client.contacts.listConversations({ + public async initInstanceChatwoot( + instance: InstanceDto, + inboxName: string, + webhookUrl: string, + qrcode: boolean, + number: string, + organization?: string, + logo?: string, + ) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + const findInbox: any = await client.inboxes.list({ accountId: this.provider.accountId, - id: contactId, - })) as any; - this.logger.verbose(`Contact conversations: ${JSON.stringify(contactConversations)}`); + }); - if (!contactConversations || !contactConversations.payload) { - this.logger.error('No conversations found or payload is undefined'); + const checkDuplicate = findInbox.payload.map((inbox) => inbox.name).includes(inboxName); + + let inboxId: number; + + this.logger.log('Creating chatwoot inbox'); + if (!checkDuplicate) { + const data = { + type: 'api', + webhook_url: webhookUrl, + }; + + const inbox = await client.inboxes.create({ + accountId: this.provider.accountId, + data: { + name: inboxName, + channel: data as any, + }, + }); + + if (!inbox) { + this.logger.warn('inbox not found'); + return null; + } + + inboxId = inbox.id; + } else { + const inbox = findInbox.payload.find((inbox) => inbox.name === inboxName); + + if (!inbox) { + this.logger.warn('inbox not found'); + return null; + } + + inboxId = inbox.id; + } + this.logger.log(`Inbox created - inboxId: ${inboxId}`); + + if (!this.configService.get('CHATWOOT').BOT_CONTACT) { + this.logger.log('Chatwoot bot contact is disabled'); + + return true; + } + + this.logger.log('Creating chatwoot bot contact'); + const contact = + (await this.findContact(instance, '123456')) || + ((await this.createContact( + instance, + '123456', + inboxId, + false, + organization ? organization : 'EvolutionAPI', + logo ? logo : 'https://evolution-api.com/files/evolution-api-favicon.png', + )) as any); + + if (!contact) { + this.logger.warn('contact not found'); return null; } - if (contactConversations.payload.length) { - let conversation: any; - if (this.provider.reopenConversation) { - conversation = contactConversations.payload.find((conversation) => conversation.inbox_id == filterInbox.id); - this.logger.verbose(`Found conversation in reopenConversation mode: ${JSON.stringify(conversation)}`); + const contactId = contact.id || contact.payload.contact.id; + this.logger.log(`Contact created - contactId: ${contactId}`); - if (this.provider.conversationPending && conversation.status !== 'open') { - if (conversation) { - await client.conversations.toggleStatus({ - accountId: this.provider.accountId, - conversationId: conversation.id, - data: { - status: 'pending', - }, + if (qrcode) { + this.logger.log('QR code enabled'); + const data = { + contact_id: contactId.toString(), + inbox_id: inboxId.toString(), + }; + + const conversation = await client.conversations.create({ + accountId: this.provider.accountId, + data, + }); + + if (!conversation) { + this.logger.warn('conversation not found'); + return null; + } + + let contentMsg = 'init'; + + if (number) { + contentMsg = `init:${number}`; + } + + const message = await client.messages.create({ + accountId: this.provider.accountId, + conversationId: conversation.id, + data: { + content: contentMsg, + message_type: 'outgoing', + }, + }); + + if (!message) { + this.logger.warn('conversation not found'); + return null; + } + this.logger.log('Init message sent'); + } + + return true; + } + + public async createContact( + instance: InstanceDto, + phoneNumber: string, + inboxId: number, + isGroup: boolean, + name?: string, + avatar_url?: string, + jid?: string, + ) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + let data: any = {}; + if (!isGroup) { + data = { + inbox_id: inboxId, + name: name || phoneNumber, + identifier: jid, + avatar_url: avatar_url, + }; + + if ((jid && jid.includes('@')) || !jid) { + data['phone_number'] = `+${phoneNumber}`; + } + } else { + data = { + inbox_id: inboxId, + name: name || phoneNumber, + identifier: phoneNumber, + avatar_url: avatar_url, + }; + } + + const contact = await client.contacts.create({ + accountId: this.provider.accountId, + data, + }); + + if (!contact) { + this.logger.warn('contact not found'); + return null; + } + + const findContact = await this.findContact(instance, phoneNumber); + + const contactId = findContact?.id; + + await this.addLabelToContact(this.provider.nameInbox, contactId); + + return contact; + } + + public async updateContact(instance: InstanceDto, id: number, data: any) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + if (!id) { + this.logger.warn('id is required'); + return null; + } + + try { + const contact = await client.contacts.update({ + accountId: this.provider.accountId, + id, + data, + }); + + return contact; + } catch (error) { + return null; + } + } + + public async addLabelToContact(nameInbox: string, contactId: number) { + try { + const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; + + if (!uri) return false; + + const sqlTags = `SELECT id, taggings_count FROM tags WHERE name = $1 LIMIT 1`; + const tagData = (await this.pgClient.query(sqlTags, [nameInbox]))?.rows[0]; + let tagId = tagData?.id; + const taggingsCount = tagData?.taggings_count || 0; + + const sqlTag = `INSERT INTO tags (name, taggings_count) + VALUES ($1, $2) + ON CONFLICT (name) + DO UPDATE SET taggings_count = tags.taggings_count + 1 + RETURNING id`; + + tagId = (await this.pgClient.query(sqlTag, [nameInbox, taggingsCount + 1]))?.rows[0]?.id; + + const sqlCheckTagging = `SELECT 1 FROM taggings + WHERE tag_id = $1 AND taggable_type = 'Contact' AND taggable_id = $2 AND context = 'labels' LIMIT 1`; + + const taggingExists = (await this.pgClient.query(sqlCheckTagging, [tagId, contactId]))?.rowCount > 0; + + if (!taggingExists) { + const sqlInsertLabel = `INSERT INTO taggings (tag_id, taggable_type, taggable_id, context, created_at) + VALUES ($1, 'Contact', $2, 'labels', NOW())`; + + await this.pgClient.query(sqlInsertLabel, [tagId, contactId]); + } + + return true; + } catch (error) { + return false; + } + } + + public async findContact(instance: InstanceDto, phoneNumber: string) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + let query: any; + const isGroup = phoneNumber.includes('@g.us'); + + if (!isGroup) { + query = `+${phoneNumber}`; + } else { + query = phoneNumber; + } + + let contact: any; + + if (isGroup) { + contact = await client.contacts.search({ + accountId: this.provider.accountId, + q: query, + }); + } else { + contact = await chatwootRequest(this.getClientCwConfig(), { + method: 'POST', + url: `/api/v1/accounts/${this.provider.accountId}/contacts/filter`, + body: { + payload: this.getFilterPayload(query), + }, + }); + } + + if (!contact && contact?.payload?.length === 0) { + this.logger.warn('contact not found'); + return null; + } + + if (!isGroup) { + return contact.payload.length > 1 ? this.findContactInContactList(contact.payload, query) : contact.payload[0]; + } else { + return contact.payload.find((contact) => contact.identifier === query); + } + } + + private async mergeBrazilianContacts(contacts: any[]) { + try { + const contact = await chatwootRequest(this.getClientCwConfig(), { + method: 'POST', + url: `/api/v1/accounts/${this.provider.accountId}/actions/contact_merge`, + body: { + base_contact_id: contacts.find((contact) => contact.phone_number.length === 14)?.id, + mergee_contact_id: contacts.find((contact) => contact.phone_number.length === 13)?.id, + }, + }); + + return contact; + } catch { + this.logger.error('Error merging contacts'); + return null; + } + } + + private findContactInContactList(contacts: any[], query: string) { + const phoneNumbers = this.getNumbers(query); + const searchableFields = this.getSearchableFields(); + + // eslint-disable-next-line prettier/prettier + if (contacts.length === 2 && this.getClientCwConfig().mergeBrazilContacts && query.startsWith('+55')) { + const contact = this.mergeBrazilianContacts(contacts); + if (contact) { + return contact; + } + } + + const phone = phoneNumbers.reduce( + (savedNumber, number) => (number.length > savedNumber.length ? number : savedNumber), + '', + ); + + const contact_with9 = contacts.find((contact) => contact.phone_number === phone); + if (contact_with9) { + return contact_with9; + } + + for (const contact of contacts) { + for (const field of searchableFields) { + if (contact[field] && phoneNumbers.includes(contact[field])) { + return contact; + } + } + } + + return null; + } + + private getNumbers(query: string) { + const numbers = []; + numbers.push(query); + + if (query.startsWith('+55') && query.length === 14) { + const withoutNine = query.slice(0, 5) + query.slice(6); + numbers.push(withoutNine); + } else if (query.startsWith('+55') && query.length === 13) { + const withNine = query.slice(0, 5) + '9' + query.slice(5); + numbers.push(withNine); + } + + return numbers; + } + + private getSearchableFields() { + return ['phone_number']; + } + + private getFilterPayload(query: string) { + const filterPayload = []; + + const numbers = this.getNumbers(query); + const fieldsToSearch = this.getSearchableFields(); + + fieldsToSearch.forEach((field, index1) => { + numbers.forEach((number, index2) => { + const queryOperator = fieldsToSearch.length - 1 === index1 && numbers.length - 1 === index2 ? null : 'OR'; + filterPayload.push({ + attribute_key: field, + filter_operator: 'equal_to', + values: [number.replace('+', '')], + query_operator: queryOperator, + }); + }); + }); + + return filterPayload; + } + + public async createConversation(instance: InstanceDto, body: any) { + try { + this.logger.verbose('--- Start createConversation ---'); + this.logger.verbose(`Instance: ${JSON.stringify(instance)}`); + + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn(`Client not found for instance: ${JSON.stringify(instance)}`); + return null; + } + + const cacheKey = `${instance.instanceName}:createConversation-${body.key.remoteJid}`; + this.logger.verbose(`Cache key: ${cacheKey}`); + + if (await this.cache.has(cacheKey)) { + this.logger.verbose(`Cache hit for key: ${cacheKey}`); + const conversationId = (await this.cache.get(cacheKey)) as number; + this.logger.verbose(`Cached conversation ID: ${conversationId}`); + let conversationExists: conversation | boolean; + try { + conversationExists = await client.conversations.get({ + accountId: this.provider.accountId, + conversationId: conversationId, + }); + this.logger.verbose(`Conversation exists: ${JSON.stringify(conversationExists)}`); + } catch (error) { + this.logger.error(`Error getting conversation: ${error}`); + conversationExists = false; + } + if (!conversationExists) { + this.logger.verbose('Conversation does not exist, re-calling createConversation'); + 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}`); + + const chatId = isGroup ? body.key.remoteJid : body.key.remoteJid.split('@')[0]; + this.logger.verbose(`Chat ID: ${chatId}`); + + let nameContact: string; + + nameContact = !body.key.fromMe ? body.pushName : chatId; + this.logger.verbose(`Name contact: ${nameContact}`); + + const filterInbox = await this.getInbox(instance); + + if (!filterInbox) { + this.logger.warn(`Inbox not found for instance: ${JSON.stringify(instance)}`); + return null; + } + + if (isGroup) { + this.logger.verbose('Processing group conversation'); + const group = await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId); + this.logger.verbose(`Group metadata: ${JSON.stringify(group)}`); + + nameContact = `${group.subject} (GROUP)`; + + const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture( + body.key.participant.split('@')[0], + ); + this.logger.verbose(`Participant profile picture URL: ${JSON.stringify(picture_url)}`); + + const findParticipant = await this.findContact(instance, body.key.participant.split('@')[0]); + this.logger.verbose(`Found participant: ${JSON.stringify(findParticipant)}`); + + if (findParticipant) { + if (!findParticipant.name || findParticipant.name === chatId) { + await this.updateContact(instance, findParticipant.id, { + name: body.pushName, + avatar_url: picture_url.profilePictureUrl || null, + }); + } + } else { + await this.createContact( + instance, + body.key.participant.split('@')[0], + filterInbox.id, + false, + body.pushName, + picture_url.profilePictureUrl || null, + body.key.participant, + ); + } + } + + const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId); + this.logger.verbose(`Contact profile picture URL: ${JSON.stringify(picture_url)}`); + + let contact = await this.findContact(instance, chatId); + this.logger.verbose(`Found contact: ${JSON.stringify(contact)}`); + + if (contact) { + if (!body.key.fromMe) { + const waProfilePictureFile = + picture_url?.profilePictureUrl?.split('#')[0].split('?')[0].split('/').pop() || ''; + const chatwootProfilePictureFile = contact?.thumbnail?.split('#')[0].split('?')[0].split('/').pop() || ''; + const pictureNeedsUpdate = waProfilePictureFile !== chatwootProfilePictureFile; + const nameNeedsUpdate = + !contact.name || + contact.name === chatId || + (`+${chatId}`.startsWith('+55') + ? this.getNumbers(`+${chatId}`).some( + (v) => contact.name === v || contact.name === v.substring(3) || contact.name === v.substring(1), + ) + : false); + + this.logger.verbose(`Picture needs update: ${pictureNeedsUpdate}`); + this.logger.verbose(`Name needs update: ${nameNeedsUpdate}`); + + if (pictureNeedsUpdate || nameNeedsUpdate) { + contact = await this.updateContact(instance, contact.id, { + ...(nameNeedsUpdate && { name: nameContact }), + ...(waProfilePictureFile === '' && { avatar: null }), + ...(pictureNeedsUpdate && { avatar_url: picture_url?.profilePictureUrl }), }); } } } else { - conversation = contactConversations.payload.find( - (conversation) => conversation.status !== 'resolved' && conversation.inbox_id == filterInbox.id, + const jid = body.key.remoteJid; + contact = await this.createContact( + instance, + chatId, + filterInbox.id, + isGroup, + nameContact, + picture_url.profilePictureUrl || null, + jid, ); - this.logger.verbose(`Found conversation: ${JSON.stringify(conversation)}`); } - if (conversation) { - this.logger.verbose(`Returning existing conversation ID: ${conversation.id}`); - this.cache.set(cacheKey, conversation.id); - return conversation.id; - } - } - - const data = { - contact_id: contactId.toString(), - inbox_id: filterInbox.id.toString(), - }; - - if (this.provider.conversationPending) { - data['status'] = 'pending'; - } - - const conversation = await client.conversations.create({ - accountId: this.provider.accountId, - data, - }); - - if (!conversation) { - this.logger.warn('Conversation not created or found'); - return null; - } - - this.logger.verbose(`New conversation created with ID: ${conversation.id}`); - this.cache.set(cacheKey, conversation.id); - return conversation.id; - } catch (error) { - this.logger.error(`Error in createConversation: ${error}`); - } - } - - public async getInbox(instance: InstanceDto): Promise { - 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) { - this.logger.warn('client not found'); - return null; - } - - const inbox = (await client.inboxes.list({ - accountId: this.provider.accountId, - })) as any; - - if (!inbox) { - this.logger.warn('inbox not found'); - return null; - } - - const findByName = inbox.payload.find((inbox) => inbox.name === this.getClientCwConfig().nameInbox); - - if (!findByName) { - this.logger.warn('inbox not found'); - return null; - } - - this.cache.set(cacheKey, findByName); - return findByName; - } - - public async createMessage( - instance: InstanceDto, - conversationId: number, - content: string, - messageType: 'incoming' | 'outgoing' | undefined, - privateMessage?: boolean, - attachments?: { - content: unknown; - encoding: string; - filename: string; - }[], - messageBody?: any, - sourceId?: string, - quotedMsg?: MessageModel, - ) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - const replyToIds = await this.getReplyToIds(messageBody, instance); - - const sourceReplyId = quotedMsg?.chatwootMessageId || null; - - const message = await client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversationId, - data: { - content: content, - message_type: messageType, - attachments: attachments, - private: privateMessage || false, - source_id: sourceId, - content_attributes: { - ...replyToIds, - }, - source_reply_id: sourceReplyId ? sourceReplyId.toString() : null, - }, - }); - - if (!message) { - this.logger.warn('message not found'); - return null; - } - - return message; - } - - public async getOpenConversationByContact( - instance: InstanceDto, - inbox: inbox, - contact: generic_id & contact, - ): Promise { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - const conversations = (await client.contacts.listConversations({ - accountId: this.provider.accountId, - id: contact.id, - })) as any; - - return ( - conversations.payload.find( - (conversation) => conversation.inbox_id === inbox.id && conversation.status === 'open', - ) || undefined - ); - } - - public async createBotMessage( - instance: InstanceDto, - content: string, - messageType: 'incoming' | 'outgoing' | undefined, - attachments?: { - content: unknown; - encoding: string; - filename: string; - }[], - ) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - const contact = await this.findContact(instance, '123456'); - - if (!contact) { - this.logger.warn('contact not found'); - return null; - } - - const filterInbox = await this.getInbox(instance); - - if (!filterInbox) { - this.logger.warn('inbox not found'); - return null; - } - - const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); - - if (!conversation) { - this.logger.warn('conversation not found'); - return; - } - - const message = await client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation.id, - data: { - content: content, - message_type: messageType, - attachments: attachments, - }, - }); - - if (!message) { - this.logger.warn('message not found'); - return null; - } - - return message; - } - - private async sendData( - conversationId: number, - fileStream: Readable, - fileName: string, - messageType: 'incoming' | 'outgoing' | undefined, - content?: string, - instance?: InstanceDto, - messageBody?: any, - sourceId?: string, - quotedMsg?: MessageModel, - ) { - if (sourceId && this.isImportHistoryAvailable()) { - const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); - if (messageAlreadySaved) { - if (messageAlreadySaved.size > 0) { - this.logger.warn('Message already saved on chatwoot'); + if (!contact) { + this.logger.warn('Contact not created or found'); return null; } - } - } - const data = new FormData(); - if (content) { - data.append('content', content); - } + const contactId = contact?.payload?.id || contact?.payload?.contact?.id || contact?.id; + this.logger.verbose(`Contact ID: ${contactId}`); - data.append('message_type', messageType); + const contactConversations = (await client.contacts.listConversations({ + accountId: this.provider.accountId, + id: contactId, + })) as any; + this.logger.verbose(`Contact conversations: ${JSON.stringify(contactConversations)}`); - data.append('attachments[]', fileStream, { filename: fileName }); + if (!contactConversations || !contactConversations.payload) { + this.logger.error('No conversations found or payload is undefined'); + return null; + } - const sourceReplyId = quotedMsg?.chatwootMessageId || null; + if (contactConversations.payload.length) { + let conversation: any; + if (this.provider.reopenConversation) { + conversation = contactConversations.payload.find((conversation) => conversation.inbox_id == filterInbox.id); + this.logger.verbose(`Found conversation in reopenConversation mode: ${JSON.stringify(conversation)}`); - if (messageBody && instance) { - const replyToIds = await this.getReplyToIds(messageBody, instance); + if (conversation && this.provider.conversationPending && conversation.status !== 'open') { - if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) { - const content = JSON.stringify({ - ...replyToIds, - }); - data.append('content_attributes', content); - } - } + if (conversation) { + await client.conversations.toggleStatus({ + accountId: this.provider.accountId, + conversationId: conversation.id, + data: { + status: 'pending', + }, + }); + } + } + else if (!conversation) { + this.logger.warn('Conversation not found, creating a new one'); + this.cache.delete(cacheKey); + return await this.createConversation(instance, body); + } + + } else { + conversation = contactConversations.payload.find( + (conversation) => conversation.status !== 'resolved' && conversation.inbox_id == filterInbox.id, + ); + this.logger.verbose(`Found conversation: ${JSON.stringify(conversation)}`); + } - if (sourceReplyId) { - data.append('source_reply_id', sourceReplyId.toString()); - } + if (conversation) { + this.logger.verbose(`Returning existing conversation ID: ${conversation.id}`); + this.cache.set(cacheKey, conversation.id); + return conversation.id; + } + } - if (sourceId) { - data.append('source_id', sourceId); - } - - const config = { - method: 'post', - maxBodyLength: Infinity, - url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversationId}/messages`, - headers: { - api_access_token: this.provider.token, - ...data.getHeaders(), - }, - data: data, - }; - - try { - const { data } = await axios.request(config); - - return data; - } catch (error) { - this.logger.error(error); - } - } - - public async createBotQr( - instance: InstanceDto, - content: string, - messageType: 'incoming' | 'outgoing' | undefined, - fileStream?: Readable, - fileName?: string, - ) { - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - if (!this.configService.get('CHATWOOT').BOT_CONTACT) { - this.logger.log('Chatwoot bot contact is disabled'); - - return true; - } - - const contact = await this.findContact(instance, '123456'); - - if (!contact) { - this.logger.warn('contact not found'); - return null; - } - - const filterInbox = await this.getInbox(instance); - - if (!filterInbox) { - this.logger.warn('inbox not found'); - return null; - } - - const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); - - if (!conversation) { - this.logger.warn('conversation not found'); - return; - } - - const data = new FormData(); - - if (content) { - data.append('content', content); - } - - data.append('message_type', messageType); - - if (fileStream && fileName) { - data.append('attachments[]', fileStream, { filename: fileName }); - } - - const config = { - method: 'post', - maxBodyLength: Infinity, - url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversation.id}/messages`, - headers: { - api_access_token: this.provider.token, - ...data.getHeaders(), - }, - data: data, - }; - - try { - const { data } = await axios.request(config); - - return data; - } catch (error) { - this.logger.error(error); - } - } - - public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) { - try { - const parsedMedia = path.parse(decodeURIComponent(media)); - let mimeType = mimeTypes.lookup(parsedMedia?.ext) || ''; - let fileName = parsedMedia?.name + parsedMedia?.ext; - - if (!mimeType) { - const parts = media.split('/'); - fileName = decodeURIComponent(parts[parts.length - 1]); - - const response = await axios.get(media, { - responseType: 'arraybuffer', - }); - mimeType = response.headers['content-type']; - } - - let type = 'document'; - - switch (mimeType.split('/')[0]) { - case 'image': - type = 'image'; - break; - case 'video': - type = 'video'; - break; - case 'audio': - type = 'audio'; - break; - default: - type = 'document'; - break; - } - - if (type === 'audio') { - const data: SendAudioDto = { - number: number, - audio: media, - delay: 1200, - quoted: options?.quoted, + const data = { + contact_id: contactId.toString(), + inbox_id: filterInbox.id.toString(), }; - sendTelemetry('/message/sendWhatsAppAudio'); + if (this.provider.conversationPending) { + data['status'] = 'pending'; + } - const messageSent = await waInstance?.audioWhatsapp(data, true); + const conversation = await client.conversations.create({ + accountId: this.provider.accountId, + data, + }); - return messageSent; + if (!conversation) { + this.logger.warn('Conversation not created or found'); + return null; + } + + this.logger.verbose(`New conversation created with ID: ${conversation.id}`); + this.cache.set(cacheKey, conversation.id); + return conversation.id; + } catch (error) { + this.logger.error(`Error in createConversation: ${error}`); } - - if (type === 'image' && parsedMedia && parsedMedia?.ext === '.gif') { - type = 'document'; - } - - const data: SendMediaDto = { - number: number, - mediatype: type as any, - fileName: fileName, - media: media, - delay: 1200, - quoted: options?.quoted, - }; - - sendTelemetry('/message/sendMedia'); - - if (caption) { - data.caption = caption; - } - - const messageSent = await waInstance?.mediaMessage(data, null, true); - - return messageSent; - } catch (error) { - this.logger.error(error); - } - } - - public async onSendMessageError(instance: InstanceDto, conversation: number, error?: any) { - this.logger.verbose(`onSendMessageError ${JSON.stringify(error)}`); - - const client = await this.clientCw(instance); - - if (!client) { - return; } - if (error && error?.status === 400 && error?.message[0]?.exists === false) { - client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation, - data: { - content: `${i18next.t('cw.message.numbernotinwhatsapp')}`, - message_type: 'outgoing', - private: true, - }, - }); - - return; - } - - client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation, - data: { - content: i18next.t('cw.message.notsent', { - error: error ? `_${error.toString()}_` : '', - }), - message_type: 'outgoing', - private: true, - }, - }); - } - - public async receiveWebhook(instance: InstanceDto, body: any) { - try { - await new Promise((resolve) => setTimeout(resolve, 500)); + public async getInbox(instance: InstanceDto): Promise { + 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); @@ -1185,553 +776,946 @@ export class ChatwootService { return null; } - if ( - this.provider.reopenConversation === 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); + const inbox = (await client.inboxes.list({ + accountId: this.provider.accountId, + })) as any; + + if (!inbox) { + this.logger.warn('inbox not found'); + return null; } - if ( - !body?.conversation || - body.private || - (body.event === 'message_updated' && !body.content_attributes?.deleted) - ) { - return { message: 'bot' }; + const findByName = inbox.payload.find((inbox) => inbox.name === this.getClientCwConfig().nameInbox); + + if (!findByName) { + this.logger.warn('inbox not found'); + return null; } - const chatId = - body.conversation.meta.sender?.identifier || body.conversation.meta.sender?.phone_number.replace('+', ''); - // Chatwoot to Whatsapp - const messageReceived = body.content - ? body.content - .replaceAll(/(? 0) { + this.logger.warn('Message already saved on chatwoot'); + return null; + } + } + + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + const replyToIds = await this.getReplyToIds(messageBody, instance); + + const sourceReplyId = quotedMsg?.chatwootMessageId || null; + + const message = await client.messages.create({ + accountId: this.provider.accountId, + conversationId: conversationId, + data: { + content: content, + message_type: messageType, + attachments: attachments, + private: privateMessage || false, + source_id: sourceId, + content_attributes: { + ...replyToIds, + }, + source_reply_id: sourceReplyId ? sourceReplyId.toString() : null, + }, + }); + + if (!message) { + this.logger.warn('message not found'); + return null; + } + + return message; + } + + public async getOpenConversationByContact( + instance: InstanceDto, + inbox: inbox, + contact: generic_id & contact, + ): Promise { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + const conversations = (await client.contacts.listConversations({ + accountId: this.provider.accountId, + id: contact.id, + })) as any; + + return ( + conversations.payload.find( + (conversation) => conversation.inbox_id === inbox.id && conversation.status === 'open', + ) || undefined + ); + } + + public async createBotMessage( + instance: InstanceDto, + content: string, + messageType: 'incoming' | 'outgoing' | undefined, + attachments?: { + content: unknown; + encoding: string; + filename: string; + }[], + ) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + const contact = await this.findContact(instance, '123456'); + + if (!contact) { + this.logger.warn('contact not found'); + return null; + } + + const filterInbox = await this.getInbox(instance); + + if (!filterInbox) { + this.logger.warn('inbox not found'); + return null; + } + + const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); + + if (!conversation) { + this.logger.warn('conversation not found'); + return; + } + + const message = await client.messages.create({ + accountId: this.provider.accountId, + conversationId: conversation.id, + data: { + content: content, + message_type: messageType, + attachments: attachments, + }, + }); + + if (!message) { + this.logger.warn('message not found'); + return null; + } + + return message; + } + + private async sendData( + conversationId: number, + fileStream: Readable, + fileName: string, + messageType: 'incoming' | 'outgoing' | undefined, + content?: string, + instance?: InstanceDto, + messageBody?: any, + sourceId?: string, + quotedMsg?: MessageModel, + ) { + if (sourceId && this.isImportHistoryAvailable()) { + const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); + if (messageAlreadySaved) { + if (messageAlreadySaved.size > 0) { + this.logger.warn('Message already saved on chatwoot'); + return null; + } + } + } + const data = new FormData(); + + if (content) { + data.append('content', content); + } + + data.append('message_type', messageType); + + data.append('attachments[]', fileStream, { filename: fileName }); + + const sourceReplyId = quotedMsg?.chatwootMessageId || null; + + if (messageBody && instance) { + const replyToIds = await this.getReplyToIds(messageBody, instance); + + if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) { + const content = JSON.stringify({ + ...replyToIds, + }); + data.append('content_attributes', content); + } + } + + if (sourceReplyId) { + data.append('source_reply_id', sourceReplyId.toString()); + } + + if (sourceId) { + data.append('source_id', sourceId); + } + + const config = { + method: 'post', + maxBodyLength: Infinity, + url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversationId}/messages`, + headers: { + api_access_token: this.provider.token, + ...data.getHeaders(), + }, + data: data, + }; + + try { + const { data } = await axios.request(config); + + return data; + } catch (error) { + this.logger.error(error); + } + } + + public async createBotQr( + instance: InstanceDto, + content: string, + messageType: 'incoming' | 'outgoing' | undefined, + fileStream?: Readable, + fileName?: string, + ) { + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + if (!this.configService.get('CHATWOOT').BOT_CONTACT) { + this.logger.log('Chatwoot bot contact is disabled'); + + return true; + } + + const contact = await this.findContact(instance, '123456'); + + if (!contact) { + this.logger.warn('contact not found'); + return null; + } + + const filterInbox = await this.getInbox(instance); + + if (!filterInbox) { + this.logger.warn('inbox not found'); + return null; + } + + const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); + + if (!conversation) { + this.logger.warn('conversation not found'); + return; + } + + const data = new FormData(); + + if (content) { + data.append('content', content); + } + + data.append('message_type', messageType); + + if (fileStream && fileName) { + data.append('attachments[]', fileStream, { filename: fileName }); + } + + const config = { + method: 'post', + maxBodyLength: Infinity, + url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversation.id}/messages`, + headers: { + api_access_token: this.provider.token, + ...data.getHeaders(), + }, + data: data, + }; + + try { + const { data } = await axios.request(config); + + return data; + } catch (error) { + this.logger.error(error); + } + } + + public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) { + try { + const parsedMedia = path.parse(decodeURIComponent(media)); + let mimeType = mimeTypes.lookup(parsedMedia?.ext) || ''; + let fileName = parsedMedia?.name + parsedMedia?.ext; + + if (!mimeType) { + const parts = media.split('/'); + fileName = decodeURIComponent(parts[parts.length - 1]); + + const response = await axios.get(media, { + responseType: 'arraybuffer', + }); + mimeType = response.headers['content-type']; + } + + let type = 'document'; + + switch (mimeType.split('/')[0]) { + case 'image': + type = 'image'; + break; + case 'video': + type = 'video'; + break; + case 'audio': + type = 'audio'; + break; + default: + type = 'document'; + break; + } + + if (type === 'audio') { + const data: SendAudioDto = { + number: number, + audio: media, + delay: 1200, + quoted: options?.quoted, + }; + + sendTelemetry('/message/sendWhatsAppAudio'); + + const messageSent = await waInstance?.audioWhatsapp(data, true); + + return messageSent; + } + + if (type === 'image' && parsedMedia && parsedMedia?.ext === '.gif') { + type = 'document'; + } + + const data: SendMediaDto = { + number: number, + mediatype: type as any, + fileName: fileName, + media: media, + delay: 1200, + quoted: options?.quoted, + }; + + sendTelemetry('/message/sendMedia'); + + if (caption) { + data.caption = caption; + } + + const messageSent = await waInstance?.mediaMessage(data, null, true); + + return messageSent; + } catch (error) { + this.logger.error(error); + } + } + + public async onSendMessageError(instance: InstanceDto, conversation: number, error?: any) { + this.logger.verbose(`onSendMessageError ${JSON.stringify(error)}`); + + const client = await this.clientCw(instance); + + if (!client) { + return; + } + + if (error && error?.status === 400 && error?.message[0]?.exists === false) { + client.messages.create({ + accountId: this.provider.accountId, + conversationId: conversation, + data: { + content: `${i18next.t('cw.message.numbernotinwhatsapp')}`, + message_type: 'outgoing', + private: true, }, }); - if (message) { - const key = message.key as { - id: string; - remoteJid: string; - fromMe: boolean; - participant: string; - }; + return; + } - await waInstance?.client.sendMessage(key.remoteJid, { delete: key }); + client.messages.create({ + accountId: this.provider.accountId, + conversationId: conversation, + data: { + content: i18next.t('cw.message.notsent', { + error: error ? `_${error.toString()}_` : '', + }), + message_type: 'outgoing', + private: true, + }, + }); + } - await this.prismaRepository.message.deleteMany({ + public async receiveWebhook(instance: InstanceDto, body: any) { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; + } + + if ( + this.provider.reopenConversation === 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); + } + + if ( + !body?.conversation || + body.private || + (body.event === 'message_updated' && !body.content_attributes?.deleted) + ) { + return { message: 'bot' }; + } + + const chatId = + body.conversation.meta.sender?.identifier || body.conversation.meta.sender?.phone_number.replace('+', ''); + // Chatwoot to Whatsapp + const messageReceived = body.content + ? body.content + .replaceAll(/(?('CHATWOOT').BOT_CONTACT; + const cwBotContact = this.configService.get('CHATWOOT').BOT_CONTACT; - if (chatId === '123456' && body.message_type === 'outgoing') { - const command = messageReceived.replace('/', ''); + if (chatId === '123456' && body.message_type === 'outgoing') { + const command = messageReceived.replace('/', ''); - if (cwBotContact && (command.includes('init') || command.includes('iniciar'))) { - const state = waInstance?.connectionStatus?.state; + if (cwBotContact && (command.includes('init') || command.includes('iniciar'))) { + const state = waInstance?.connectionStatus?.state; - if (state !== 'open') { - const number = command.split(':')[1]; - await waInstance.connectToWhatsapp(number); - } else { + if (state !== 'open') { + const number = command.split(':')[1]; + await waInstance.connectToWhatsapp(number); + } else { + await this.createBotMessage( + instance, + i18next.t('cw.inbox.alreadyConnected', { + inboxName: body.inbox.name, + }), + 'incoming', + ); + } + } + + if (command === 'clearcache') { + waInstance.clearCacheChatwoot(); await this.createBotMessage( instance, - i18next.t('cw.inbox.alreadyConnected', { + i18next.t('cw.inbox.clearCache', { inboxName: body.inbox.name, }), 'incoming', ); } - } - if (command === 'clearcache') { - waInstance.clearCacheChatwoot(); - await this.createBotMessage( - instance, - i18next.t('cw.inbox.clearCache', { + if (command === 'status') { + const state = waInstance?.connectionStatus?.state; + + if (!state) { + await this.createBotMessage( + instance, + i18next.t('cw.inbox.notFound', { + inboxName: body.inbox.name, + }), + 'incoming', + ); + } + + if (state) { + await this.createBotMessage( + instance, + i18next.t('cw.inbox.status', { + inboxName: body.inbox.name, + state: state, + }), + 'incoming', + ); + } + } + + if (cwBotContact && (command === 'disconnect' || command === 'desconectar')) { + const msgLogout = i18next.t('cw.inbox.disconnect', { inboxName: body.inbox.name, - }), - 'incoming', - ); - } + }); - if (command === 'status') { - const state = waInstance?.connectionStatus?.state; + await this.createBotMessage(instance, msgLogout, 'incoming'); - if (!state) { - await this.createBotMessage( - instance, - i18next.t('cw.inbox.notFound', { - inboxName: body.inbox.name, - }), - 'incoming', - ); - } - - if (state) { - await this.createBotMessage( - instance, - i18next.t('cw.inbox.status', { - inboxName: body.inbox.name, - state: state, - }), - 'incoming', - ); + await waInstance?.client?.logout('Log out instance: ' + instance.instanceName); + await waInstance?.client?.ws?.close(); } } - if (cwBotContact && (command === 'disconnect' || command === 'desconectar')) { - const msgLogout = i18next.t('cw.inbox.disconnect', { - inboxName: body.inbox.name, - }); + if (body.message_type === 'outgoing' && body?.conversation?.messages?.length && chatId !== '123456') { + if (body?.conversation?.messages[0]?.source_id?.substring(0, 5) === 'WAID:') { + return { message: 'bot' }; + } - await this.createBotMessage(instance, msgLogout, 'incoming'); + if (!waInstance && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); + return { message: 'bot' }; + } - await waInstance?.client?.logout('Log out instance: ' + instance.instanceName); - await waInstance?.client?.ws?.close(); - } - } + let formatText: string; + if (senderName === null || senderName === undefined) { + formatText = messageReceived; + } else { + const formattedDelimiter = this.provider.signDelimiter + ? this.provider.signDelimiter.replaceAll('\\n', '\n') + : '\n'; + const textToConcat = this.provider.signMsg ? [`*${senderName}:*`] : []; + textToConcat.push(messageReceived); - if (body.message_type === 'outgoing' && body?.conversation?.messages?.length && chatId !== '123456') { - if (body?.conversation?.messages[0]?.source_id?.substring(0, 5) === 'WAID:') { - return { message: 'bot' }; - } + formatText = textToConcat.join(formattedDelimiter); + } - if (!waInstance && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); - return { message: 'bot' }; - } + for (const message of body.conversation.messages) { + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + if (!messageReceived) { + formatText = null; + } - let formatText: string; - if (senderName === null || senderName === undefined) { - formatText = messageReceived; - } else { - const formattedDelimiter = this.provider.signDelimiter - ? this.provider.signDelimiter.replaceAll('\\n', '\n') - : '\n'; - const textToConcat = this.provider.signMsg ? [`*${senderName}:*`] : []; - textToConcat.push(messageReceived); + const options: Options = { + quoted: await this.getQuotedMessage(body, instance), + }; - formatText = textToConcat.join(formattedDelimiter); - } + const messageSent = await this.sendAttachment( + waInstance, + chatId, + attachment.data_url, + formatText, + options, + ); + if (!messageSent && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id); + } - for (const message of body.conversation.messages) { - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (!messageReceived) { - formatText = null; + await this.updateChatwootMessageId( + { + ...messageSent, + owner: instance.instanceName, + }, + { + messageId: body.id, + inboxId: body.inbox?.id, + conversationId: body.conversation?.id, + contactInboxSourceId: body.conversation?.contact_inbox?.source_id, + }, + instance, + ); } - - const options: Options = { + } else { + const data: SendTextDto = { + number: chatId, + text: formatText, + delay: 1200, quoted: await this.getQuotedMessage(body, instance), }; - const messageSent = await this.sendAttachment( - waInstance, - chatId, - attachment.data_url, - formatText, - options, - ); - if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id); + sendTelemetry('/message/sendText'); + + let messageSent: any; + try { + messageSent = await waInstance?.textMessage(data, true); + if (!messageSent) { + throw new Error('Message not sent'); + } + + if (Long.isLong(messageSent?.messageTimestamp)) { + messageSent.messageTimestamp = messageSent.messageTimestamp?.toNumber(); + } + + await this.updateChatwootMessageId( + { + ...messageSent, + instanceId: instance.instanceId, + }, + { + messageId: body.id, + inboxId: body.inbox?.id, + conversationId: body.conversation?.id, + contactInboxSourceId: body.conversation?.contact_inbox?.source_id, + }, + instance, + ); + } catch (error) { + if (!messageSent && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id, error); + } + throw error; } - - await this.updateChatwootMessageId( - { - ...messageSent, - owner: instance.instanceName, - }, - { - messageId: body.id, - inboxId: body.inbox?.id, - conversationId: body.conversation?.id, - contactInboxSourceId: body.conversation?.contact_inbox?.source_id, - }, - instance, - ); - } - } else { - const data: SendTextDto = { - number: chatId, - text: formatText, - delay: 1200, - quoted: await this.getQuotedMessage(body, instance), - }; - - sendTelemetry('/message/sendText'); - - let messageSent: any; - try { - messageSent = await waInstance?.textMessage(data, true); - if (!messageSent) { - throw new Error('Message not sent'); - } - - if (Long.isLong(messageSent?.messageTimestamp)) { - messageSent.messageTimestamp = messageSent.messageTimestamp?.toNumber(); - } - - await this.updateChatwootMessageId( - { - ...messageSent, - instanceId: instance.instanceId, - }, - { - messageId: body.id, - inboxId: body.inbox?.id, - conversationId: body.conversation?.id, - contactInboxSourceId: body.conversation?.contact_inbox?.source_id, - }, - instance, - ); - } catch (error) { - if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, error); - } - throw error; } } - } - const chatwootRead = this.configService.get('CHATWOOT').MESSAGE_READ; - if (chatwootRead) { - const lastMessage = await this.prismaRepository.message.findFirst({ - where: { - key: { - path: ['fromMe'], - equals: false, - }, - instanceId: instance.instanceId, - }, - }); - if (lastMessage && !lastMessage.chatwootIsRead) { - const key = lastMessage.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; - - waInstance?.markMessageAsRead({ - readMessages: [ - { - id: key.id, - fromMe: key.fromMe, - remoteJid: key.remoteJid, - }, - ], - }); - const updateMessage = { - chatwootMessageId: lastMessage.chatwootMessageId, - chatwootConversationId: lastMessage.chatwootConversationId, - chatwootInboxId: lastMessage.chatwootInboxId, - chatwootContactInboxSourceId: lastMessage.chatwootContactInboxSourceId, - chatwootIsRead: true, - }; - - await this.prismaRepository.message.updateMany({ + const chatwootRead = this.configService.get('CHATWOOT').MESSAGE_READ; + if (chatwootRead) { + const lastMessage = await this.prismaRepository.message.findFirst({ where: { - instanceId: instance.instanceId, key: { - path: ['id'], - equals: key.id, + path: ['fromMe'], + equals: false, }, + instanceId: instance.instanceId, }, - data: updateMessage, }); + if (lastMessage && !lastMessage.chatwootIsRead) { + const key = lastMessage.key as { + id: string; + fromMe: boolean; + remoteJid: string; + participant?: string; + }; + + waInstance?.markMessageAsRead({ + readMessages: [ + { + id: key.id, + fromMe: key.fromMe, + remoteJid: key.remoteJid, + }, + ], + }); + const updateMessage = { + chatwootMessageId: lastMessage.chatwootMessageId, + chatwootConversationId: lastMessage.chatwootConversationId, + chatwootInboxId: lastMessage.chatwootInboxId, + chatwootContactInboxSourceId: lastMessage.chatwootContactInboxSourceId, + chatwootIsRead: true, + }; + + await this.prismaRepository.message.updateMany({ + where: { + instanceId: instance.instanceId, + key: { + path: ['id'], + equals: key.id, + }, + }, + data: updateMessage, + }); + } } } - } - if (body.message_type === 'template' && body.event === 'message_created') { - const data: SendTextDto = { - number: chatId, - text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'), - delay: 1200, - }; + if (body.message_type === 'template' && body.event === 'message_created') { + const data: SendTextDto = { + number: chatId, + text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'), + delay: 1200, + }; - sendTelemetry('/message/sendText'); + sendTelemetry('/message/sendText'); - await waInstance?.textMessage(data); - } - - return { message: 'bot' }; - } catch (error) { - this.logger.error(error); - - return { message: 'bot' }; - } - } - - private async updateChatwootMessageId( - message: MessageModel, - chatwootMessageIds: ChatwootMessage, - instance: InstanceDto, - ) { - const key = message.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; - - if (!chatwootMessageIds.messageId || !key?.id) { - return; - } - - await this.prismaRepository.message.updateMany({ - where: { - key: { - path: ['id'], - equals: key.id, - }, - instanceId: instance.instanceId, - }, - data: { - chatwootMessageId: chatwootMessageIds.messageId, - chatwootConversationId: chatwootMessageIds.conversationId, - chatwootInboxId: chatwootMessageIds.inboxId, - chatwootContactInboxSourceId: chatwootMessageIds.contactInboxSourceId, - chatwootIsRead: chatwootMessageIds.isRead, - }, - }); - - if (this.isImportHistoryAvailable()) { - chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id); - } - } - - private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { - const messages = await this.prismaRepository.message.findFirst({ - where: { - key: { - path: ['id'], - equals: keyId, - }, - instanceId: instance.instanceId, - }, - }); - - return messages || null; - } - - private async getReplyToIds( - msg: any, - instance: InstanceDto, - ): Promise<{ in_reply_to: string; in_reply_to_external_id: string }> { - let inReplyTo = null; - let inReplyToExternalId = null; - - if (msg) { - inReplyToExternalId = msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? msg.contextInfo?.stanzaId; - if (inReplyToExternalId) { - const message = await this.getMessageByKeyId(instance, inReplyToExternalId); - if (message?.chatwootMessageId) { - inReplyTo = message.chatwootMessageId; + await waInstance?.textMessage(data); } + + return { message: 'bot' }; + } catch (error) { + this.logger.error(error); + + return { message: 'bot' }; } } - return { - in_reply_to: inReplyTo, - in_reply_to_external_id: inReplyToExternalId, - }; - } + private async updateChatwootMessageId( + message: MessageModel, + chatwootMessageIds: ChatwootMessage, + instance: InstanceDto, + ) { + const key = message.key as { + id: string; + fromMe: boolean; + remoteJid: string; + participant?: string; + }; - private async getQuotedMessage(msg: any, instance: InstanceDto): Promise { - if (msg?.content_attributes?.in_reply_to) { - const message = await this.prismaRepository.message.findFirst({ + if (!chatwootMessageIds.messageId || !key?.id) { + return; + } + + await this.prismaRepository.message.updateMany({ where: { - chatwootMessageId: msg?.content_attributes?.in_reply_to, + key: { + path: ['id'], + equals: key.id, + }, + instanceId: instance.instanceId, + }, + data: { + chatwootMessageId: chatwootMessageIds.messageId, + chatwootConversationId: chatwootMessageIds.conversationId, + chatwootInboxId: chatwootMessageIds.inboxId, + chatwootContactInboxSourceId: chatwootMessageIds.contactInboxSourceId, + chatwootIsRead: chatwootMessageIds.isRead, + }, + }); + + if (this.isImportHistoryAvailable()) { + chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id); + } + } + + private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { + const messages = await this.prismaRepository.message.findFirst({ + where: { + key: { + path: ['id'], + equals: keyId, + }, instanceId: instance.instanceId, }, }); - const key = message?.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; + return messages || null; + } - if (message && key?.id) { - return { - key: message.key as proto.IMessageKey, - message: message.message as proto.IMessage, - }; + private async getReplyToIds( + msg: any, + instance: InstanceDto, + ): Promise<{ in_reply_to: string; in_reply_to_external_id: string }> { + let inReplyTo = null; + let inReplyToExternalId = null; + + if (msg) { + inReplyToExternalId = msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? msg.contextInfo?.stanzaId; + if (inReplyToExternalId) { + const message = await this.getMessageByKeyId(instance, inReplyToExternalId); + if (message?.chatwootMessageId) { + inReplyTo = message.chatwootMessageId; + } + } } - } - return null; - } - - private isMediaMessage(message: any) { - const media = [ - 'imageMessage', - 'documentMessage', - 'documentWithCaptionMessage', - 'audioMessage', - 'videoMessage', - 'stickerMessage', - 'viewOnceMessageV2', - ]; - - const messageKeys = Object.keys(message); - - const result = messageKeys.some((key) => media.includes(key)); - - return result; - } - - private getAdsMessage(msg: any) { - interface AdsMessage { - title: string; - body: string; - thumbnailUrl: string; - sourceUrl: string; - } - - const adsMessage: AdsMessage | undefined = { - title: msg.extendedTextMessage?.contextInfo?.externalAdReply?.title || msg.contextInfo?.externalAdReply?.title, - body: msg.extendedTextMessage?.contextInfo?.externalAdReply?.body || msg.contextInfo?.externalAdReply?.body, - thumbnailUrl: - msg.extendedTextMessage?.contextInfo?.externalAdReply?.thumbnailUrl || - msg.contextInfo?.externalAdReply?.thumbnailUrl, - sourceUrl: - msg.extendedTextMessage?.contextInfo?.externalAdReply?.sourceUrl || msg.contextInfo?.externalAdReply?.sourceUrl, - }; - - return adsMessage; - } - - private getReactionMessage(msg: any) { - interface ReactionMessage { - key: { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; + return { + in_reply_to: inReplyTo, + in_reply_to_external_id: inReplyToExternalId, }; - text: string; - } - const reactionMessage: ReactionMessage | undefined = msg?.reactionMessage; - - return reactionMessage; - } - - private getTypeMessage(msg: any) { - const types = { - conversation: msg.conversation, - imageMessage: msg.imageMessage?.caption, - videoMessage: msg.videoMessage?.caption, - extendedTextMessage: msg.extendedTextMessage?.text, - messageContextInfo: msg.messageContextInfo?.stanzaId, - stickerMessage: undefined, - documentMessage: msg.documentMessage?.caption, - documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, - audioMessage: msg.audioMessage?.caption, - contactMessage: msg.contactMessage?.vcard, - contactsArrayMessage: msg.contactsArrayMessage, - locationMessage: msg.locationMessage, - liveLocationMessage: msg.liveLocationMessage, - listMessage: msg.listMessage, - listResponseMessage: msg.listResponseMessage, - viewOnceMessageV2: - msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, - }; - - return types; - } - - private getMessageContent(types: any) { - const typeKey = Object.keys(types).find((key) => types[key] !== undefined); - - let result = typeKey ? types[typeKey] : undefined; - - // Remove externalAdReplyBody| in Chatwoot (Already Have) - if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) { - result = result.split('externalAdReplyBody|').filter(Boolean).join(''); } - if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') { - const latitude = result.degreesLatitude; - const longitude = result.degreesLongitude; + private async getQuotedMessage(msg: any, instance: InstanceDto): Promise { + if (msg?.content_attributes?.in_reply_to) { + const message = await this.prismaRepository.message.findFirst({ + where: { + chatwootMessageId: msg?.content_attributes?.in_reply_to, + instanceId: instance.instanceId, + }, + }); - const locationName = result?.name; - const locationAddress = result?.address; + const key = message?.key as { + id: string; + fromMe: boolean; + remoteJid: string; + participant?: string; + }; - const formattedLocation = - `*${i18next.t('cw.locationMessage.location')}:*\n\n` + - `_${i18next.t('cw.locationMessage.latitude')}:_ ${latitude} \n` + - `_${i18next.t('cw.locationMessage.longitude')}:_ ${longitude} \n` + - (locationName ? `_${i18next.t('cw.locationMessage.locationName')}:_ ${locationName}\n` : '') + - (locationAddress ? `_${i18next.t('cw.locationMessage.locationAddress')}:_ ${locationAddress} \n` : '') + - `_${i18next.t('cw.locationMessage.locationUrl')}:_ ` + - `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`; - - return formattedLocation; - } - - if (typeKey === 'contactMessage') { - const vCardData = result.split('\n'); - const contactInfo = {}; - - vCardData.forEach((line) => { - const [key, value] = line.split(':'); - if (key && value) { - contactInfo[key] = value; + if (message && key?.id) { + return { + key: message.key as proto.IMessageKey, + message: message.message as proto.IMessage, + }; } - }); + } - let formattedContact = - `*${i18next.t('cw.contactMessage.contact')}:*\n\n` + - `_${i18next.t('cw.contactMessage.name')}:_ ${contactInfo['FN']}`; - - let numberCount = 1; - Object.keys(contactInfo).forEach((key) => { - if (key.startsWith('item') && key.includes('TEL')) { - const phoneNumber = contactInfo[key]; - formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; - numberCount++; - } else if (key.includes('TEL')) { - const phoneNumber = contactInfo[key]; - formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; - numberCount++; - } - }); - - return formattedContact; + return null; } - if (typeKey === 'contactsArrayMessage') { - const formattedContacts = result.contacts.map((contact) => { - const vCardData = contact.vcard.split('\n'); + private isMediaMessage(message: any) { + const media = [ + 'imageMessage', + 'documentMessage', + 'documentWithCaptionMessage', + 'audioMessage', + 'videoMessage', + 'stickerMessage', + 'viewOnceMessageV2', + ]; + + const messageKeys = Object.keys(message); + + const result = messageKeys.some((key) => media.includes(key)); + + return result; + } + + private getAdsMessage(msg: any) { + interface AdsMessage { + title: string; + body: string; + thumbnailUrl: string; + sourceUrl: string; + } + + const adsMessage: AdsMessage | undefined = { + title: msg.extendedTextMessage?.contextInfo?.externalAdReply?.title || msg.contextInfo?.externalAdReply?.title, + body: msg.extendedTextMessage?.contextInfo?.externalAdReply?.body || msg.contextInfo?.externalAdReply?.body, + thumbnailUrl: + msg.extendedTextMessage?.contextInfo?.externalAdReply?.thumbnailUrl || + msg.contextInfo?.externalAdReply?.thumbnailUrl, + sourceUrl: + msg.extendedTextMessage?.contextInfo?.externalAdReply?.sourceUrl || msg.contextInfo?.externalAdReply?.sourceUrl, + }; + + return adsMessage; + } + + private getReactionMessage(msg: any) { + interface ReactionMessage { + key: { + id: string; + fromMe: boolean; + remoteJid: string; + participant?: string; + }; + text: string; + } + const reactionMessage: ReactionMessage | undefined = msg?.reactionMessage; + + return reactionMessage; + } + + private getTypeMessage(msg: any) { + const types = { + conversation: msg.conversation, + imageMessage: msg.imageMessage?.caption, + videoMessage: msg.videoMessage?.caption, + extendedTextMessage: msg.extendedTextMessage?.text, + messageContextInfo: msg.messageContextInfo?.stanzaId, + stickerMessage: undefined, + documentMessage: msg.documentMessage?.caption, + documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, + audioMessage: msg.audioMessage?.caption, + contactMessage: msg.contactMessage?.vcard, + contactsArrayMessage: msg.contactsArrayMessage, + locationMessage: msg.locationMessage, + liveLocationMessage: msg.liveLocationMessage, + listMessage: msg.listMessage, + listResponseMessage: msg.listResponseMessage, + viewOnceMessageV2: + msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, + }; + + return types; + } + + private getMessageContent(types: any) { + const typeKey = Object.keys(types).find((key) => types[key] !== undefined); + + let result = typeKey ? types[typeKey] : undefined; + + // Remove externalAdReplyBody| in Chatwoot (Already Have) + if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) { + result = result.split('externalAdReplyBody|').filter(Boolean).join(''); + } + + if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') { + const latitude = result.degreesLatitude; + const longitude = result.degreesLongitude; + + const locationName = result?.name; + const locationAddress = result?.address; + + const formattedLocation = + `*${i18next.t('cw.locationMessage.location')}:*\n\n` + + `_${i18next.t('cw.locationMessage.latitude')}:_ ${latitude} \n` + + `_${i18next.t('cw.locationMessage.longitude')}:_ ${longitude} \n` + + (locationName ? `_${i18next.t('cw.locationMessage.locationName')}:_ ${locationName}\n` : '') + + (locationAddress ? `_${i18next.t('cw.locationMessage.locationAddress')}:_ ${locationAddress} \n` : '') + + `_${i18next.t('cw.locationMessage.locationUrl')}:_ ` + + `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`; + + return formattedLocation; + } + + if (typeKey === 'contactMessage') { + const vCardData = result.split('\n'); const contactInfo = {}; vCardData.forEach((line) => { @@ -1741,9 +1725,9 @@ export class ChatwootService { } }); - let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n_${i18next.t( - 'cw.contactMessage.name', - )}:_ ${contact.displayName}`; + let formattedContact = + `*${i18next.t('cw.contactMessage.contact')}:*\n\n` + + `_${i18next.t('cw.contactMessage.name')}:_ ${contactInfo['FN']}`; let numberCount = 1; Object.keys(contactInfo).forEach((key) => { @@ -1759,214 +1743,385 @@ export class ChatwootService { }); return formattedContact; - }); + } - const formattedContactsArray = formattedContacts.join('\n\n'); + if (typeKey === 'contactsArrayMessage') { + const formattedContacts = result.contacts.map((contact) => { + const vCardData = contact.vcard.split('\n'); + const contactInfo = {}; - return formattedContactsArray; - } + vCardData.forEach((line) => { + const [key, value] = line.split(':'); + if (key && value) { + contactInfo[key] = value; + } + }); - if (typeKey === 'listMessage') { - const listTitle = result?.title || 'Unknown'; - const listDescription = result?.description || 'Unknown'; - const listFooter = result?.footerText || 'Unknown'; + let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n_${i18next.t( + 'cw.contactMessage.name', + )}:_ ${contact.displayName}`; - let formattedList = - '*List Menu:*\n\n' + - '_Title_: ' + - listTitle + - '\n' + - '_Description_: ' + - listDescription + - '\n' + - '_Footer_: ' + - listFooter; + let numberCount = 1; + Object.keys(contactInfo).forEach((key) => { + if (key.startsWith('item') && key.includes('TEL')) { + const phoneNumber = contactInfo[key]; + formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; + numberCount++; + } else if (key.includes('TEL')) { + const phoneNumber = contactInfo[key]; + formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; + numberCount++; + } + }); - 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'; - } + return formattedContact; }); - } else { - formattedList += '\nNo sections found.\n'; + + const formattedContactsArray = formattedContacts.join('\n\n'); + + return formattedContactsArray; } - return formattedList; + 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; + } + + return result; } - if (typeKey === 'listResponseMessage') { - const responseTitle = result?.title || 'Unknown'; - const responseDescription = result?.description || 'Unknown'; - const responseRowId = result?.singleSelectReply?.selectedRowId || 'Unknown'; + public getConversationMessage(msg: any) { + const types = this.getTypeMessage(msg); - const formattedResponseList = - '*List Response:*\n\n' + - '_Title_: ' + - responseTitle + - '\n' + - '_Description_: ' + - responseDescription + - '\n' + - '_ID_: ' + - responseRowId; - return formattedResponseList; + const messageContent = this.getMessageContent(types); + + return messageContent; } - return result; - } + public async eventWhatsapp(event: string, instance: InstanceDto, body: any) { + try { + const waInstance = this.waMonitor.waInstances[instance.instanceName]; - public getConversationMessage(msg: any) { - const types = this.getTypeMessage(msg); - - const messageContent = this.getMessageContent(types); - - return messageContent; - } - - public async eventWhatsapp(event: string, instance: InstanceDto, body: any) { - try { - const waInstance = this.waMonitor.waInstances[instance.instanceName]; - - if (!waInstance) { - this.logger.warn('wa instance not found'); - return null; - } - - const client = await this.clientCw(instance); - - if (!client) { - this.logger.warn('client not found'); - return null; - } - - if (this.provider?.ignoreJids && this.provider?.ignoreJids.length > 0) { - const ignoreJids: any = this.provider?.ignoreJids; - - let ignoreGroups = false; - let ignoreContacts = false; - - if (ignoreJids.includes('@g.us')) { - ignoreGroups = true; + if (!waInstance) { + this.logger.warn('wa instance not found'); + return null; } - if (ignoreJids.includes('@s.whatsapp.net')) { - ignoreContacts = true; + const client = await this.clientCw(instance); + + if (!client) { + this.logger.warn('client not found'); + return null; } - if (ignoreGroups && body?.key?.remoteJid.endsWith('@g.us')) { - this.logger.warn('Ignoring message from group: ' + body?.key?.remoteJid); - return; + if (this.provider?.ignoreJids && this.provider?.ignoreJids.length > 0) { + const ignoreJids: any = this.provider?.ignoreJids; + + let ignoreGroups = false; + let ignoreContacts = false; + + if (ignoreJids.includes('@g.us')) { + ignoreGroups = true; + } + + if (ignoreJids.includes('@s.whatsapp.net')) { + ignoreContacts = true; + } + + if (ignoreGroups && body?.key?.remoteJid.endsWith('@g.us')) { + this.logger.warn('Ignoring message from group: ' + body?.key?.remoteJid); + return; + } + + if (ignoreContacts && body?.key?.remoteJid.endsWith('@s.whatsapp.net')) { + this.logger.warn('Ignoring message from contact: ' + body?.key?.remoteJid); + return; + } + + if (ignoreJids.includes(body?.key?.remoteJid)) { + this.logger.warn('Ignoring message from jid: ' + body?.key?.remoteJid); + return; + } } - if (ignoreContacts && body?.key?.remoteJid.endsWith('@s.whatsapp.net')) { - this.logger.warn('Ignoring message from contact: ' + body?.key?.remoteJid); - return; - } + if (event === 'messages.upsert' || event === 'send.message') { + if (body.key.remoteJid === 'status@broadcast') { + return; + } - if (ignoreJids.includes(body?.key?.remoteJid)) { - this.logger.warn('Ignoring message from jid: ' + body?.key?.remoteJid); - return; - } - } + if (body.message?.ephemeralMessage?.message) { + body.message = { + ...body.message?.ephemeralMessage?.message, + }; + } - if (event === 'messages.upsert' || event === 'send.message') { - if (body.key.remoteJid === 'status@broadcast') { - return; - } + const originalMessage = await this.getConversationMessage(body.message); + const bodyMessage = originalMessage + ? originalMessage + .replaceAll(/\*((?!\s)([^\n*]+?)(? {}; + fileStream.push(fileData); + fileStream.push(null); - const getConversation = await this.createConversation(instance, body); + if (body.key.remoteJid.includes('@g.us')) { + const participantName = body.pushName; + const rawPhoneNumber = body.key.participant.split('@')[0]; + const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); - if (!getConversation) { - this.logger.warn('conversation not found'); - return; - } + let formattedPhoneNumber: string; - const messageType = body.key.fromMe ? 'outgoing' : 'incoming'; + if (phoneMatch) { + formattedPhoneNumber = `+${phoneMatch[1]} (${phoneMatch[2]}) ${phoneMatch[3]}-${phoneMatch[4]}`; + } else { + formattedPhoneNumber = `+${rawPhoneNumber}`; + } - if (isMedia) { - const downloadBase64 = await waInstance?.getBase64FromMediaMessage({ - message: { - ...body, - }, - }); + let content: string; - let nameFile: string; - const messageBody = body?.message[body?.messageType]; - const originalFilename = - messageBody?.fileName || 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 (!body.key.fromMe) { + content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`; + } else { + content = `${bodyMessage}`; + } + + const send = await this.sendData( + getConversation, + fileStream, + nameFile, + messageType, + content, + instance, + body, + 'WAID:' + body.key.id, + quotedMsg, + ); + + if (!send) { + this.logger.warn('message not sent'); + return; + } + + return send; + } else { + const send = await this.sendData( + getConversation, + fileStream, + nameFile, + messageType, + bodyMessage, + instance, + body, + 'WAID:' + body.key.id, + quotedMsg, + ); + + if (!send) { + this.logger.warn('message not sent'); + return; + } + + return send; } } - if (!nameFile) { - nameFile = `${Math.random().toString(36).substring(7)}.${mimeTypes.extension(downloadBase64.mimetype) || ''}`; + if (reactionMessage) { + if (reactionMessage.text) { + const send = await this.createMessage( + instance, + getConversation, + reactionMessage.text, + messageType, + false, + [], + { + message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, + }, + 'WAID:' + body.key.id, + quotedMsg, + ); + if (!send) { + this.logger.warn('message not sent'); + return; + } + } + + return; } - const fileData = Buffer.from(downloadBase64.base64, 'base64'); + const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl; + if (isAdsMessage) { + const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' }); - const fileStream = new Readable(); - fileStream._read = () => {}; - fileStream.push(fileData); - fileStream.push(null); + const extension = mimeTypes.extension(imgBuffer.headers['content-type']); + const mimeType = extension && mimeTypes.lookup(extension); + + if (!mimeType) { + this.logger.warn('mimetype of Ads message not found'); + return; + } + + const random = Math.random().toString(36).substring(7); + const nameFile = `${random}.${mimeTypes.extension(mimeType)}`; + const fileData = Buffer.from(imgBuffer.data, 'binary'); + + const img = await Jimp.read(fileData); + await img.cover(320, 180); + + const processedBuffer = await img.getBufferAsync(Jimp.MIME_PNG); + + const fileStream = new Readable(); + fileStream._read = () => {}; // _read is required but you can noop it + fileStream.push(processedBuffer); + fileStream.push(null); + + const truncStr = (str: string, len: number) => { + if (!str) return ''; + + return str.length > len ? str.substring(0, len) + '...' : str; + }; + + const title = truncStr(adsMessage.title, 40); + const description = truncStr(adsMessage?.body, 75); + + const send = await this.sendData( + getConversation, + fileStream, + nameFile, + messageType, + `${bodyMessage}\n\n\n**${title}**\n${description}\n${adsMessage.sourceUrl}`, + instance, + body, + 'WAID:' + body.key.id, + ); + + if (!send) { + this.logger.warn('message not sent'); + return; + } + + return send; + } if (body.key.remoteJid.includes('@g.us')) { const participantName = body.pushName; @@ -1989,13 +2144,13 @@ export class ChatwootService { content = `${bodyMessage}`; } - const send = await this.sendData( - getConversation, - fileStream, - nameFile, - messageType, - content, + const send = await this.createMessage( instance, + getConversation, + content, + messageType, + false, + [], body, 'WAID:' + body.key.id, quotedMsg, @@ -2008,13 +2163,13 @@ export class ChatwootService { return send; } else { - const send = await this.sendData( - getConversation, - fileStream, - nameFile, - messageType, - bodyMessage, + const send = await this.createMessage( instance, + getConversation, + bodyMessage, + messageType, + false, + [], body, 'WAID:' + body.key.id, quotedMsg, @@ -2029,495 +2184,356 @@ export class ChatwootService { } } - if (reactionMessage) { - if (reactionMessage.text) { + if (event === Events.MESSAGES_DELETE) { + const chatwootDelete = this.configService.get('CHATWOOT').MESSAGE_DELETE; + + if (chatwootDelete === true) { + if (!body?.key?.id) { + this.logger.warn('message id not found'); + return; + } + + const message = await this.getMessageByKeyId(instance, body.key.id); + + if (message?.chatwootMessageId && message?.chatwootConversationId) { + await this.prismaRepository.message.deleteMany({ + where: { + key: { + path: ['id'], + equals: body.key.id, + }, + instanceId: instance.instanceId, + }, + }); + + return await client.messages.delete({ + accountId: this.provider.accountId, + conversationId: message.chatwootConversationId, + messageId: message.chatwootMessageId, + }); + } + } + } + + if (event === 'messages.edit') { + const editedText = `${ + body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text + }\n\n_\`${i18next.t('cw.message.edited')}.\`_`; + const message = await this.getMessageByKeyId(instance, body?.key?.id); + const key = message.key as { + id: string; + fromMe: boolean; + remoteJid: string; + participant?: string; + }; + + const messageType = key?.fromMe ? 'outgoing' : 'incoming'; + + if (message && message.chatwootConversationId) { const send = await this.createMessage( instance, - getConversation, - reactionMessage.text, + message.chatwootConversationId, + editedText, messageType, false, [], { - message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, + message: { extendedTextMessage: { contextInfo: { stanzaId: key.id } } }, }, 'WAID:' + body.key.id, - quotedMsg, + null, ); if (!send) { - this.logger.warn('message not sent'); + this.logger.warn('edited message not sent'); return; } } - return; } - const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl; - if (isAdsMessage) { - const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' }); - - const extension = mimeTypes.extension(imgBuffer.headers['content-type']); - const mimeType = extension && mimeTypes.lookup(extension); - - if (!mimeType) { - this.logger.warn('mimetype of Ads message not found'); - return; - } - - const random = Math.random().toString(36).substring(7); - const nameFile = `${random}.${mimeTypes.extension(mimeType)}`; - const fileData = Buffer.from(imgBuffer.data, 'binary'); - - const img = await Jimp.read(fileData); - await img.cover(320, 180); - - const processedBuffer = await img.getBufferAsync(Jimp.MIME_PNG); - - const fileStream = new Readable(); - fileStream._read = () => {}; // _read is required but you can noop it - fileStream.push(processedBuffer); - fileStream.push(null); - - const truncStr = (str: string, len: number) => { - if (!str) return ''; - - return str.length > len ? str.substring(0, len) + '...' : str; - }; - - const title = truncStr(adsMessage.title, 40); - const description = truncStr(adsMessage?.body, 75); - - const send = await this.sendData( - getConversation, - fileStream, - nameFile, - messageType, - `${bodyMessage}\n\n\n**${title}**\n${description}\n${adsMessage.sourceUrl}`, - instance, - body, - 'WAID:' + body.key.id, - ); - - if (!send) { - this.logger.warn('message not sent'); - return; - } - - return send; - } - - if (body.key.remoteJid.includes('@g.us')) { - const participantName = body.pushName; - const rawPhoneNumber = body.key.participant.split('@')[0]; - const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); - - let formattedPhoneNumber: string; - - if (phoneMatch) { - formattedPhoneNumber = `+${phoneMatch[1]} (${phoneMatch[2]}) ${phoneMatch[3]}-${phoneMatch[4]}`; - } else { - formattedPhoneNumber = `+${rawPhoneNumber}`; - } - - let content: string; - - if (!body.key.fromMe) { - content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`; - } else { - content = `${bodyMessage}`; - } - - const send = await this.createMessage( - instance, - getConversation, - content, - messageType, - false, - [], - body, - 'WAID:' + body.key.id, - quotedMsg, - ); - - if (!send) { - this.logger.warn('message not sent'); - return; - } - - return send; - } else { - const send = await this.createMessage( - instance, - getConversation, - bodyMessage, - messageType, - false, - [], - body, - 'WAID:' + body.key.id, - quotedMsg, - ); - - if (!send) { - this.logger.warn('message not sent'); - return; - } - - return send; - } - } - - if (event === Events.MESSAGES_DELETE) { - const chatwootDelete = this.configService.get('CHATWOOT').MESSAGE_DELETE; - - if (chatwootDelete === true) { - if (!body?.key?.id) { + if (event === 'messages.read') { + 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 = message?.chatwootConversationId; + const contactInboxSourceId = message?.chatwootContactInboxSourceId; - if (message?.chatwootMessageId && message?.chatwootConversationId) { - await this.prismaRepository.message.deleteMany({ - where: { - key: { - path: ['id'], - equals: body.key.id, - }, - instanceId: instance.instanceId, - }, - }); + if (conversationId) { + let sourceId = contactInboxSourceId; + const inbox = (await this.getInbox(instance)) as inbox & { + inbox_identifier?: string; + }; - return await client.messages.delete({ - accountId: this.provider.accountId, - conversationId: message.chatwootConversationId, - messageId: message.chatwootMessageId, - }); + if (!sourceId && inbox) { + const conversation = (await client.conversations.get({ + accountId: this.provider.accountId, + conversationId: conversationId, + })) as conversation_show & { + last_non_activity_message: { conversation: { contact_inbox: contact_inboxes } }; + }; + sourceId = conversation.last_non_activity_message?.conversation?.contact_inbox?.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 === 'messages.edit') { - const editedText = `${ - body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text - }\n\n_\`${i18next.t('cw.message.edited')}.\`_`; - const message = await this.getMessageByKeyId(instance, body?.key?.id); - const key = message.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; + if (event === 'status.instance') { + const data = body; + const inbox = await this.getInbox(instance); - const messageType = key?.fromMe ? 'outgoing' : 'incoming'; - - if (message && message.chatwootConversationId) { - const send = await this.createMessage( - instance, - message.chatwootConversationId, - editedText, - messageType, - false, - [], - { - message: { extendedTextMessage: { contextInfo: { stanzaId: key.id } } }, - }, - 'WAID:' + body.key.id, - null, - ); - if (!send) { - this.logger.warn('edited message not sent'); + if (!inbox) { + this.logger.warn('inbox not found'); return; } + + const msgStatus = i18next.t('cw.inbox.status', { + inboxName: inbox.name, + state: data.status, + }); + + await this.createBotMessage(instance, msgStatus, 'incoming'); } + + if (event === 'connection.update') { + if (body.status === 'open') { + // if we have qrcode count then we understand that a new connection was established + if (this.waMonitor.waInstances[instance.instanceName].qrCode.count > 0) { + const msgConnection = i18next.t('cw.inbox.connected'); + await this.createBotMessage(instance, msgConnection, 'incoming'); + this.waMonitor.waInstances[instance.instanceName].qrCode.count = 0; + chatwootImport.clearAll(instance); + } + } + } + + if (event === 'qrcode.updated') { + if (body.statusCode === 500) { + const erroQRcode = `🚨 ${i18next.t('qrlimitreached')}`; + return await this.createBotMessage(instance, erroQRcode, 'incoming'); + } else { + const fileData = Buffer.from(body?.qrcode.base64.replace('data:image/png;base64,', ''), 'base64'); + + const fileStream = new Readable(); + fileStream._read = () => {}; + fileStream.push(fileData); + fileStream.push(null); + + await this.createBotQr( + instance, + i18next.t('qrgeneratedsuccesfully'), + 'incoming', + fileStream, + `${instance.instanceName}.png`, + ); + + let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`; + + if (body?.qrcode?.pairingCode) { + msgQrCode = + msgQrCode + + `\n\n*Pairing Code:* ${body.qrcode.pairingCode.substring(0, 4)}-${body.qrcode.pairingCode.substring( + 4, + 8, + )}`; + } + + await this.createBotMessage(instance, msgQrCode, 'incoming'); + } + } + } catch (error) { + this.logger.error(error); + } + } + + public getNumberFromRemoteJid(remoteJid: string) { + return remoteJid.replace(/:\d+/, '').split('@')[0]; + } + + public startImportHistoryMessages(instance: InstanceDto) { + if (!this.isImportHistoryAvailable()) { return; } - if (event === 'messages.read') { - if (!body?.key?.id || !body?.key?.remoteJid) { - this.logger.warn('message id not found'); + this.createBotMessage(instance, i18next.t('cw.import.startImport'), 'incoming'); + } + + public isImportHistoryAvailable() { + const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; + + return uri && uri !== 'postgres://user:password@hostname:port/dbname'; + } + + public addHistoryMessages(instance: InstanceDto, messagesRaw: MessageModel[]) { + if (!this.isImportHistoryAvailable()) { + return; + } + + chatwootImport.addHistoryMessages(instance, messagesRaw); + } + + public addHistoryContacts(instance: InstanceDto, contactsRaw: ContactModel[]) { + if (!this.isImportHistoryAvailable()) { + return; + } + + return chatwootImport.addHistoryContacts(instance, contactsRaw); + } + + public async importHistoryMessages(instance: InstanceDto) { + if (!this.isImportHistoryAvailable()) { + return; + } + + this.createBotMessage(instance, i18next.t('cw.import.importingMessages'), 'incoming'); + + const totalMessagesImported = await chatwootImport.importHistoryMessages( + instance, + this, + await this.getInbox(instance), + this.provider, + ); + this.updateContactAvatarInRecentConversations(instance); + + const msg = Number.isInteger(totalMessagesImported) + ? i18next.t('cw.import.messagesImported', { totalMessagesImported }) + : i18next.t('cw.import.messagesException'); + + this.createBotMessage(instance, msg, 'incoming'); + + return totalMessagesImported; + } + + public async updateContactAvatarInRecentConversations(instance: InstanceDto, limitContacts = 100) { + try { + if (!this.isImportHistoryAvailable()) { return; } - const message = await this.getMessageByKeyId(instance, body.key.id); - const conversationId = message?.chatwootConversationId; - const contactInboxSourceId = message?.chatwootContactInboxSourceId; - - if (conversationId) { - let sourceId = contactInboxSourceId; - const inbox = (await this.getInbox(instance)) as inbox & { - inbox_identifier?: string; - }; - - if (!sourceId && inbox) { - const conversation = (await client.conversations.get({ - accountId: this.provider.accountId, - conversationId: conversationId, - })) as conversation_show & { - last_non_activity_message: { conversation: { contact_inbox: contact_inboxes } }; - }; - sourceId = conversation.last_non_activity_message?.conversation?.contact_inbox?.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, - }); - } + const client = await this.clientCw(instance); + if (!client) { + this.logger.warn('client not found'); + return null; } - return; - } - if (event === 'status.instance') { - const data = body; const inbox = await this.getInbox(instance); - if (!inbox) { this.logger.warn('inbox not found'); + return null; + } + + const recentContacts = await chatwootImport.getContactsOrderByRecentConversations( + inbox, + this.provider, + limitContacts, + ); + + const contactIdentifiers = recentContacts + .map((contact) => contact.identifier) + .filter((identifier) => identifier !== null); + + const contactsWithProfilePicture = ( + await this.prismaRepository.contact.findMany({ + where: { + instanceId: instance.instanceId, + id: { + in: contactIdentifiers, + }, + profilePicUrl: { + not: null, + }, + }, + }) + ).reduce((acc: Map, contact: ContactModel) => acc.set(contact.id, contact), new Map()); + + recentContacts.forEach(async (contact) => { + if (contactsWithProfilePicture.has(contact.identifier)) { + client.contacts.update({ + accountId: this.provider.accountId, + id: contact.id, + data: { + avatar_url: contactsWithProfilePicture.get(contact.identifier).profilePictureUrl || null, + }, + }); + } + }); + } catch (error) { + this.logger.error(`Error on update avatar in recent conversations: ${error.toString()}`); + } + } + + public async syncLostMessages( + instance: InstanceDto, + chatwootConfig: ChatwootDto, + prepareMessage: (message: any) => any, + ) { + try { + if (!this.isImportHistoryAvailable()) { + return; + } + if (!this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) { return; } - const msgStatus = i18next.t('cw.inbox.status', { - inboxName: inbox.name, - state: data.status, + const inbox = await this.getInbox(instance); + + const sqlMessages = `select * from messages m + where account_id = ${chatwootConfig.accountId} + and inbox_id = ${inbox.id} + and created_at >= now() - interval '6h' + order by created_at desc`; + + const messagesData = (await this.pgClient.query(sqlMessages))?.rows; + const ids: string[] = messagesData + .filter((message) => !!message.source_id) + .map((message) => message.source_id.replace('WAID:', '')); + + const savedMessages = await this.prismaRepository.message.findMany({ + where: { + Instance: { name: instance.instanceName }, + messageTimestamp: { gte: dayjs().subtract(6, 'hours').unix() }, + AND: ids.map((id) => ({ key: { path: ['id'], not: id } })), + }, }); - await this.createBotMessage(instance, msgStatus, 'incoming'); - } - - if (event === 'connection.update') { - if (body.status === 'open') { - // if we have qrcode count then we understand that a new connection was established - if (this.waMonitor.waInstances[instance.instanceName].qrCode.count > 0) { - const msgConnection = i18next.t('cw.inbox.connected'); - await this.createBotMessage(instance, msgConnection, 'incoming'); - this.waMonitor.waInstances[instance.instanceName].qrCode.count = 0; - chatwootImport.clearAll(instance); - } - } - } - - if (event === 'qrcode.updated') { - if (body.statusCode === 500) { - const erroQRcode = `🚨 ${i18next.t('qrlimitreached')}`; - return await this.createBotMessage(instance, erroQRcode, 'incoming'); - } else { - const fileData = Buffer.from(body?.qrcode.base64.replace('data:image/png;base64,', ''), 'base64'); - - const fileStream = new Readable(); - fileStream._read = () => {}; - fileStream.push(fileData); - fileStream.push(null); - - await this.createBotQr( - instance, - i18next.t('qrgeneratedsuccesfully'), - 'incoming', - fileStream, - `${instance.instanceName}.png`, - ); - - let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`; - - if (body?.qrcode?.pairingCode) { - msgQrCode = - msgQrCode + - `\n\n*Pairing Code:* ${body.qrcode.pairingCode.substring(0, 4)}-${body.qrcode.pairingCode.substring( - 4, - 8, - )}`; + const filteredMessages = savedMessages.filter( + (msg: any) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid), + ); + const messagesRaw: any[] = []; + for (const m of filteredMessages) { + if (!m.message || !m.key || !m.messageTimestamp) { + continue; } - await this.createBotMessage(instance, msgQrCode, 'incoming'); + if (Long.isLong(m?.messageTimestamp)) { + m.messageTimestamp = m.messageTimestamp?.toNumber(); + } + + messagesRaw.push(prepareMessage(m as any)); } - } - } catch (error) { - this.logger.error(error); - } - } - public getNumberFromRemoteJid(remoteJid: string) { - return remoteJid.replace(/:\d+/, '').split('@')[0]; - } + this.addHistoryMessages( + instance, + messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), + ); - public startImportHistoryMessages(instance: InstanceDto) { - if (!this.isImportHistoryAvailable()) { - return; - } - - this.createBotMessage(instance, i18next.t('cw.import.startImport'), 'incoming'); - } - - public isImportHistoryAvailable() { - const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; - - return uri && uri !== 'postgres://user:password@hostname:port/dbname'; - } - - public addHistoryMessages(instance: InstanceDto, messagesRaw: MessageModel[]) { - if (!this.isImportHistoryAvailable()) { - return; - } - - chatwootImport.addHistoryMessages(instance, messagesRaw); - } - - public addHistoryContacts(instance: InstanceDto, contactsRaw: ContactModel[]) { - if (!this.isImportHistoryAvailable()) { - return; - } - - return chatwootImport.addHistoryContacts(instance, contactsRaw); - } - - public async importHistoryMessages(instance: InstanceDto) { - if (!this.isImportHistoryAvailable()) { - return; - } - - this.createBotMessage(instance, i18next.t('cw.import.importingMessages'), 'incoming'); - - const totalMessagesImported = await chatwootImport.importHistoryMessages( - instance, - this, - await this.getInbox(instance), - this.provider, - ); - this.updateContactAvatarInRecentConversations(instance); - - const msg = Number.isInteger(totalMessagesImported) - ? i18next.t('cw.import.messagesImported', { totalMessagesImported }) - : i18next.t('cw.import.messagesException'); - - this.createBotMessage(instance, msg, 'incoming'); - - return totalMessagesImported; - } - - public async updateContactAvatarInRecentConversations(instance: InstanceDto, limitContacts = 100) { - try { - if (!this.isImportHistoryAvailable()) { + await chatwootImport.importHistoryMessages(instance, this, inbox, this.provider); + const waInstance = this.waMonitor.waInstances[instance.instanceName]; + waInstance.clearCacheChatwoot(); + } catch (error) { return; } - - const client = await this.clientCw(instance); - if (!client) { - this.logger.warn('client not found'); - return null; - } - - const inbox = await this.getInbox(instance); - if (!inbox) { - this.logger.warn('inbox not found'); - return null; - } - - const recentContacts = await chatwootImport.getContactsOrderByRecentConversations( - inbox, - this.provider, - limitContacts, - ); - - const contactIdentifiers = recentContacts - .map((contact) => contact.identifier) - .filter((identifier) => identifier !== null); - - const contactsWithProfilePicture = ( - await this.prismaRepository.contact.findMany({ - where: { - instanceId: instance.instanceId, - id: { - in: contactIdentifiers, - }, - profilePicUrl: { - not: null, - }, - }, - }) - ).reduce((acc: Map, contact: ContactModel) => acc.set(contact.id, contact), new Map()); - - recentContacts.forEach(async (contact) => { - if (contactsWithProfilePicture.has(contact.identifier)) { - client.contacts.update({ - accountId: this.provider.accountId, - id: contact.id, - data: { - avatar_url: contactsWithProfilePicture.get(contact.identifier).profilePictureUrl || null, - }, - }); - } - }); - } catch (error) { - this.logger.error(`Error on update avatar in recent conversations: ${error.toString()}`); } } - - public async syncLostMessages( - instance: InstanceDto, - chatwootConfig: ChatwootDto, - prepareMessage: (message: any) => any, - ) { - try { - if (!this.isImportHistoryAvailable()) { - return; - } - if (!this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) { - return; - } - - const inbox = await this.getInbox(instance); - - const sqlMessages = `select * from messages m - where account_id = ${chatwootConfig.accountId} - and inbox_id = ${inbox.id} - and created_at >= now() - interval '6h' - order by created_at desc`; - - const messagesData = (await this.pgClient.query(sqlMessages))?.rows; - const ids: string[] = messagesData - .filter((message) => !!message.source_id) - .map((message) => message.source_id.replace('WAID:', '')); - - const savedMessages = await this.prismaRepository.message.findMany({ - where: { - Instance: { name: instance.instanceName }, - messageTimestamp: { gte: dayjs().subtract(6, 'hours').unix() }, - AND: ids.map((id) => ({ key: { path: ['id'], not: id } })), - }, - }); - - const filteredMessages = savedMessages.filter( - (msg: any) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid), - ); - const messagesRaw: any[] = []; - for (const m of filteredMessages) { - if (!m.message || !m.key || !m.messageTimestamp) { - continue; - } - - if (Long.isLong(m?.messageTimestamp)) { - m.messageTimestamp = m.messageTimestamp?.toNumber(); - } - - messagesRaw.push(prepareMessage(m as any)); - } - - this.addHistoryMessages( - instance, - messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), - ); - - await chatwootImport.importHistoryMessages(instance, this, inbox, this.provider); - const waInstance = this.waMonitor.waInstances[instance.instanceName]; - waInstance.clearCacheChatwoot(); - } catch (error) { - return; - } - } -} diff --git a/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts b/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts index 52453f59..b6466851 100644 --- a/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts +++ b/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts @@ -199,6 +199,10 @@ class ChatwootImport { provider: ChatwootModel, ) { try { + this.logger.info( + `[importHistoryMessages] Iniciando importação de mensagens para a instância "${instance.instanceName}".` + ); + const pgClient = postgresClient.getChatwootConnection(); const chatwootUser = await this.getChatwootUser(provider); @@ -209,28 +213,32 @@ class ChatwootImport { let totalMessagesImported = 0; let messagesOrdered = this.historyMessages.get(instance.instanceName) || []; + this.logger.info( + `[importHistoryMessages] Número de mensagens recuperadas do histórico: ${messagesOrdered.length}.` + ); if (messagesOrdered.length === 0) { return 0; } - // ordering messages by number and timestamp asc + // Ordenando as mensagens por remoteJid e timestamp (ascendente) messagesOrdered.sort((a, b) => { - const aKey = a.key as { - remoteJid: string; - }; - - const bKey = b.key as { - remoteJid: string; - }; + const aKey = a.key as { remoteJid: string }; + const bKey = b.key as { remoteJid: string }; const aMessageTimestamp = a.messageTimestamp as any as number; const bMessageTimestamp = b.messageTimestamp as any as number; return parseInt(aKey.remoteJid) - parseInt(bKey.remoteJid) || aMessageTimestamp - bMessageTimestamp; }); + this.logger.info('[importHistoryMessages] Mensagens ordenadas por remoteJid e messageTimestamp.'); + // Mapeando mensagens por telefone const allMessagesMappedByPhoneNumber = this.createMessagesMapByPhoneNumber(messagesOrdered); - // Map structure: +552199999999 => { first message timestamp from number, last message timestamp from number} + this.logger.info( + `[importHistoryMessages] Mensagens mapeadas para ${allMessagesMappedByPhoneNumber.size} números únicos.` + ); + + // Map: +numero => { first: timestamp, last: timestamp } const phoneNumbersWithTimestamp = new Map(); allMessagesMappedByPhoneNumber.forEach((messages: Message[], phoneNumber: string) => { phoneNumbersWithTimestamp.set(phoneNumber, { @@ -238,15 +246,37 @@ class ChatwootImport { last: messages[messages.length - 1]?.messageTimestamp as any as number, }); }); + this.logger.info( + `[importHistoryMessages] Criado mapa de timestamps para ${phoneNumbersWithTimestamp.size} números.` + ); - const existingSourceIds = await this.getExistingSourceIds(messagesOrdered.map((message: any) => message.key.id)); + // Removendo mensagens que já existem no banco (verificação pelo source_id) + const existingSourceIds = await this.getExistingSourceIds( + messagesOrdered.map((message: any) => message.key.id) + ); + this.logger.info( + `[importHistoryMessages] Quantidade de source_ids existentes no banco: ${existingSourceIds.size}.` + ); + const initialCount = messagesOrdered.length; messagesOrdered = messagesOrdered.filter((message: any) => !existingSourceIds.has(message.key.id)); - // processing messages in batch + this.logger.info( + `[importHistoryMessages] Mensagens filtradas: de ${initialCount} para ${messagesOrdered.length} após remoção de duplicados.` + ); + + // Processamento das mensagens em batches const batchSize = 4000; let messagesChunk: Message[] = this.sliceIntoChunks(messagesOrdered, batchSize); + let batchNumber = 1; while (messagesChunk.length > 0) { - // Map structure: +552199999999 => Message[] + this.logger.info( + `[importHistoryMessages] Processando batch ${batchNumber} com ${messagesChunk.length} mensagens.` + ); + + // Agrupando as mensagens deste batch por telefone const messagesByPhoneNumber = this.createMessagesMapByPhoneNumber(messagesChunk); + this.logger.info( + `[importHistoryMessages] Batch ${batchNumber}: ${messagesByPhoneNumber.size} números únicos encontrados.` + ); if (messagesByPhoneNumber.size > 0) { const fksByNumber = await this.selectOrCreateFksFromChatwoot( @@ -255,8 +285,11 @@ class ChatwootImport { phoneNumbersWithTimestamp, messagesByPhoneNumber, ); + this.logger.info( + `[importHistoryMessages] Batch ${batchNumber}: FKs recuperados para ${fksByNumber.size} números.` + ); - // inserting messages in chatwoot db + // Inserindo as mensagens no banco let sqlInsertMsg = `INSERT INTO messages (content, processed_message_content, account_id, inbox_id, conversation_id, message_type, private, content_type, sender_type, sender_id, source_id, created_at, updated_at) VALUES `; @@ -264,16 +297,16 @@ class ChatwootImport { messagesByPhoneNumber.forEach((messages: any[], phoneNumber: string) => { const fksChatwoot = fksByNumber.get(phoneNumber); - + this.logger.info( + `[importHistoryMessages] Número ${phoneNumber}: processando ${messages.length} mensagens.` + ); messages.forEach((message) => { if (!message.message) { return; } - if (!fksChatwoot?.conversation_id || !fksChatwoot?.contact_id) { return; } - const contentMessage = this.getContentMessage(chatwootService, message); if (!contentMessage) { return; @@ -308,123 +341,237 @@ class ChatwootImport { if (sqlInsertMsg.slice(-1) === ',') { sqlInsertMsg = sqlInsertMsg.slice(0, -1); } - totalMessagesImported += (await pgClient.query(sqlInsertMsg, bindInsertMsg))?.rowCount ?? 0; + const result = await pgClient.query(sqlInsertMsg, bindInsertMsg); + const rowCount = result?.rowCount ?? 0; + totalMessagesImported += rowCount; + this.logger.info( + `[importHistoryMessages] Batch ${batchNumber}: Inseridas ${rowCount} mensagens no banco.` + ); } } + batchNumber++; messagesChunk = this.sliceIntoChunks(messagesOrdered, batchSize); } this.deleteHistoryMessages(instance); this.deleteRepositoryMessagesCache(instance); + this.logger.info( + `[importHistoryMessages] Histórico e cache de mensagens da instância "${instance.instanceName}" foram limpos.` + ); const providerData: ChatwootDto = { ...provider, ignoreJids: Array.isArray(provider.ignoreJids) ? provider.ignoreJids.map((event) => String(event)) : [], }; + this.logger.info( + `[importHistoryMessages] Iniciando importação de contatos do histórico para a instância "${instance.instanceName}".` + ); this.importHistoryContacts(instance, providerData); + this.logger.info( + `[importHistoryMessages] Concluída a importação de mensagens para a instância "${instance.instanceName}". Total importado: ${totalMessagesImported}.` + ); return totalMessagesImported; } catch (error) { this.logger.error(`Error on import history messages: ${error.toString()}`); - this.deleteHistoryMessages(instance); this.deleteRepositoryMessagesCache(instance); } } + + private normalizeBrazilianPhoneNumberOptions(raw: string): [string, string] { + if (!raw.startsWith('+55')) { + return [raw, raw]; + } + + // Remove o prefixo "+55" + const digits = raw.slice(3); // pega tudo após os 3 primeiros caracteres + + if (digits.length === 10) { + // Se tiver 10 dígitos, assume que é o formato antigo. + // Old: exatamente o valor recebido. + // New: insere o '9' após os dois primeiros dígitos. + const newDigits = digits.slice(0, 2) + '9' + digits.slice(2); + return [raw, `+55${newDigits}`]; + } else if (digits.length === 11) { + // Se tiver 11 dígitos, assume que é o formato novo. + // New: exatamente o valor recebido. + // Old: remove o dígito extra na terceira posição. + const oldDigits = digits.slice(0, 2) + digits.slice(3); + return [`+55${oldDigits}`, raw]; + } else { + // Se por algum motivo tiver outra quantidade de dígitos, retorna os mesmos valores. + return [raw, raw]; + } + } + + public async selectOrCreateFksFromChatwoot( provider: ChatwootModel, inbox: inbox, phoneNumbersWithTimestamp: Map, - messagesByPhoneNumber: Map, + messagesByPhoneNumber: Map ): Promise> { const pgClient = postgresClient.getChatwootConnection(); + const resultMap = new Map(); + try { + // Para cada telefone presente + for (const rawPhoneNumber of messagesByPhoneNumber.keys()) { - const bindValues = [provider.accountId, inbox.id]; - const phoneNumberBind = Array.from(messagesByPhoneNumber.keys()) - .map((phoneNumber) => { - const phoneNumberTimestamp = phoneNumbersWithTimestamp.get(phoneNumber); - - if (phoneNumberTimestamp) { - bindValues.push(phoneNumber); - let bindStr = `($${bindValues.length},`; - - bindValues.push(phoneNumberTimestamp.first); - bindStr += `$${bindValues.length},`; - - bindValues.push(phoneNumberTimestamp.last); - return `${bindStr}$${bindValues.length})`; + // Obtém as duas versões normalizadas do número (com e sem nono dígito) + const [normalizedWith, normalizedWithout] = this.normalizeBrazilianPhoneNumberOptions(rawPhoneNumber); + const phoneTimestamp = phoneNumbersWithTimestamp.get(rawPhoneNumber); + if (!phoneTimestamp) { + this.logger.warn(`Timestamp não encontrado para o telefone ${rawPhoneNumber}`); + // Se preferir interromper, lance um erro: + throw new Error(`Timestamp não encontrado para o telefone ${rawPhoneNumber}`); } - }) - .join(','); - // select (or insert when necessary) data from tables contacts, contact_inboxes, conversations from chatwoot db - const sqlFromChatwoot = `WITH - phone_number AS ( - SELECT phone_number, created_at::INTEGER, last_activity_at::INTEGER FROM ( - VALUES - ${phoneNumberBind} - ) as t (phone_number, created_at, last_activity_at) - ), + // --- Etapa 1: Buscar ou Inserir o Contato --- + let contact; + try { + this.logger.verbose(`Buscando contato para: ${normalizedWith} OU ${normalizedWithout}`); + const selectContactQuery = ` + SELECT id, phone_number + FROM contacts + WHERE account_id = $1 + AND (phone_number = $2 OR phone_number = $3) + LIMIT 1 + `; + const contactRes = await pgClient.query(selectContactQuery, [ + provider.accountId, + normalizedWith, + normalizedWithout + ]); + if (contactRes.rowCount > 0) { + contact = contactRes.rows[0]; + this.logger.verbose(`Contato encontrado: ${JSON.stringify(contact)}`); + } else { + this.logger.verbose(`Contato não encontrado. Inserindo novo contato para ${normalizedWith}`); + const insertContactQuery = ` + INSERT INTO contacts (name, phone_number, account_id, identifier, created_at, updated_at) + VALUES (REPLACE($2, '+', ''), $2, $1, CONCAT(REPLACE($2, '+', ''), '@s.whatsapp.net'), + to_timestamp($3), to_timestamp($4)) + RETURNING id, phone_number + `; + const insertRes = await pgClient.query(insertContactQuery, [ + provider.accountId, + normalizedWith, + phoneTimestamp.first, + phoneTimestamp.last, + ]); + contact = insertRes.rows[0]; + this.logger.verbose(`Novo contato inserido: ${JSON.stringify(contact)}`); + } + } catch (error) { + this.logger.error(`Erro ao recuperar/inserir contato para ${rawPhoneNumber}: ${error}`); + throw error; + } - only_new_phone_number AS ( - SELECT * FROM phone_number - WHERE phone_number NOT IN ( - SELECT phone_number - FROM contacts - JOIN contact_inboxes ci ON ci.contact_id = contacts.id AND ci.inbox_id = $2 - JOIN conversations con ON con.contact_inbox_id = ci.id - AND con.account_id = $1 - AND con.inbox_id = $2 - AND con.contact_id = contacts.id - WHERE contacts.account_id = $1 - ) - ), - - new_contact AS ( - INSERT INTO contacts (name, phone_number, account_id, identifier, created_at, updated_at) - SELECT REPLACE(p.phone_number, '+', ''), p.phone_number, $1, CONCAT(REPLACE(p.phone_number, '+', ''), - '@s.whatsapp.net'), to_timestamp(p.created_at), to_timestamp(p.last_activity_at) - FROM only_new_phone_number AS p - ON CONFLICT(identifier, account_id) DO UPDATE SET updated_at = EXCLUDED.updated_at - RETURNING id, phone_number, created_at, updated_at - ), - - new_contact_inbox AS ( + // --- Etapa 2: Buscar ou Inserir a Conversa (e o Contact_inboxes) --- + let conversation; + try { + this.logger.verbose(`Buscando conversa para o contato (ID: ${contact.id}) na caixa ${inbox.id}`); + const selectConversationQuery = ` + SELECT con.id AS conversation_id, con.contact_id + FROM conversations con + JOIN contact_inboxes ci ON ci.contact_id = con.contact_id AND ci.inbox_id = $2 + WHERE con.account_id = $1 AND con.inbox_id = $2 AND con.contact_id = $3 + LIMIT 1 + `; + const convRes = await pgClient.query(selectConversationQuery, [provider.accountId, inbox.id, contact.id]); + if (convRes.rowCount > 0) { + conversation = convRes.rows[0]; + this.logger.verbose(`Conversa encontrada: ${JSON.stringify(conversation)}`); + } else { + this.logger.verbose(`Nenhuma conversa encontrada para o contato ${contact.id}. Verificando contact_inboxes.`); + let contactInboxId: number; + const selectContactInboxQuery = ` + SELECT id + FROM contact_inboxes + WHERE contact_id = $1 AND inbox_id = $2 + LIMIT 1 + `; + const ciRes = await pgClient.query(selectContactInboxQuery, [contact.id, inbox.id]); + if (ciRes.rowCount > 0) { + contactInboxId = ciRes.rows[0].id; + this.logger.verbose(`contact_inbox encontrado: ${contactInboxId}`); + } else { + this.logger.verbose(`Contact_inbox não encontrado para o contato ${contact.id}. Inserindo novo contact_inbox.`); + const insertContactInboxQuery = ` INSERT INTO contact_inboxes (contact_id, inbox_id, source_id, created_at, updated_at) - SELECT new_contact.id, $2, gen_random_uuid(), new_contact.created_at, new_contact.updated_at - FROM new_contact - RETURNING id, contact_id, created_at, updated_at - ), + VALUES ($1, $2, gen_random_uuid(), NOW(), NOW()) + RETURNING id + `; + const ciInsertRes = await pgClient.query(insertContactInboxQuery, [contact.id, inbox.id]); + contactInboxId = ciInsertRes.rows[0].id; + this.logger.verbose(`Novo contact_inbox inserido com ID: ${contactInboxId}`); + } - new_conversation AS ( - INSERT INTO conversations (account_id, inbox_id, status, contact_id, - contact_inbox_id, uuid, last_activity_at, created_at, updated_at) - SELECT $1, $2, 0, new_contact_inbox.contact_id, new_contact_inbox.id, gen_random_uuid(), - new_contact_inbox.updated_at, new_contact_inbox.created_at, new_contact_inbox.updated_at - FROM new_contact_inbox - RETURNING id, contact_id - ) + this.logger.verbose(`Inserindo conversa para o contato ${contact.id} com contact_inbox ${contactInboxId}`); + const insertConversationQuery = ` + INSERT INTO conversations + (account_id, inbox_id, status, contact_id, contact_inbox_id, uuid, last_activity_at, created_at, updated_at) + VALUES + ($1, $2, 0, $3, $4, gen_random_uuid(), NOW(), NOW(), NOW()) + RETURNING id AS conversation_id, contact_id + `; + const convInsertRes = await pgClient.query(insertConversationQuery, [ + provider.accountId, + inbox.id, + contact.id, + contactInboxId, + ]); + conversation = convInsertRes.rows[0]; + this.logger.verbose(`Nova conversa inserida: ${JSON.stringify(conversation)}`); + } + } catch (error) { + this.logger.error(`Erro ao recuperar/inserir conversa para o contato ${contact.id}: ${error}`); + throw error; + } - SELECT new_contact.phone_number, new_conversation.contact_id, new_conversation.id AS conversation_id - FROM new_conversation - JOIN new_contact ON new_conversation.contact_id = new_contact.id + // --- Etapa 3: Mapeia o resultado para o Map --- + const fks: FksChatwoot = { + phone_number: normalizedWith, + contact_id: contact.id, + conversation_id: conversation.conversation_id || conversation.id + }; + resultMap.set(normalizedWith, fks); + this.logger.verbose(`Resultado mapeado para ${normalizedWith}: ${JSON.stringify(fks)}`); - UNION - - SELECT p.phone_number, c.id contact_id, con.id conversation_id - FROM phone_number p - JOIN contacts c ON c.phone_number = p.phone_number - JOIN contact_inboxes ci ON ci.contact_id = c.id AND ci.inbox_id = $2 - JOIN conversations con ON con.contact_inbox_id = ci.id AND con.account_id = $1 - AND con.inbox_id = $2 AND con.contact_id = c.id`; - - const fksFromChatwoot = await pgClient.query(sqlFromChatwoot, bindValues); - - return new Map(fksFromChatwoot.rows.map((item: FksChatwoot) => [item.phone_number, item])); + } // fim for + } catch (error) { + this.logger.error(`Erro geral no processamento: ${error}`); + throw error; // Propaga o erro para que o método pare + } + return resultMap; } + + + + + + + + + + + + + + + + + + + + + + + public async getChatwootUser(provider: ChatwootModel): Promise { try { const pgClient = postgresClient.getChatwootConnection(); @@ -503,16 +650,14 @@ class ChatwootImport { switch (typeKey) { case 'documentMessage': - return `__`; + return `__`; case 'documentWithCaptionMessage': - return `__`; + }>_`; case 'templateMessage': return msg.message.templateMessage.hydratedTemplate.hydratedTitleText