diff --git a/CHANGELOG.md b/CHANGELOG.md index eb524c2d..5bc0bd6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Sync lost messages on chatwoot * Set the maximum number of listeners that can be registered for events +* Now is possible send medias with form-data ### Fixed diff --git a/Docker/swarm/evolution_api_v2.yaml b/Docker/swarm/evolution_api_v2.yaml index 679e7368..41c2daa2 100644 --- a/Docker/swarm/evolution_api_v2.yaml +++ b/Docker/swarm/evolution_api_v2.yaml @@ -2,7 +2,7 @@ version: "3.7" services: evolution_v2: - image: atendai/evolution-api:v2.0.10 + image: atendai/evolution-api:v2.1.2 volumes: - evolution_instances:/evolution/instances networks: diff --git a/package.json b/package.json index 9ab52f81..ed141449 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "long": "^5.2.3", "mime": "^3.0.0", "minio": "^8.0.1", + "multer": "^1.4.5-lts.1", "node-cache": "^5.1.2", "node-cron": "^3.0.3", "node-windows": "^1.0.0-beta.8", diff --git a/src/@types/express.d.ts b/src/@types/express.d.ts new file mode 100644 index 00000000..4df23f80 --- /dev/null +++ b/src/@types/express.d.ts @@ -0,0 +1,9 @@ +import { Multer } from 'multer'; + +declare global { + namespace Express { + interface Request { + file?: Multer.File; + } + } +} diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 6a286cb8..0d6ea33e 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -28,27 +28,27 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].textMessage(data); } - public async sendMedia({ instanceName }: InstanceDto, data: SendMediaDto) { + public async sendMedia({ instanceName }: InstanceDto, data: SendMediaDto, file?: any) { if (isBase64(data?.media) && !data?.fileName && data?.mediatype === 'document') { throw new BadRequestException('For base64 the file name must be informed.'); } - if (isURL(data?.media) || isBase64(data?.media)) { - return await this.waMonitor.waInstances[instanceName].mediaMessage(data); + if (file || isURL(data?.media) || isBase64(data?.media)) { + return await this.waMonitor.waInstances[instanceName].mediaMessage(data, file); } throw new BadRequestException('Owned media must be a url or base64'); } - public async sendSticker({ instanceName }: InstanceDto, data: SendStickerDto) { - if (isURL(data.sticker) || isBase64(data.sticker)) { - return await this.waMonitor.waInstances[instanceName].mediaSticker(data); + public async sendSticker({ instanceName }: InstanceDto, data: SendStickerDto, file?: any) { + if (file || isURL(data.sticker) || isBase64(data.sticker)) { + return await this.waMonitor.waInstances[instanceName].mediaSticker(data, file); } throw new BadRequestException('Owned media must be a url or base64'); } - public async sendWhatsAppAudio({ instanceName }: InstanceDto, data: SendAudioDto) { - if (isURL(data.audio) || isBase64(data.audio)) { - return await this.waMonitor.waInstances[instanceName].audioWhatsapp(data); + public async sendWhatsAppAudio({ instanceName }: InstanceDto, data: SendAudioDto, file?: any) { + if (file || isURL(data.audio) || isBase64(data.audio)) { + return await this.waMonitor.waInstances[instanceName].audioWhatsapp(data, file); } throw new BadRequestException('Owned media must be a url or base64'); } @@ -80,7 +80,7 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].pollMessage(data); } - public async sendStatus({ instanceName }: InstanceDto, data: SendStatusDto) { - return await this.waMonitor.waInstances[instanceName].statusMessage(data); + public async sendStatus({ instanceName }: InstanceDto, data: SendStatusDto, file?: any) { + return await this.waMonitor.waInstances[instanceName].statusMessage(data, file); } } diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index 1c61e746..d16fbd55 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -7,6 +7,7 @@ import { ChannelStartupService } from '@api/services/channel.service'; import { Events, wa } from '@api/types/wa.types'; import { Chatwoot, ConfigService, Openai } from '@config/env.config'; import { BadRequestException, InternalServerErrorException } from '@exceptions'; +import { deleteTempFile, getTempFile } from '@utils/getTempFile'; import { isURL } from 'class-validator'; import EventEmitter2 from 'eventemitter2'; import mime from 'mime'; @@ -164,7 +165,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.key.fromMe ? '' : messageRaw.key.fromMe == null ? '' : received.pushName, profilePicUrl: received.profilePicUrl, }); } @@ -433,11 +434,14 @@ export class EvolutionStartupService extends ChannelStartupService { } } - public async mediaMessage(data: SendMediaDto, isIntegration = false) { - const message = await this.prepareMediaMessage(data); + public async mediaMessage(data: SendMediaDto, file?: any, isIntegration = false) { + const mediaData: SendMediaDto = { ...data }; - console.log('message', message); - return await this.sendMessageWithTyping( + if (file) mediaData.media = await getTempFile(file, this.instanceId); + + const message = await this.prepareMediaMessage(mediaData); + + const mediaSent = await this.sendMessageWithTyping( data.number, { ...message }, { @@ -450,6 +454,10 @@ export class EvolutionStartupService extends ChannelStartupService { }, isIntegration, ); + + if (file) await deleteTempFile(file, this.instanceId); + + return mediaSent; } public async processAudio(audio: string, number: string) { @@ -475,10 +483,14 @@ export class EvolutionStartupService extends ChannelStartupService { return prepareMedia; } - public async audioWhatsapp(data: SendAudioDto, isIntegration = false) { - const message = await this.processAudio(data.audio, data.number); + public async audioWhatsapp(data: SendAudioDto, file?: any, isIntegration = false) { + const mediaData: SendAudioDto = { ...data }; - return await this.sendMessageWithTyping( + if (file) mediaData.audio = await getTempFile(file, this.instanceId); + + const message = await this.processAudio(mediaData.audio, data.number); + + const audioSent = await this.sendMessageWithTyping( data.number, { ...message }, { @@ -491,6 +503,10 @@ export class EvolutionStartupService extends ChannelStartupService { }, isIntegration, ); + + if (file) await deleteTempFile(file, this.instanceId); + + return audioSent; } public async buttonMessage() { diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index a45e2cde..7eaf39ec 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -22,6 +22,7 @@ import { ChannelStartupService } from '@api/services/channel.service'; import { Events, wa } from '@api/types/wa.types'; import { Chatwoot, ConfigService, Database, Openai, S3, WaBusiness } from '@config/env.config'; import { BadRequestException, InternalServerErrorException } from '@exceptions'; +import { deleteTempFile, getTempFile } from '@utils/getTempFile'; import axios from 'axios'; import { arrayUnique, isURL } from 'class-validator'; import EventEmitter2 from 'eventemitter2'; @@ -1026,10 +1027,14 @@ export class BusinessStartupService extends ChannelStartupService { } } - public async mediaMessage(data: SendMediaDto, isIntegration = false) { - const message = await this.prepareMediaMessage(data); + public async mediaMessage(data: SendMediaDto, file?: any, isIntegration = false) { + const mediaData: SendMediaDto = { ...data }; - return await this.sendMessageWithTyping( + if (file) mediaData.media = await getTempFile(file, this.instanceId); + + const message = await this.prepareMediaMessage(mediaData); + + const mediaSent = await this.sendMessageWithTyping( data.number, { ...message }, { @@ -1042,6 +1047,10 @@ export class BusinessStartupService extends ChannelStartupService { }, isIntegration, ); + + if (file) await deleteTempFile(file, this.instanceId); + + return mediaSent; } public async processAudio(audio: string, number: string) { @@ -1072,10 +1081,14 @@ export class BusinessStartupService extends ChannelStartupService { return prepareMedia; } - public async audioWhatsapp(data: SendAudioDto, isIntegration = false) { - const message = await this.processAudio(data.audio, data.number); + public async audioWhatsapp(data: SendAudioDto, file?: any, isIntegration = false) { + const mediaData: SendAudioDto = { ...data }; - return await this.sendMessageWithTyping( + if (file) mediaData.audio = await getTempFile(file, this.instanceId); + + const message = await this.processAudio(mediaData.audio, data.number); + + const audioSent = await this.sendMessageWithTyping( data.number, { ...message }, { @@ -1088,6 +1101,10 @@ export class BusinessStartupService extends ChannelStartupService { }, isIntegration, ); + + if (file) await deleteTempFile(file, this.instanceId); + + return audioSent; } public async buttonMessage(data: SendButtonDto) { diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 3c0ab6d8..df675633 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -70,6 +70,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } import ffmpegPath from '@ffmpeg-installer/ffmpeg'; import { Boom } from '@hapi/boom'; import { Instance } from '@prisma/client'; +import { deleteTempFile, getTempFile } from '@utils/getTempFile'; import { makeProxyAgent } from '@utils/makeProxyAgent'; import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache'; import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma'; @@ -2266,12 +2267,20 @@ export class BaileysStartupService extends ChannelStartupService { throw new BadRequestException('Type not found'); } - public async statusMessage(data: SendStatusDto) { - const status = await this.formatStatusMessage(data); + public async statusMessage(data: SendStatusDto, file?: any) { + const mediaData: SendStatusDto = { ...data }; - return await this.sendMessageWithTyping('status@broadcast', { + if (file) mediaData.content = await getTempFile(file, this.instanceId); + + const status = await this.formatStatusMessage(mediaData); + + const statusSent = await this.sendMessageWithTyping('status@broadcast', { status, }); + + if (file) await deleteTempFile(file, this.instanceId); + + return statusSent; } private async prepareMediaMessage(mediaMessage: MediaMessage) { @@ -2395,7 +2404,11 @@ export class BaileysStartupService extends ChannelStartupService { } } - public async mediaSticker(data: SendStickerDto) { + public async mediaSticker(data: SendStickerDto, file?: any) { + const mediaData: SendStickerDto = { ...data }; + + if (file) mediaData.sticker = await getTempFile(file, this.instanceId); + const convert = await this.convertToWebP(data.sticker); const gifPlayback = data.sticker.includes('.gif'); const result = await this.sendMessageWithTyping( @@ -2413,13 +2426,19 @@ export class BaileysStartupService extends ChannelStartupService { }, ); + if (file) await deleteTempFile(file, this.instanceId); + return result; } - public async mediaMessage(data: SendMediaDto, isIntegration = false) { - const generate = await this.prepareMediaMessage(data); + public async mediaMessage(data: SendMediaDto, file?: any, isIntegration = false) { + const mediaData: SendMediaDto = { ...data }; - return await this.sendMessageWithTyping( + if (file) mediaData.media = await getTempFile(file, this.instanceId); + + const generate = await this.prepareMediaMessage(mediaData); + + const mediaSent = await this.sendMessageWithTyping( data.number, { ...generate.message }, { @@ -2431,6 +2450,10 @@ export class BaileysStartupService extends ChannelStartupService { }, isIntegration, ); + + if (file) await deleteTempFile(file, this.instanceId); + + return mediaSent; } public async processAudioMp4(audio: string) { @@ -2534,13 +2557,17 @@ export class BaileysStartupService extends ChannelStartupService { }); } - public async audioWhatsapp(data: SendAudioDto, isIntegration = false) { + public async audioWhatsapp(data: SendAudioDto, file?: any, isIntegration = false) { + const mediaData: SendAudioDto = { ...data }; + + if (file) mediaData.audio = await getTempFile(file, this.instanceId); + if (!data?.encoding && data?.encoding !== false) { data.encoding = true; } if (data?.encoding) { - const convert = await this.processAudio(data.audio); + const convert = await this.processAudio(mediaData.audio); if (Buffer.isBuffer(convert)) { const result = this.sendMessageWithTyping( @@ -2554,6 +2581,8 @@ export class BaileysStartupService extends ChannelStartupService { isIntegration, ); + if (file) await deleteTempFile(file, this.instanceId); + return result; } else { throw new InternalServerErrorException('Failed to convert audio'); diff --git a/src/api/integrations/storage/s3/libs/minio.server.ts b/src/api/integrations/storage/s3/libs/minio.server.ts index 70869cd8..5a66305c 100644 --- a/src/api/integrations/storage/s3/libs/minio.server.ts +++ b/src/api/integrations/storage/s3/libs/minio.server.ts @@ -105,4 +105,35 @@ const getObjectUrl = async (fileName: string, expiry?: number) => { } }; -export { BUCKET, getObjectUrl, uploadFile }; +const uploadTempFile = async ( + folder: string, + fileName: string, + file: Buffer | Transform | Readable, + size: number, + metadata: Metadata, +) => { + if (minioClient) { + const objectName = join(folder, fileName); + try { + metadata['custom-header-application'] = 'evolution-api'; + return await minioClient.putObject(bucketName, objectName, file, size, metadata); + } catch (error) { + logger.error(error); + return error; + } + } +}; + +const deleteFile = async (folder: string, fileName: string) => { + if (minioClient) { + const objectName = join(folder, fileName); + try { + return await minioClient.removeObject(bucketName, objectName); + } catch (error) { + logger.error(error); + return error; + } + } +}; + +export { BUCKET, deleteFile, getObjectUrl, uploadFile, uploadTempFile }; diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index a61230f6..06f70ad7 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -29,9 +29,12 @@ import { textMessageSchema, } from '@validate/validate.schema'; import { RequestHandler, Router } from 'express'; +import multer from 'multer'; import { HttpStatus } from './index.router'; +const upload = multer({ storage: multer.memoryStorage() }); + export class MessageRouter extends RouterBroker { constructor(...guards: RequestHandler[]) { super(); @@ -56,43 +59,51 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) - .post(this.routerPath('sendMedia'), ...guards, async (req, res) => { + .post(this.routerPath('sendMedia'), ...guards, upload.single('file'), async (req, res) => { + const bodyData = req.body; + const response = await this.dataValidate({ request: req, schema: mediaMessageSchema, ClassRef: SendMediaDto, - execute: (instance, data) => sendMessageController.sendMedia(instance, data), + execute: (instance) => sendMessageController.sendMedia(instance, bodyData, req.file as any), }); return res.status(HttpStatus.CREATED).json(response); }) - .post(this.routerPath('sendWhatsAppAudio'), ...guards, async (req, res) => { + .post(this.routerPath('sendWhatsAppAudio'), ...guards, upload.single('file'), async (req, res) => { + const bodyData = req.body; + const response = await this.dataValidate({ request: req, schema: audioMessageSchema, ClassRef: SendMediaDto, - execute: (instance, data) => sendMessageController.sendWhatsAppAudio(instance, data), + execute: (instance) => sendMessageController.sendWhatsAppAudio(instance, bodyData, req.file as any), }); return res.status(HttpStatus.CREATED).json(response); }) // TODO: Revisar funcionamento do envio de Status - .post(this.routerPath('sendStatus'), ...guards, async (req, res) => { + .post(this.routerPath('sendStatus'), ...guards, upload.single('file'), async (req, res) => { + const bodyData = req.body; + const response = await this.dataValidate({ request: req, schema: statusMessageSchema, ClassRef: SendStatusDto, - execute: (instance, data) => sendMessageController.sendStatus(instance, data), + execute: (instance) => sendMessageController.sendStatus(instance, bodyData, req.file as any), }); return res.status(HttpStatus.CREATED).json(response); }) - .post(this.routerPath('sendSticker'), ...guards, async (req, res) => { + .post(this.routerPath('sendSticker'), ...guards, upload.single('file'), async (req, res) => { + const bodyData = req.body; + const response = await this.dataValidate({ request: req, schema: stickerMessageSchema, ClassRef: SendStickerDto, - execute: (instance, data) => sendMessageController.sendSticker(instance, data), + execute: (instance) => sendMessageController.sendSticker(instance, bodyData, req.file as any), }); return res.status(HttpStatus.CREATED).json(response); diff --git a/src/utils/getTempFile.ts b/src/utils/getTempFile.ts new file mode 100644 index 00000000..220569f2 --- /dev/null +++ b/src/utils/getTempFile.ts @@ -0,0 +1,34 @@ +import * as s3Service from '@api/integrations/storage/s3/libs/minio.server'; +import mime from 'mime-types'; + +export const getTempFile = async (file: any, instanceId: string): Promise => { + const fileName = file.originalname; + const mimetype = mime.lookup(fileName) || 'application/octet-stream'; + const folder = `${process.env.S3_BUCKET}/${instanceId}/temp`; + const fileUrl = `https://${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET}/${folder}/${fileName}`; + + if (!process.env.S3_ENABLED || process.env.S3_ENABLED !== 'true') { + return file.buffer.toString('base64'); + } + + try { + if (file.buffer) { + await s3Service.uploadTempFile(folder, fileName, file.buffer, file.size, { + 'Content-Type': mimetype, + }); + } + } catch (error) { + console.error(`Erro ao fazer upload do arquivo ${fileName}:`, error); + } + + return fileUrl; +}; + +export const deleteTempFile = async (file: any, instanceId: string): Promise => { + if (!process.env.S3_ENABLED) return; + + const fileName = file.originalname; + const folder = `${process.env.S3_BUCKET}/${instanceId}/temp`; + + await s3Service.deleteFile(folder, fileName); +}; diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index 6705be5e..6ec1ab04 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -108,7 +108,7 @@ export const mediaMessageSchema: JSONSchema7 = { }, }, }, - required: ['number', 'mediatype', 'media'], + required: ['number', 'mediatype'], }; export const audioMessageSchema: JSONSchema7 = { @@ -134,7 +134,7 @@ export const audioMessageSchema: JSONSchema7 = { }, }, }, - required: ['number', 'audio'], + required: ['number'], }; export const statusMessageSchema: JSONSchema7 = { @@ -158,7 +158,7 @@ export const statusMessageSchema: JSONSchema7 = { }, allContacts: { type: 'boolean', enum: [true, false] }, }, - required: ['type', 'content'], + required: ['type'], }; export const stickerMessageSchema: JSONSchema7 = { @@ -184,7 +184,7 @@ export const stickerMessageSchema: JSONSchema7 = { }, }, }, - required: ['number', 'sticker'], + required: ['number'], }; export const locationMessageSchema: JSONSchema7 = {