From a657b92c54e17abda1e4e0c10d31c0c5aa916b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Sat, 19 Apr 2025 10:53:45 -0300 Subject: [PATCH] Ajustes da integracao com chatwoot e correcoes de erros na integracao com o s3 --- Dockerfile | 2 +- package-lock.json | 7 +- package.json | 5 +- .../chatwoot/services/chatwoot.service.ts | 4517 +++++++++-------- .../chatwoot/utils/chatwoot-import-helper.ts | 295 +- .../storage/s3/libs/minio.server.ts | 40 +- src/api/services/cache.service.ts | 19 +- 7 files changed, 2496 insertions(+), 2389 deletions(-) diff --git a/Dockerfile b/Dockerfile index f2878316..1c9759cb 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.3" description="Api to control whatsapp features through http requests." +LABEL version="2.2.3.17" 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 f942a6f5..daa0418c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evolution-api", - "version": "2.2.3.3", + "version": "2.2.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evolution-api", - "version": "2.2.3.3", + "version": "2.2.3.17", "license": "Apache-2.0", "dependencies": { "@adiwajshing/keyed-db": "^0.2.4", @@ -55,6 +55,7 @@ "sharp": "^0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "source-map-support": "^0.5.21", "tsup": "^8.3.5" }, "devDependencies": { @@ -10848,7 +10849,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10857,7 +10857,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" diff --git a/package.json b/package.json index 8920c25e..ebd58b28 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "evolution-api", - "version": "2.2.3.3", + "version": "2.2.3.17", "description": "Rest api for communication with WhatsApp", "main": "./dist/main.js", "type": "commonjs", "scripts": { "build": "tsc --noEmit && tsup", "start": "tsnd -r tsconfig-paths/register --files --transpile-only ./src/main.ts", - "start:prod": "node dist/main", + "start:prod": "node --enable-source-maps -r source-map-support/register dist/main.js", "dev:server": "tsnd -r tsconfig-paths/register --files --transpile-only --respawn --ignore-watch node_modules ./src/main.ts", "test": "tsnd -r tsconfig-paths/register --files --transpile-only --respawn --ignore-watch node_modules ./test/all.test.ts", "lint": "eslint --fix --ext .ts src", @@ -95,6 +95,7 @@ "sharp": "^0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "source-map-support": "^0.5.21", "tsup": "^8.3.5" }, "devDependencies": { diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 782a4eb2..db863ead 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1,1018 +1,1246 @@ - 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; - - 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); + 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; } - 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'); - 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; - } - - 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, - }); - - 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; - } - } - } + const provider = await this.waMonitor.waInstances[instance.instanceName]?.findChatwoot(); + if (!provider) { + this.logger.warn('provider not found'); return null; } - private getNumbers(query: string) { - const numbers = []; - numbers.push(query); + this.cache.set(cacheKey, provider); - 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 provider; + } - return numbers; + private async clientCw(instance: InstanceDto) { + const provider = await this.getProvider(instance); + + if (!provider) { + this.logger.error('provider not found'); + return null; } - private getSearchableFields() { - return ['phone_number']; + 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; } - 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; + if (!id) { + this.logger.warn('id is required'); + return null; } - public async createConversation(instance: InstanceDto, body: any) { - try { - this.logger.verbose('--- Start createConversation ---'); - this.logger.verbose(`Instance: ${JSON.stringify(instance)}`); + const contact = await client.contact.getContactable({ + accountId: this.provider.accountId, + id, + }); - 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 { - const jid = body.key.remoteJid; - contact = await this.createContact( - instance, - chatId, - filterInbox.id, - isGroup, - nameContact, - picture_url.profilePictureUrl || null, - jid, - ); - } - - if (!contact) { - this.logger.warn('Contact not created or found'); - return null; - } - - const contactId = contact?.payload?.id || contact?.payload?.contact?.id || contact?.id; - this.logger.verbose(`Contact ID: ${contactId}`); - - const contactConversations = (await client.contacts.listConversations({ - 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'); - 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)}`); - - if (conversation && this.provider.conversationPending && conversation.status !== 'open') { - - 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 (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}`); - } + if (!contact) { + this.logger.warn('contact not found'); + return null; } - 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; - } + return contact; + } - const client = await this.clientCw(instance); + 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; - } + if (!client) { + this.logger.warn('client not found'); + return null; + } - const inbox = (await client.inboxes.list({ + 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, - })) as any; + data: { + name: inboxName, + channel: data as any, + }, + }); if (!inbox) { this.logger.warn('inbox not found'); return null; } - const findByName = inbox.payload.find((inbox) => inbox.name === this.getClientCwConfig().nameInbox); + inboxId = inbox.id; + } else { + const inbox = findInbox.payload.find((inbox) => inbox.name === inboxName); - if (!findByName) { + if (!inbox) { this.logger.warn('inbox not found'); return null; } - this.cache.set(cacheKey, findByName); - return findByName; + 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; } - 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, - ) { + 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 (sourceId && this.isImportHistoryAvailable()) { - const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); - if (messageAlreadySaved && messageAlreadySaved.size > 0) { - this.logger.warn('Message already saved on chatwoot'); - return null; - } - } + if (!contact) { + this.logger.warn('contact not found'); + return null; + } - const client = await this.clientCw(instance); + const contactId = contact.id || contact.payload.contact.id; + this.logger.log(`Contact created - contactId: ${contactId}`); - if (!client) { - this.logger.warn('client not found'); - return null; - } + if (qrcode) { + this.logger.log('QR code enabled'); + const data = { + contact_id: contactId.toString(), + inbox_id: inboxId.toString(), + }; - const replyToIds = await this.getReplyToIds(messageBody, instance); - - const sourceReplyId = quotedMsg?.chatwootMessageId || null; - - const message = await client.messages.create({ + const conversation = await client.conversations.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, - }, + data, }); - 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; + 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: content, - message_type: messageType, - attachments: attachments, + content: contentMsg, + message_type: 'outgoing', }, }); if (!message) { - this.logger.warn('message not found'); + this.logger.warn('conversation not found'); return null; } - - return message; + this.logger.log('Init message sent'); } - 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(); + return true; + } - if (content) { - data.append('content', content); + public async createContact( + instance: InstanceDto, + phoneNumber: string, + inboxId: number, + isGroup: boolean, + name?: string, + avatar_url?: string, + jid?: string, + ) { + this.logger.verbose( + `[ChatwootService][createContact] start instance=${instance.instanceName} phone=${phoneNumber}` + ); + + // 1) obter cliente + const client = await this.clientCw(instance); + if (!client) { + this.logger.warn( + `[ChatwootService][createContact] client not found for instance=${instance.instanceName}` + ); + return null; + } + this.logger.verbose(`[ChatwootService][createContact] client obtained`); + + // 2) montar payload + const data: any = { inbox_id: inboxId, name: name || phoneNumber, avatar_url }; + if (!isGroup) { + data.identifier = jid; + data.phone_number = `+${phoneNumber}`; + } else { + data.identifier = phoneNumber; + } + this.logger.verbose( + `[ChatwootService][createContact] payload=${JSON.stringify(data)}` + ); + + // 3) criar no Chatwoot + let rawResponse: any; + try { + rawResponse = await client.contacts.create({ + accountId: this.provider.accountId, + data, + }); + this.logger.verbose( + `[ChatwootService][createContact] raw create response=${JSON.stringify(rawResponse)}` + ); + } catch (err) { + this.logger.error( + `[ChatwootService][createContact] error creating contact: ${err}` + ); + throw err; + } + + // 4) extrair o contactId dos dois possíveis formatos + // - legacy: { id: number, ... } + // - nova versão: { payload: { contact: { id: number, ... } } } + const maybePayload = rawResponse.payload?.contact; + const contactObj = maybePayload ?? rawResponse; + const contactId = contactObj.id as number | undefined; + + if (!contactId) { + this.logger.error( + `[ChatwootService][createContact] no id found in response; raw=${JSON.stringify(rawResponse)}` + ); + return null; + } + this.logger.verbose( + `[ChatwootService][createContact] created contact id=${contactId}` + ); + + // 5) adicionar label + try { + this.logger.verbose( + `[ChatwootService][createContact] adding label=${this.provider.nameInbox} to contactId=${contactId}` + ); + await this.addLabelToContact(this.provider.nameInbox, contactId); + } catch (err) { + this.logger.error( + `[ChatwootService][createContact] error addLabelToContact: ${err}` + ); + } + + // 6) retornar objeto com .id para ser usado pelo createConversation + return { id: contactId, ...contactObj }; + } + + + + + 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]); } - data.append('message_type', messageType); + return true; + } catch (error) { + return false; + } + } - 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); - } + public async findContact(instance: InstanceDto, phoneNumber: string) { + this.logger.verbose( + `[ChatwootService][findContact] start for instance=${instance.instanceName}, phoneNumber=${phoneNumber}` + ); + + const client = await this.clientCw(instance); + if (!client) { + this.logger.warn( + `[ChatwootService][findContact] client not found for instance=${instance.instanceName}` + ); + return null; + } + + const isGroup = phoneNumber.includes('@g.us'); + const query = isGroup ? phoneNumber : `+${phoneNumber}`; + + this.logger.verbose( + `[ChatwootService][findContact] isGroup=${isGroup}, query=${query}` + ); + + let response: any; + try { + if (isGroup) { + response = await client.contacts.search({ + accountId: this.provider.accountId, + q: query, + }); + } else { + response = await chatwootRequest(this.getClientCwConfig(), { + method: 'POST', + url: `/api/v1/accounts/${this.provider.accountId}/contacts/filter`, + body: { payload: this.getFilterPayload(query) }, + }); } + this.logger.verbose( + `[ChatwootService][findContact] raw response: ${JSON.stringify(response)}` + ); + } catch (error) { + this.logger.error( + `[ChatwootService][findContact] error during API call: ${error.message}` + ); + return null; + } + + const payload = response.payload || []; + this.logger.verbose( + `[ChatwootService][findContact] payload length: ${payload.length}` + ); + + if (payload.length === 0) { + this.logger.warn( + `[ChatwootService][findContact] contact not found for query=${query}` + ); + return null; + } + + let found: any; + if (isGroup) { + found = payload.find((c: any) => c.identifier === query); + } else { + found = payload.length > 1 + ? this.findContactInContactList(payload, query) + : payload[0]; + } + + this.logger.verbose( + `[ChatwootService][findContact] returning contact: ${JSON.stringify(found)}` + ); + + return found; + } + - 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(), + 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, }, - data: data, + }); + + 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; + } + + + + private pendingCreateConv = new Map>(); + + public async createConversation(instance: InstanceDto, body: any): Promise { + const remoteJid = body.key.remoteJid as string; + this.logger.verbose("[createConversation] Iniciando para remoteJid=" + remoteJid); + + // 0) Se já está criando, reutiliza a promise + if (this.pendingCreateConv.has(remoteJid)) { + this.logger.verbose("[createConversation] Ja em criacao para " + remoteJid + ", retornando promise existente"); + return this.pendingCreateConv.get(remoteJid)!; + } + + let triedRecovery = false; + const cacheKey = instance.instanceName + ":createConversation-" + remoteJid; + + const p = (async (): Promise => { + try { + this.logger.verbose("[createConversation] Chamando _createConversation pela primeira vez"); + return await this._createConversation(instance, body); + } catch (err) { + this.logger.error("[createConversation] Erro na primeira tentativa: " + err); + if (!triedRecovery) { + triedRecovery = true; + this.logger.warn("[createConversation] Tentando recuperacao: limpando cache e recriando conversa"); + await this.cache.delete(cacheKey); + this.logger.verbose("[createConversation] Cache deletado para chave=" + cacheKey); + return await this._createConversation(instance, body); + } + this.logger.error("[createConversation] Ja tentei recuperacao, repassando erro"); + throw err; + } + })(); + + this.pendingCreateConv.set(remoteJid, p); + try { + const convId = await p; + this.logger.verbose("[createConversation] Concluido para " + remoteJid + ", convId=" + convId); + return convId; + } finally { + this.pendingCreateConv.delete(remoteJid); + this.logger.verbose("[createConversation] Removido pendingCreateConv para " + remoteJid); + } + } + + private async _createConversation(instance: InstanceDto, body: any): Promise { + const remoteJid = body.key.remoteJid as string; + const cacheKey = instance.instanceName + ":createConversation-" + remoteJid; + this.logger.verbose("[_createConversation] Start para remoteJid=" + remoteJid); + + // 1) Cliente Chatwoot + const client = await this.clientCw(instance); + if (!client) { + this.logger.error("[_createConversation] Client Chatwoot nao encontrado para " + instance.instanceName); + throw new Error("Client not found for instance: " + instance.instanceName); + } + this.logger.verbose("[_createConversation] Client Chatwoot obtido"); + + // 2) Cache + const hasCache = await this.cache.has(cacheKey); + this.logger.verbose("[_createConversation] Cache check para key=" + cacheKey + ": " + hasCache); + if (hasCache) { + const cachedId = (await this.cache.get(cacheKey)) as number; + this.logger.verbose("[_createConversation] Usando ID em cache=" + cachedId); + return cachedId; + } + + // 3) Inbox + const filterInbox = await this.getInbox(instance); + if (!filterInbox) { + this.logger.error("[_createConversation] Inbox nao encontrada para " + instance.instanceName); + throw new Error("Inbox not found for instance: " + instance.instanceName); + } + this.logger.verbose("[_createConversation] Inbox encontrada: id=" + filterInbox.id); + + // 4) Contato + const isGroup = remoteJid.includes("@g.us"); + const chatId = isGroup ? remoteJid : remoteJid.split("@")[0]; + this.logger.verbose("[_createConversation] isGroup=" + isGroup + ", chatId=" + chatId); + + let contact = await this.findContact(instance, chatId); + if (contact) { + this.logger.verbose("[_createConversation] Contato encontrado: id=" + contact.id); + } else { + this.logger.verbose("[_createConversation] Contato nao existe, criando..."); + const name = isGroup + ? (await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId)).subject + " (GROUP)" + : body.pushName || chatId; + const pictureUrl = (await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId)).profilePictureUrl; + contact = await this.createContact( + instance, + chatId, + filterInbox.id, + isGroup, + name, + pictureUrl || null, + isGroup ? remoteJid : undefined + ); + if (!contact) { + this.logger.error("[_createConversation] Falha ao criar contato para " + chatId); + throw new Error("Nao conseguiu criar contato para conversa"); + } + this.logger.verbose("[_createConversation] Contato criado: id=" + contact.id); + } + const contactId = (contact.id ?? contact.payload?.contact?.id) as number; + + // 5) Listar conversas existentes + this.logger.verbose("[_createConversation] Chamando listConversations para contactId=" + contactId); + const listResp: any = await client.contacts.listConversations({ + accountId: this.provider.accountId, + id: contactId, + }); + this.logger.verbose("[_createConversation] listConversations raw: " + JSON.stringify(listResp)); + + let conversations: any[] = []; + if (Array.isArray(listResp)) conversations = listResp; + else if (Array.isArray(listResp.payload)) conversations = listResp.payload; + else if (Array.isArray(listResp.data?.payload)) conversations = listResp.data.payload; + else if (Array.isArray(listResp.data)) conversations = listResp.data; + this.logger.verbose("[_createConversation] Encontradas " + conversations.length + " conversas"); + + // 6) Filtrar conversa aberta ou pendente + let conv = null; + if (this.provider.reopenConversation) { + this.logger.verbose("[_createConversation] reopenConversation=true, buscando inbox_id=" + filterInbox.id); + conv = conversations.find(c => c.inbox_id === filterInbox.id); + if (conv && this.provider.conversationPending && conv.status !== "pending") { + this.logger.verbose("[_createConversation] Reabrindo conversa " + conv.id + " para status=pending"); + await client.conversations.toggleStatus({ + accountId: this.provider.accountId, + conversationId: conv.id, + data: { status: "pending" }, + }); + } + } else { + this.logger.verbose("[_createConversation] reopenConversation=false, buscando status!=resolved"); + conv = conversations.find(c => c.status !== "resolved" && c.inbox_id === filterInbox.id); + } + + if (conv) { + this.logger.verbose("[_createConversation] Usando conversa existente id=" + conv.id); + this.cache.set(cacheKey, conv.id, 5 * 60); + return conv.id; + } + + // 7) Criar nova conversa + this.logger.verbose("[_createConversation] Nenhuma conversa encontrada, criando nova..."); + const payload: any = { + contact_id: contactId.toString(), + inbox_id: filterInbox.id.toString(), + ...(this.provider.conversationPending ? { status: "pending" } : {}), + }; + + try { + const newConv = await client.conversations.create({ + accountId: this.provider.accountId, + data: payload, + }); + if (!newConv?.id) { + this.logger.error("[_createConversation] create retornou sem ID"); + throw new Error("Falha ao criar nova conversa: resposta sem ID"); + } + this.logger.verbose("[_createConversation] Nova conversa criada id=" + newConv.id); + this.cache.set(cacheKey, newConv.id, 5 * 60); + return newConv.id; + + } catch (err: any) { + this.logger.error("[_createConversation] Erro ao criar conversa: " + err); + this.logger.warn("[_createConversation] Tentando recuperar conversa via listConversations novamente"); + + const retryList: any = await client.contacts.listConversations({ + accountId: this.provider.accountId, + id: contactId, + }); + this.logger.verbose("[_createConversation] retry listConversations raw: " + JSON.stringify(retryList)); + + let retryConvs: any[] = []; + if (Array.isArray(retryList)) retryConvs = retryList; + else if (Array.isArray(retryList.payload)) retryConvs = retryList.payload; + else if (Array.isArray(retryList.data?.payload)) retryConvs = retryList.data.payload; + else if (Array.isArray(retryList.data)) retryConvs = retryList.data; + this.logger.verbose("[_createConversation] retry encontrou " + retryConvs.length + " conversas"); + + const recovered = retryConvs.find(c => c.inbox_id === filterInbox.id); + if (recovered) { + this.logger.verbose("[_createConversation] Recuperou conversa existente id=" + recovered.id); + this.cache.set(cacheKey, recovered.id, 5 * 60); + return recovered.id; + } + + this.logger.error("[_createConversation] Nao recuperou conversa, repassando erro"); + throw err; + } + } + + + + + + + + + + + + + + + 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, + ) { + + if (sourceId && this.isImportHistoryAvailable()) { + const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); + if (messageAlreadySaved && messageAlreadySaved.size > 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, }; - try { - const { data } = await axios.request(config); + sendTelemetry('/message/sendMedia'); - return data; - } catch (error) { - this.logger.error(error); + 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; } - public async createBotQr( - instance: InstanceDto, - content: string, - messageType: 'incoming' | 'outgoing' | undefined, - fileStream?: Readable, - fileName?: string, - ) { + 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)); + const client = await this.clientCw(instance); if (!client) { @@ -1020,702 +1248,553 @@ return null; } - if (!this.configService.get('CHATWOOT').BOT_CONTACT) { - this.logger.log('Chatwoot bot contact is disabled'); - - return true; + 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 contact = await this.findContact(instance, '123456'); - - if (!contact) { - this.logger.warn('contact not found'); - return null; + if ( + !body?.conversation || + body.private || + (body.event === 'message_updated' && !body.content_attributes?.deleted) + ) { + return { message: 'bot' }; } - const filterInbox = await this.getInbox(instance); + const chatId = + body.conversation.meta.sender?.identifier || body.conversation.meta.sender?.phone_number.replace('+', ''); + // Chatwoot to Whatsapp + const messageReceived = body.content + ? body.content + .replaceAll(/(? 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 { - await this.createBotMessage( - instance, - i18next.t('cw.inbox.alreadyConnected', { - inboxName: body.inbox.name, - }), - 'incoming', - ); - } - } - - if (command === 'clearcache') { - waInstance.clearCacheChatwoot(); + if (state !== 'open') { + const number = command.split(':')[1]; + await waInstance.connectToWhatsapp(number); + } else { await this.createBotMessage( instance, - i18next.t('cw.inbox.clearCache', { + i18next.t('cw.inbox.alreadyConnected', { + inboxName: body.inbox.name, + }), + 'incoming', + ); + } + } + + if (command === 'clearcache') { + waInstance.clearCacheChatwoot(); + await this.createBotMessage( + instance, + i18next.t('cw.inbox.clearCache', { + inboxName: body.inbox.name, + }), + 'incoming', + ); + } + + 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 (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, - }); - - await this.createBotMessage(instance, msgLogout, 'incoming'); - - await waInstance?.client?.logout('Log out instance: ' + instance.instanceName); - await waInstance?.client?.ws?.close(); + if (state) { + await this.createBotMessage( + instance, + i18next.t('cw.inbox.status', { + inboxName: body.inbox.name, + state: state, + }), + 'incoming', + ); } } - 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' }; - } + if (cwBotContact && (command === 'disconnect' || command === 'desconectar')) { + const msgLogout = i18next.t('cw.inbox.disconnect', { + inboxName: body.inbox.name, + }); - if (!waInstance && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); - return { message: 'bot' }; - } + await this.createBotMessage(instance, msgLogout, 'incoming'); - 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); + await waInstance?.client?.logout('Log out instance: ' + instance.instanceName); + await waInstance?.client?.ws?.close(); + } + } - formatText = textToConcat.join(formattedDelimiter); - } + 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' }; + } - for (const message of body.conversation.messages) { - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (!messageReceived) { - formatText = null; - } + if (!waInstance && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); + return { message: 'bot' }; + } - const options: Options = { - quoted: await this.getQuotedMessage(body, instance), - }; + 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 messageSent = await this.sendAttachment( - waInstance, - chatId, - attachment.data_url, - formatText, - options, - ); - if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id); - } + formatText = textToConcat.join(formattedDelimiter); + } - 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, - ); + for (const message of body.conversation.messages) { + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + if (!messageReceived) { + formatText = null; } - } else { - const data: SendTextDto = { - number: chatId, - text: formatText, - delay: 1200, + + const options: Options = { 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 messageSent = await this.sendAttachment( + waInstance, + chatId, + attachment.data_url, + formatText, + options, + ); + if (!messageSent && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id); } - } - } - const chatwootRead = this.configService.get('CHATWOOT').MESSAGE_READ; - if (chatwootRead) { - const lastMessage = await this.prismaRepository.message.findFirst({ - where: { - key: { - path: ['fromMe'], - equals: false, + await this.updateChatwootMessageId( + { + ...messageSent, + owner: instance.instanceName, }, - instanceId: instance.instanceId, - }, - }); - if (lastMessage && !lastMessage.chatwootIsRead) { - const key = lastMessage.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; + { + 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), + }; - 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, - }; + sendTelemetry('/message/sendText'); - await this.prismaRepository.message.updateMany({ - where: { + 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, - key: { - path: ['id'], - equals: key.id, - }, }, - data: updateMessage, - }); + { + 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; } } } - 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, - }; + 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; + }; - sendTelemetry('/message/sendText'); + 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 waInstance?.textMessage(data); + await this.prismaRepository.message.updateMany({ + where: { + instanceId: instance.instanceId, + key: { + path: ['id'], + equals: key.id, + }, + }, + data: updateMessage, + }); + } } + } - return { message: 'bot' }; - } catch (error) { - this.logger.error(error); + 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, + }; - return { message: 'bot' }; + 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; + } } } - private async updateChatwootMessageId( - message: MessageModel, - chatwootMessageIds: ChatwootMessage, - instance: InstanceDto, - ) { - const key = message.key as { + return { + in_reply_to: inReplyTo, + in_reply_to_external_id: inReplyToExternalId, + }; + } + + 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 key = message?.key as { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; - if (!chatwootMessageIds.messageId || !key?.id) { - return; + if (message && key?.id) { + return { + key: message.key as proto.IMessageKey, + message: message.message as proto.IMessage, + }; } + } - 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, - }, + 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; + }; + 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) => { + const [key, value] = line.split(':'); + if (key && value) { + contactInfo[key] = value; + } }); - if (this.isImportHistoryAvailable()) { - chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id); - } - } + let formattedContact = + `*${i18next.t('cw.contactMessage.contact')}:*\n\n` + + `_${i18next.t('cw.contactMessage.name')}:_ ${contactInfo['FN']}`; - private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { - const messages = await this.prismaRepository.message.findFirst({ - where: { - key: { - path: ['id'], - equals: keyId, - }, - instanceId: instance.instanceId, - }, + 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 messages || null; + return formattedContact; } - 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 { - in_reply_to: inReplyTo, - in_reply_to_external_id: inReplyToExternalId, - }; - } - - 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 key = message?.key as { - id: string; - fromMe: boolean; - remoteJid: string; - participant?: string; - }; - - if (message && key?.id) { - return { - key: message.key as proto.IMessageKey, - message: message.message as proto.IMessage, - }; - } - } - - 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; - }; - 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'); + if (typeKey === 'contactsArrayMessage') { + const formattedContacts = result.contacts.map((contact) => { + const vCardData = contact.vcard.split('\n'); const contactInfo = {}; vCardData.forEach((line) => { @@ -1725,9 +1804,9 @@ } }); - let formattedContact = - `*${i18next.t('cw.contactMessage.contact')}:*\n\n` + - `_${i18next.t('cw.contactMessage.name')}:_ ${contactInfo['FN']}`; + let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n_${i18next.t( + 'cw.contactMessage.name', + )}:_ ${contact.displayName}`; let numberCount = 1; Object.keys(contactInfo).forEach((key) => { @@ -1743,385 +1822,214 @@ }); return formattedContact; - } + }); - if (typeKey === 'contactsArrayMessage') { - const formattedContacts = result.contacts.map((contact) => { - const vCardData = contact.vcard.split('\n'); - const contactInfo = {}; + const formattedContactsArray = formattedContacts.join('\n\n'); - vCardData.forEach((line) => { - const [key, value] = line.split(':'); - if (key && value) { - contactInfo[key] = value; - } - }); + return formattedContactsArray; + } - let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n_${i18next.t( - 'cw.contactMessage.name', - )}:_ ${contact.displayName}`; + if (typeKey === 'listMessage') { + const listTitle = result?.title || 'Unknown'; + const listDescription = result?.description || 'Unknown'; + const listFooter = result?.footerText || 'Unknown'; - 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++; - } - }); + let formattedList = + '*List Menu:*\n\n' + + '_Title_: ' + + listTitle + + '\n' + + '_Description_: ' + + listDescription + + '\n' + + '_Footer_: ' + + listFooter; - return formattedContact; + 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'; + } }); - - const formattedContactsArray = formattedContacts.join('\n\n'); - - return formattedContactsArray; + } else { + formattedList += '\nNo sections found.\n'; } - if (typeKey === 'listMessage') { - const listTitle = result?.title || 'Unknown'; - const listDescription = result?.description || 'Unknown'; - const listFooter = result?.footerText || 'Unknown'; + return formattedList; + } - let formattedList = - '*List Menu:*\n\n' + - '_Title_: ' + - listTitle + - '\n' + - '_Description_: ' + - listDescription + - '\n' + - '_Footer_: ' + - listFooter; + if (typeKey === 'listResponseMessage') { + const responseTitle = result?.title || 'Unknown'; + const responseDescription = result?.description || 'Unknown'; + const responseRowId = result?.singleSelectReply?.selectedRowId || 'Unknown'; - if (result.sections && result.sections.length > 0) { - result.sections.forEach((section, sectionIndex) => { - formattedList += '\n\n*Section ' + (sectionIndex + 1) + ':* ' + section.title || 'Unknown\n'; + const formattedResponseList = + '*List Response:*\n\n' + + '_Title_: ' + + responseTitle + + '\n' + + '_Description_: ' + + responseDescription + + '\n' + + '_ID_: ' + + responseRowId; + return formattedResponseList; + } - 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 result; + } + + 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 (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 (event === 'messages.upsert' || event === 'send.message') { + if (body.key.remoteJid === 'status@broadcast') { + return; + } + + if (body.message?.ephemeralMessage?.message) { + body.message = { + ...body.message?.ephemeralMessage?.message, + }; + } + + const originalMessage = await this.getConversationMessage(body.message); + const bodyMessage = originalMessage + ? originalMessage + .replaceAll(/\*((?!\s)([^\n*]+?)(? 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 (event === 'messages.upsert' || event === 'send.message') { - if (body.key.remoteJid === 'status@broadcast') { - return; - } - - if (body.message?.ephemeralMessage?.message) { - body.message = { - ...body.message?.ephemeralMessage?.message, - }; - } - - const originalMessage = await this.getConversationMessage(body.message); - const bodyMessage = originalMessage - ? originalMessage - .replaceAll(/\*((?!\s)([^\n*]+?)(? {}; - fileStream.push(fileData); - fileStream.push(null); - - 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.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; + 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 (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; + if (!nameFile) { + nameFile = `${Math.random().toString(36).substring(7)}.${mimeTypes.extension(downloadBase64.mimetype) || ''}`; } - const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl; - if (isAdsMessage) { - const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' }); + const fileData = Buffer.from(downloadBase64.base64, 'base64'); - 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; - } + const fileStream = new Readable(); + fileStream._read = () => { }; + fileStream.push(fileData); + fileStream.push(null); if (body.key.remoteJid.includes('@g.us')) { const participantName = body.pushName; @@ -2144,13 +2052,13 @@ content = `${bodyMessage}`; } - const send = await this.createMessage( - instance, + const send = await this.sendData( getConversation, - content, + fileStream, + nameFile, messageType, - false, - [], + content, + instance, body, 'WAID:' + body.key.id, quotedMsg, @@ -2163,13 +2071,13 @@ return send; } else { - const send = await this.createMessage( - instance, + const send = await this.sendData( getConversation, - bodyMessage, + fileStream, + nameFile, messageType, - false, - [], + bodyMessage, + instance, body, 'WAID:' + body.key.id, quotedMsg, @@ -2184,356 +2092,503 @@ } } - 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) { + if (reactionMessage) { + if (reactionMessage.text) { const send = await this.createMessage( instance, - message.chatwootConversationId, - editedText, + getConversation, + reactionMessage.text, messageType, false, [], { - message: { extendedTextMessage: { contextInfo: { stanzaId: key.id } } }, + message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, }, 'WAID:' + body.key.id, - null, + quotedMsg, ); if (!send) { - this.logger.warn('edited message not sent'); + this.logger.warn('message not sent'); return; } } + return; } - if (event === 'messages.read') { - if (!body?.key?.id || !body?.key?.remoteJid) { + 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) { 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 (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, - }); - } - } - return; - } - - if (event === 'status.instance') { - const data = body; - const inbox = await this.getInbox(instance); - - 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; - } - - 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 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, + 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, + }); } - }); - } 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 (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, + 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'); + return; + } } - if (!this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) { + return; + } + + 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 (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, + }); + } + } + return; + } + + if (event === 'status.instance') { + const data = body; const inbox = await this.getInbox(instance); - const sqlMessages = `select * from messages m + 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; + } + + 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 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()); + + for (const c of recentContacts) { + const pic = contactsWithProfilePicture.get(c.identifier); + if (!pic) continue; + + try { + await client.contacts.update({ + accountId: this.provider.accountId, + id: c.id, + data: { + avatar_url: pic.profilePictureUrl || null, + }, + }); + this.logger.verbose(`Avatar atualizado para o contato ${c.id}`); + } catch (err) { + this.logger.error(`Falha ao atualizar avatar do contato ${c.id}: ${err}`); + } + } + + + + } 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 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 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)); + 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; } - this.addHistoryMessages( - instance, - messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), - ); + if (Long.isLong(m?.messageTimestamp)) { + m.messageTimestamp = m.messageTimestamp?.toNumber(); + } - await chatwootImport.importHistoryMessages(instance, this, inbox, this.provider); - const waInstance = this.waMonitor.waInstances[instance.instanceName]; - waInstance.clearCacheChatwoot(); - } catch (error) { - return; + 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 b6466851..408b20b8 100644 --- a/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts +++ b/src/api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper.ts @@ -93,8 +93,8 @@ class ChatwootImport { return 0; } - let contactsChunk: Contact[] = this.sliceIntoChunks(contacts, 3000); - while (contactsChunk.length > 0) { + const contactBatches = this.sliceIntoChunks(contacts, 3000); + for (const contactsChunk of contactBatches) { const labelSql = `SELECT id FROM labels WHERE title = '${provider.nameInbox}' AND account_id = ${provider.accountId} LIMIT 1`; let labelId = (await pgClient.query(labelSql))?.rows[0]?.id; @@ -157,8 +157,7 @@ class ChatwootImport { } await pgClient.query(sqlInsertLabel, [tagId, 'Contact', 'labels']); - - contactsChunk = this.sliceIntoChunks(contacts, 3000); + } this.deleteHistoryContacts(instance); @@ -265,9 +264,9 @@ class ChatwootImport { // Processamento das mensagens em batches const batchSize = 4000; - let messagesChunk: Message[] = this.sliceIntoChunks(messagesOrdered, batchSize); + let messagesChunks = this.sliceIntoChunks(messagesOrdered, batchSize); let batchNumber = 1; - while (messagesChunk.length > 0) { + for (const messagesChunk of messagesChunks) { this.logger.info( `[importHistoryMessages] Processando batch ${batchNumber} com ${messagesChunk.length} mensagens.` ); @@ -350,7 +349,7 @@ class ChatwootImport { } } batchNumber++; - messagesChunk = this.sliceIntoChunks(messagesOrdered, batchSize); + } this.deleteHistoryMessages(instance); @@ -367,7 +366,7 @@ class ChatwootImport { this.logger.info( `[importHistoryMessages] Iniciando importação de contatos do histórico para a instância "${instance.instanceName}".` ); - this.importHistoryContacts(instance, providerData); + await this.importHistoryContacts(instance, providerData); this.logger.info( `[importHistoryMessages] Concluída a importação de mensagens para a instância "${instance.instanceName}". Total importado: ${totalMessagesImported}.` @@ -385,10 +384,10 @@ class ChatwootImport { 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. @@ -405,7 +404,7 @@ class ChatwootImport { // Se por algum motivo tiver outra quantidade de dígitos, retorna os mesmos valores. return [raw, raw]; } - } + } public async selectOrCreateFksFromChatwoot( @@ -416,136 +415,154 @@ class ChatwootImport { ): Promise> { const pgClient = postgresClient.getChatwootConnection(); const resultMap = new Map(); - try { - // Para cada telefone presente - for (const rawPhoneNumber of messagesByPhoneNumber.keys()) { - // 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}`); - } + for (const rawPhone of messagesByPhoneNumber.keys()) { + // 1) Normalizar telefone e gerar JIDs + const [normalizedWith, normalizedWithout] = + this.normalizeBrazilianPhoneNumberOptions(rawPhone); + const jidWith = normalizedWith.replace(/^\+/, '') + '@s.whatsapp.net'; + const jidWithout = normalizedWithout.replace(/^\+/, '') + '@s.whatsapp.net'; - // --- 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 + const ts = phoneNumbersWithTimestamp.get(rawPhone); + if (!ts) { + this.logger.warn(`Timestamp não encontrado para ${rawPhone}`); + throw new Error(`Timestamp não encontrado para ${rawPhone}`); + } + + // 2) Buscar ou inserir Contact (agora incluindo identifier) + let contact: { id: number; phone_number: string }; + { + const selectContact = ` + SELECT id, phone_number + FROM contacts + WHERE account_id = $1 + AND ( + phone_number = $2 + OR phone_number = $3 + OR identifier = $4 + OR identifier = $5 + ) + LIMIT 1 + `; + const res = await pgClient.query(selectContact, [ + provider.accountId, + normalizedWith, + normalizedWithout, + jidWith, + jidWithout + ]); + if (res.rowCount) { + contact = res.rows[0]; + this.logger.verbose(`Contato existente: ${JSON.stringify(contact)}`); + } else { + const insertContact = ` + INSERT INTO contacts + (name, phone_number, account_id, identifier, created_at, updated_at) + VALUES + ( + REPLACE($2, '+', ''), + $2, + $1, + $5, -- agora é $5 + to_timestamp($3), + to_timestamp($4) + ) + RETURNING id, phone_number + `; + const insertRes = await pgClient.query(insertContact, [ + provider.accountId, // $1 + normalizedWith, // $2 + ts.first, // $3 + ts.last, // $4 + jidWith // $5 ]); - 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; - } + contact = insertRes.rows[0]; - // --- 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 + + this.logger.verbose(`Contato inserido: ${JSON.stringify(contact)}`); + } + } + + // 3) Buscar ou inserir ContactInbox + let contactInboxId: number; + { + const selectCi = ` + SELECT id + FROM contact_inboxes + WHERE contact_id = $1 + AND inbox_id = $2 + LIMIT 1 + `; + const ciRes = await pgClient.query(selectCi, [ + contact.id, + inbox.id + ]); + if (ciRes.rowCount) { + contactInboxId = ciRes.rows[0].id; + this.logger.verbose(`Contact_inbox existente: ${contactInboxId}`); + } else { + const insertCi = ` + INSERT INTO contact_inboxes + (contact_id, inbox_id, source_id, created_at, updated_at) + VALUES + ($1, $2, gen_random_uuid(), NOW(), NOW()) + RETURNING id `; - 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) - 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}`); - } - - 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; + const insertRes = await pgClient.query(insertCi, [ + contact.id, + inbox.id + ]); + contactInboxId = insertRes.rows[0].id; + this.logger.verbose(`Contact_inbox inserido: ${contactInboxId}`); } + } - // --- 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)}`); + // 4) Buscar ou inserir Conversation + let conversationId: number; + { + const selectConv = ` + SELECT id + FROM conversations + WHERE account_id = $1 + AND inbox_id = $2 + AND contact_id = $3 + LIMIT 1 + `; + const convRes = await pgClient.query(selectConv, [ + provider.accountId, + inbox.id, + contact.id + ]); + if (convRes.rowCount) { + conversationId = convRes.rows[0].id; + this.logger.verbose(`Conversa existente: ${conversationId}`); + } else { + const insertConv = ` + 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 + `; + const insertRes = await pgClient.query(insertConv, [ + provider.accountId, + inbox.id, + contact.id, + contactInboxId + ]); + conversationId = insertRes.rows[0].id; + this.logger.verbose(`Conversa inserida: ${conversationId}`); + } + } - } // fim for - } catch (error) { - this.logger.error(`Erro geral no processamento: ${error}`); - throw error; // Propaga o erro para que o método pare + resultMap.set(rawPhone, { + phone_number: normalizedWith, + contact_id: String(contact.id), + conversation_id: String(conversationId) + }); } + return resultMap; } @@ -567,8 +584,6 @@ class ChatwootImport { - - @@ -655,8 +670,8 @@ class ChatwootImport { case 'documentWithCaptionMessage': return `__`; case 'templateMessage': @@ -681,8 +696,12 @@ class ChatwootImport { } } - public sliceIntoChunks(arr: any[], chunkSize: number) { - return arr.splice(0, chunkSize); + public sliceIntoChunks(arr: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += chunkSize) { + chunks.push(arr.slice(i, i + chunkSize)); + } + return chunks; } public isGroup(remoteJid: string) { diff --git a/src/api/integrations/storage/s3/libs/minio.server.ts b/src/api/integrations/storage/s3/libs/minio.server.ts index 5a66305c..19128af9 100644 --- a/src/api/integrations/storage/s3/libs/minio.server.ts +++ b/src/api/integrations/storage/s3/libs/minio.server.ts @@ -40,22 +40,38 @@ const bucketExists = async () => { }; const setBucketPolicy = async () => { - if (minioClient) { - const policy = { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: '*', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${bucketName}/*`], - }, - ], - }; + if (!minioClient) return; + + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }; + + try { await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy)); + console.log(`[S3 Service] Bucket policy aplicada em ${bucketName}`); + } catch (err: any) { + // MinIO não implementa esse endpoint + if (err.code === 'NotImplemented') { + console.warn( + `[S3 Service] setBucketPolicy não suportado por este endpoint, ignorando (bucket=${bucketName})` + ); + } else { + // qualquer outro erro real, relança + console.error('[S3 Service] Erro ao aplicar bucket policy', err); + throw err; + } } }; + const createBucket = async () => { if (minioClient) { try { diff --git a/src/api/services/cache.service.ts b/src/api/services/cache.service.ts index e2f96d4b..d884362d 100644 --- a/src/api/services/cache.service.ts +++ b/src/api/services/cache.service.ts @@ -42,7 +42,10 @@ export class CacheService { if (!this.cache) { return; } - this.cache.set(key, value, ttl); + + const effectiveTtl = ttl ?? (2 * 60 * 60); + + this.cache.set(key, value, effectiveTtl); } public async hSet(key: string, field: string, value: any) { @@ -69,6 +72,20 @@ export class CacheService { if (!this.cache) { return; } + // Verifica se a chave é realmente uma string + if (typeof key !== 'string') { + this.logger.error( + `Invalid cache key type: expected string but received ${typeof key}. Key content: ${JSON.stringify(key)}. Stack trace: ${new Error().stack}` + ); + } else { + // Opcional: se a chave contiver quebras de linha, pode ser um sinal de que há um vCard em vez de um simples identificador + if (key.includes('\n')) { + this.logger.error( + `Invalid cache key format (contains newline characters): ${key}. Stack trace: ${new Error().stack}` + ); + } + } + // Chama a implementação real do delete return this.cache.delete(key); }