diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index e5acf65a..3c80d8a6 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -1,17 +1,28 @@ -import { MediaMessage, Options, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; -import { ProviderFiles } from '@api/provider/sessions'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { + MediaMessage, + Options, + SendAudioDto, + SendButtonsDto, + SendMediaDto, + SendTextDto, +} from '@api/dto/sendMessage.dto'; +import * as s3Service from '@api/integrations/storage/s3/libs/minio.server'; import { PrismaRepository } from '@api/repository/repository.service'; import { chatbotController } from '@api/server.module'; import { CacheService } from '@api/services/cache.service'; import { ChannelStartupService } from '@api/services/channel.service'; import { Events, wa } from '@api/types/wa.types'; -import { Chatwoot, ConfigService, Openai } from '@config/env.config'; +import { Chatwoot, ConfigService, Openai, S3 } from '@config/env.config'; import { BadRequestException, InternalServerErrorException } from '@exceptions'; import { createJid } from '@utils/createJid'; -import { status } from '@utils/renderStatus'; -import { isURL } from 'class-validator'; +import axios from 'axios'; +import { isBase64, isURL } from 'class-validator'; import EventEmitter2 from 'eventemitter2'; +import FormData from 'form-data'; +import mime from 'mime'; import mimeTypes from 'mime-types'; +import { join } from 'path'; import { v4 } from 'uuid'; export class EvolutionStartupService extends ChannelStartupService { @@ -21,8 +32,6 @@ export class EvolutionStartupService extends ChannelStartupService { public readonly prismaRepository: PrismaRepository, public readonly cache: CacheService, public readonly chatwootCache: CacheService, - public readonly baileysCache: CacheService, - private readonly providerFiles: ProviderFiles, ) { super(configService, eventEmitter, prismaRepository, chatwootCache); @@ -57,6 +66,32 @@ export class EvolutionStartupService extends ChannelStartupService { await this.closeClient(); } + public setInstance(instance: InstanceDto) { + this.logger.setInstance(instance.instanceId); + + this.instance.name = instance.instanceName; + this.instance.id = instance.instanceId; + this.instance.integration = instance.integration; + this.instance.number = instance.number; + this.instance.token = instance.token; + this.instance.businessId = instance.businessId; + + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + this.chatwootService.eventWhatsapp( + Events.STATUS_INSTANCE, + { + instanceName: this.instance.name, + instanceId: this.instance.id, + integration: instance.integration, + }, + { + instance: this.instance.name, + status: 'created', + }, + ); + } + } + public async profilePicture(number: string) { const jid = createJid(number); @@ -79,11 +114,12 @@ export class EvolutionStartupService extends ChannelStartupService { } public async connectToWhatsapp(data?: any): Promise { - if (!data) return; - - try { + if (!data) { this.loadChatwoot(); + return; + } + try { this.eventHandler(data); } catch (error) { this.logger.error(error); @@ -100,6 +136,7 @@ export class EvolutionStartupService extends ChannelStartupService { id: received.key.id || v4(), remoteJid: received.key.remoteJid, fromMe: received.key.fromMe, + profilePicUrl: received.profilePicUrl, }; messageRaw = { key, @@ -111,7 +148,9 @@ export class EvolutionStartupService extends ChannelStartupService { instanceId: this.instanceId, }; - if (this.configService.get('OPENAI').ENABLED) { + const isAudio = received?.message?.audioMessage; + + if (this.configService.get('OPENAI').ENABLED && isAudio) { const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({ where: { instanceId: this.instanceId, @@ -166,7 +205,7 @@ export class EvolutionStartupService extends ChannelStartupService { await this.updateContact({ remoteJid: messageRaw.key.remoteJid, - pushName: messageRaw.key.fromMe ? '' : messageRaw.key.fromMe == null ? '' : received.pushName, + pushName: messageRaw.pushName, profilePicUrl: received.profilePicUrl, }); } @@ -176,47 +215,47 @@ export class EvolutionStartupService extends ChannelStartupService { } private async updateContact(data: { remoteJid: string; pushName?: string; profilePicUrl?: string }) { - const contact = await this.prismaRepository.contact.findFirst({ - where: { instanceId: this.instanceId, remoteJid: data.remoteJid }, - }); + const contactRaw: any = { + remoteJid: data.remoteJid, + pushName: data?.pushName, + instanceId: this.instanceId, + profilePicUrl: data?.profilePicUrl, + }; - if (contact) { - const contactRaw: any = { + const existingContact = await this.prismaRepository.contact.findFirst({ + where: { remoteJid: data.remoteJid, - pushName: data?.pushName, instanceId: this.instanceId, - profilePicUrl: data?.profilePicUrl, - }; - - this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); - - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { - await this.chatwootService.eventWhatsapp( - Events.CONTACTS_UPDATE, - { instanceName: this.instance.name, instanceId: this.instanceId }, - contactRaw, - ); - } + }, + }); + if (existingContact) { await this.prismaRepository.contact.updateMany({ - where: { remoteJid: contact.remoteJid, instanceId: this.instanceId }, + where: { + remoteJid: data.remoteJid, + instanceId: this.instanceId, + }, + data: contactRaw, + }); + } else { + await this.prismaRepository.contact.create({ data: contactRaw, }); - return; } - const contactRaw: any = { - remoteJid: data.remoteJid, - pushName: data?.pushName, - instanceId: this.instanceId, - profilePicUrl: data?.profilePicUrl, - }; - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); - await this.prismaRepository.contact.create({ - data: contactRaw, - }); + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + await this.chatwootService.eventWhatsapp( + Events.CONTACTS_UPDATE, + { + instanceName: this.instance.name, + instanceId: this.instanceId, + integration: this.instance.integration, + }, + contactRaw, + ); + } const chat = await this.prismaRepository.chat.findFirst({ where: { instanceId: this.instanceId, remoteJid: data.remoteJid }, @@ -248,7 +287,13 @@ export class EvolutionStartupService extends ChannelStartupService { }); } - protected async sendMessageWithTyping(number: string, message: any, options?: Options, isIntegration = false) { + protected async sendMessageWithTyping( + number: string, + message: any, + options?: Options, + file?: any, + isIntegration = false, + ) { try { let quoted: any; let webhookUrl: any; @@ -273,64 +318,187 @@ export class EvolutionStartupService extends ChannelStartupService { webhookUrl = options.webhookUrl; } + let audioFile; + const messageId = v4(); - let messageRaw: any = { - key: { fromMe: true, id: messageId, remoteJid: number }, - messageTimestamp: Math.round(new Date().getTime() / 1000), - webhookUrl, - source: 'unknown', - instanceId: this.instanceId, - status: status[1], - }; + let messageRaw: any; if (message?.mediaType === 'image') { messageRaw = { - ...messageRaw, + key: { fromMe: true, id: messageId, remoteJid: number }, message: { - mediaUrl: message.media, + base64: isBase64(message.media) ? message.media : undefined, + mediaUrl: isURL(message.media) ? message.media : undefined, quoted, }, messageType: 'imageMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, }; } else if (message?.mediaType === 'video') { messageRaw = { - ...messageRaw, + key: { fromMe: true, id: messageId, remoteJid: number }, message: { - mediaUrl: message.media, + base64: isBase64(message.media) ? message.media : undefined, + mediaUrl: isURL(message.media) ? message.media : undefined, quoted, }, messageType: 'videoMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, }; } else if (message?.mediaType === 'audio') { messageRaw = { - ...messageRaw, + key: { fromMe: true, id: messageId, remoteJid: number }, message: { - mediaUrl: message.media, + base64: isBase64(message.media) ? message.media : undefined, + mediaUrl: isURL(message.media) ? message.media : undefined, quoted, }, messageType: 'audioMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, + }; + + const buffer = Buffer.from(message.media, 'base64'); + audioFile = { + buffer, + mimetype: 'audio/mp4', + originalname: `${messageId}.mp4`, }; } else if (message?.mediaType === 'document') { messageRaw = { - ...messageRaw, + key: { fromMe: true, id: messageId, remoteJid: number }, message: { - mediaUrl: message.media, + base64: isBase64(message.media) ? message.media : undefined, + mediaUrl: isURL(message.media) ? message.media : undefined, quoted, }, messageType: 'documentMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, + }; + } else if (message.buttonMessage) { + messageRaw = { + key: { fromMe: true, id: messageId, remoteJid: number }, + message: { + ...message.buttonMessage, + buttons: message.buttonMessage.buttons, + footer: message.buttonMessage.footer, + body: message.buttonMessage.body, + quoted, + }, + messageType: 'buttonMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, + }; + } else if (message.listMessage) { + messageRaw = { + key: { fromMe: true, id: messageId, remoteJid: number }, + message: { + ...message.listMessage, + quoted, + }, + messageType: 'listMessage', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, }; } else { messageRaw = { - ...messageRaw, + key: { fromMe: true, id: messageId, remoteJid: number }, message: { ...message, quoted, }, messageType: 'conversation', + messageTimestamp: Math.round(new Date().getTime() / 1000), + webhookUrl, + source: 'unknown', + instanceId: this.instanceId, }; } + if (messageRaw.message.contextInfo) { + messageRaw.contextInfo = { + ...messageRaw.message.contextInfo, + }; + } + + if (messageRaw.contextInfo?.stanzaId) { + const key: any = { + id: messageRaw.contextInfo.stanzaId, + }; + + const findMessage = await this.prismaRepository.message.findFirst({ + where: { + instanceId: this.instanceId, + key, + }, + }); + + if (findMessage) { + messageRaw.contextInfo.quotedMessage = findMessage.message; + } + } + + const base64 = messageRaw.message.base64; + delete messageRaw.message.base64; + + if (base64 || file || audioFile) { + if (this.configService.get('S3').ENABLE) { + try { + const fileBuffer = audioFile?.buffer || file?.buffer; + const buffer = base64 ? Buffer.from(base64, 'base64') : fileBuffer; + + let mediaType: string; + let mimetype = audioFile?.mimetype || file.mimetype; + + if (messageRaw.messageType === 'documentMessage') { + mediaType = 'document'; + mimetype = !mimetype ? 'application/pdf' : mimetype; + } else if (messageRaw.messageType === 'imageMessage') { + mediaType = 'image'; + mimetype = !mimetype ? 'image/png' : mimetype; + } else if (messageRaw.messageType === 'audioMessage') { + mediaType = 'audio'; + mimetype = !mimetype ? 'audio/mp4' : mimetype; + } else if (messageRaw.messageType === 'videoMessage') { + mediaType = 'video'; + mimetype = !mimetype ? 'video/mp4' : mimetype; + } + + const fileName = `${messageRaw.key.id}.${mimetype.split('/')[1]}`; + + const size = buffer.byteLength; + + const fullName = join(`${this.instance.id}`, messageRaw.key.remoteJid, mediaType, fileName); + + await s3Service.uploadFile(fullName, buffer, size, { + 'Content-Type': mimetype, + }); + + const mediaUrl = await s3Service.getObjectUrl(fullName); + + messageRaw.message.mediaUrl = mediaUrl; + } catch (error) { + this.logger.error(['Error on upload file to minio', error?.message, error?.stack]); + } + } + } + this.logger.log(messageRaw); this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); @@ -376,6 +544,7 @@ export class EvolutionStartupService extends ChannelStartupService { mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, + null, isIntegration, ); return res; @@ -440,33 +609,78 @@ export class EvolutionStartupService extends ChannelStartupService { mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, + file, isIntegration, ); return mediaSent; } - public async processAudio(audio: string, number: string) { + public async processAudio(audio: string, number: string, file: any) { number = number.replace(/\D/g, ''); const hash = `${number}-${new Date().getTime()}`; - let mimetype: string | false; + if (process.env.API_AUDIO_CONVERTER) { + try { + this.logger.verbose('Using audio converter API'); + const formData = new FormData(); - const prepareMedia: any = { - fileName: `${hash}.mp4`, - mediaType: 'audio', - media: audio, - }; + if (file) { + formData.append('file', file.buffer, { + filename: file.originalname, + contentType: file.mimetype, + }); + } else if (isURL(audio)) { + formData.append('url', audio); + } else { + formData.append('base64', audio); + } + + formData.append('format', 'mp4'); - if (isURL(audio)) { - mimetype = mimeTypes.lookup(audio); + const response = await axios.post(process.env.API_AUDIO_CONVERTER, formData, { + headers: { + ...formData.getHeaders(), + apikey: process.env.API_AUDIO_CONVERTER_KEY, + }, + }); + + if (!response?.data?.audio) { + throw new InternalServerErrorException('Failed to convert audio'); + } + + const prepareMedia: any = { + fileName: `${hash}.mp4`, + mediaType: 'audio', + media: response?.data?.audio, + mimetype: 'audio/mpeg', + }; + + return prepareMedia; + } catch (error) { + this.logger.error(error?.response?.data || error); + throw new InternalServerErrorException(error?.response?.data?.message || error?.toString() || error); + } } else { - mimetype = mimeTypes.lookup(prepareMedia.fileName); - } + let mimetype: string; + + const prepareMedia: any = { + fileName: `${hash}.mp3`, + mediaType: 'audio', + media: audio, + mimetype: 'audio/mpeg', + }; + + if (isURL(audio)) { + mimetype = mime.getType(audio); + } else { + mimetype = mime.getType(prepareMedia.fileName); + } - prepareMedia.mimetype = mimetype; + prepareMedia.mimetype = mimetype; - return prepareMedia; + return prepareMedia; + } } public async audioWhatsapp(data: SendAudioDto, file?: any, isIntegration = false) { @@ -479,7 +693,7 @@ export class EvolutionStartupService extends ChannelStartupService { throw new Error('File or buffer is undefined.'); } - const message = await this.processAudio(mediaData.audio, data.number); + const message = await this.processAudio(mediaData.audio, data.number, file); const audioSent = await this.sendMessageWithTyping( data.number, @@ -492,14 +706,34 @@ export class EvolutionStartupService extends ChannelStartupService { mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, + file, isIntegration, ); return audioSent; } - public async buttonMessage() { - throw new BadRequestException('Method not available on Evolution Channel'); + public async buttonMessage(data: SendButtonsDto, isIntegration = false) { + return await this.sendMessageWithTyping( + data.number, + { + buttonMessage: { + title: data.title, + description: data.description, + footer: data.footer, + buttons: data.buttons, + }, + }, + { + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + mentionsEveryOne: data?.mentionsEveryOne, + mentioned: data?.mentioned, + }, + null, + isIntegration, + ); } public async locationMessage() { throw new BadRequestException('Method not available on Evolution Channel');