diff --git a/.gitignore b/.gitignore index 987f5c4e..a9ba1177 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ lerna-debug.log* !/instances/.gitkeep /test/ .env +/uploads \ No newline at end of file diff --git a/package.json b/package.json index edf291c8..14d90979 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "whatsapp-api", - "version": "1.3.3", + "version": "1.3.4", "description": "Rest api for communication with WhatsApp", "main": "./dist/src/main.js", "scripts": { @@ -57,7 +57,6 @@ "fluent-ffmpeg": "^2.1.3", "form-data": "^4.0.0", "hbs": "^4.2.0", - "join": "^3.0.0", "js-yaml": "^4.1.0", "jsonschema": "^1.4.1", "jsonwebtoken": "^9.0.2", diff --git a/prisma/migrations/20241026114039_codechat/migration.sql b/prisma/migrations/20241026114039_codechat/migration.sql new file mode 100644 index 00000000..dab7f8cf --- /dev/null +++ b/prisma/migrations/20241026114039_codechat/migration.sql @@ -0,0 +1,32 @@ +-- AlterTable +ALTER TABLE "ActivityLogs" ALTER COLUMN "dateTime" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Auth" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Chat" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Contact" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Instance" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Media" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "MessageUpdate" ALTER COLUMN "dateTime" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Typebot" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Webhook" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); diff --git a/src/validate/validate.schema.ts b/src/validate/validate.schema.ts index f9c32ca4..b6e65258 100644 --- a/src/validate/validate.schema.ts +++ b/src/validate/validate.schema.ts @@ -45,7 +45,6 @@ */ import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; -import { title } from 'process'; import { ulid } from 'ulid'; const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { @@ -101,6 +100,7 @@ const optionsSchema: JSONSchema7 = { }, quotedMessageId: { type: 'integer', description: 'Enter the message id' }, messageId: { type: 'string', description: 'Set your own id for the message.' }, + convertAudio: { type: 'boolean', description: 'Convert audio to ogg' }, }, }; diff --git a/src/whatsapp/controllers/sendMessage.controller.ts b/src/whatsapp/controllers/sendMessage.controller.ts index 018c1448..e22dc417 100644 --- a/src/whatsapp/controllers/sendMessage.controller.ts +++ b/src/whatsapp/controllers/sendMessage.controller.ts @@ -64,6 +64,11 @@ export class SendMessageController { if (isBase64(data?.mediaMessage?.media)) { throw new BadRequestException('Owned media must be a url'); } + if (data.mediaMessage.mediatype === 'document' && !data.mediaMessage?.fileName) { + throw new BadRequestException( + 'The "fileName" property must be provided for documents', + ); + } if (isURL(data?.mediaMessage?.media as string)) { return await this.waMonitor.waInstances.get(instanceName).mediaMessage(data); } @@ -72,7 +77,7 @@ export class SendMessageController { public async sendMediaFile( { instanceName }: InstanceDto, data: MediaFileDto, - file: Express.Multer.File, + fileName: string, ) { if (data?.delay && !isNumberString(data.delay)) { throw new BadRequestException('The "delay" property must have an integer.'); @@ -81,7 +86,7 @@ export class SendMessageController { } return await this.waMonitor.waInstances .get(instanceName) - .mediaFileMessage(data, file); + .mediaFileMessage(data, fileName); } public async sendWhatsAppAudio({ instanceName }: InstanceDto, data: SendAudioDto) { @@ -98,16 +103,19 @@ export class SendMessageController { public async sendWhatsAppAudioFile( { instanceName }: InstanceDto, data: AudioMessageFileDto, - file: Express.Multer.File, + fileName: string, ) { if (data?.delay && !isNumberString(data.delay)) { throw new BadRequestException('The "delay" property must have an integer.'); } else { data.delay = Number.parseInt(data?.delay as never); } + if (data?.convertAudio) { + data.convertAudio = data.convertAudio === 'true'; + } return await this.waMonitor.waInstances .get(instanceName) - .audioWhatsAppFile(data, file); + .audioWhatsAppFile(data, fileName); } public async sendLocation({ instanceName }: InstanceDto, data: SendLocationDto) { diff --git a/src/whatsapp/dto/sendMessage.dto.ts b/src/whatsapp/dto/sendMessage.dto.ts index 6b02530b..1349bfaf 100644 --- a/src/whatsapp/dto/sendMessage.dto.ts +++ b/src/whatsapp/dto/sendMessage.dto.ts @@ -47,6 +47,7 @@ export class Options { quotedMessageId?: number; messageId?: string; externalAttributes?: any; + convertAudio?: boolean; } class OptionsMessage { options: Options; @@ -69,8 +70,8 @@ export class MediaMessage { caption?: string; // for document fileName?: string; - // url or base64 media: string | Buffer; + extension?: string; } export class SendMediaDto extends Metadata { mediaMessage: MediaMessage; @@ -93,6 +94,7 @@ export class SendAudioDto extends Metadata { export class AudioMessageFileDto extends Metadata { delay: number; audio: Buffer; + convertAudio: boolean | string; } class LocationMessage { diff --git a/src/whatsapp/routers/sendMessage.router.ts b/src/whatsapp/routers/sendMessage.router.ts index 752788ed..87c1bf6e 100644 --- a/src/whatsapp/routers/sendMessage.router.ts +++ b/src/whatsapp/routers/sendMessage.router.ts @@ -69,6 +69,9 @@ import { isEmpty } from 'class-validator'; import { HttpStatus } from '../../app.module'; import { SendMessageController } from '../controllers/sendMessage.controller'; import { routerPath, dataValidate } from '../../validate/router.validate'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { ROOT_DIR } from '../../config/path.config'; function validateMedia(req: Request, _: Response, next: NextFunction) { if (!req?.file || req.file.fieldname !== 'attachment') { @@ -86,6 +89,11 @@ export function MessageRouter( sendMessageController: SendMessageController, ...guards: RequestHandler[] ) { + const uploadPath = join(ROOT_DIR, 'uploads'); + if (!existsSync(uploadPath)) { + mkdirSync(uploadPath); + } + const uploadFile = multer({ preservePath: true }); const router = Router() @@ -116,8 +124,10 @@ export function MessageRouter( const response = await dataValidate({ request: req, schema: mediaFileMessageSchema, - execute: (instance, data, file) => - sendMessageController.sendMediaFile(instance, data, file), + execute: (instance, data, file) => { + writeFileSync(join(uploadPath, file.originalname), file.buffer); + return sendMessageController.sendMediaFile(instance, data, file.originalname); + }, }); res.status(HttpStatus.CREATED).json(response); }, @@ -141,8 +151,14 @@ export function MessageRouter( const response = await dataValidate({ request: req, schema: audioFileMessageSchema, - execute: (instance, data, file) => - sendMessageController.sendWhatsAppAudioFile(instance, data, file), + execute: (instance, data, file) => { + writeFileSync(join(uploadPath, file.originalname), file.buffer); + return sendMessageController.sendWhatsAppAudioFile( + instance, + data, + file.originalname, + ); + }, }); res.status(HttpStatus.CREATED).json(response); }, diff --git a/src/whatsapp/services/whatsapp.service.ts b/src/whatsapp/services/whatsapp.service.ts index 0f7de22f..44c2b2a8 100644 --- a/src/whatsapp/services/whatsapp.service.ts +++ b/src/whatsapp/services/whatsapp.service.ts @@ -78,7 +78,7 @@ import { } from '../../config/env.config'; import { Logger } from '../../config/logger.config'; import { INSTANCE_DIR, ROOT_DIR } from '../../config/path.config'; -import { join } from 'path'; +import { join, normalize } from 'path'; import axios, { AxiosError } from 'axios'; import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; import qrcodeTerminal from 'qrcode-terminal'; @@ -141,7 +141,14 @@ import { isValidUlid } from '../../validate/ulid'; import sharp from 'sharp'; import ffmpeg from 'fluent-ffmpeg'; import { PassThrough, Stream } from 'stream'; -import { readFileSync } from 'fs'; +import { + accessSync, + constants, + existsSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'fs'; type InstanceQrCode = { count: number; @@ -1338,48 +1345,159 @@ export class WAStartupService { .inputOptions(['-ss', timeInSeconds]) .outputOptions('-frames:v 1') .outputFormat('image2pipe') - .on('start', (commandLine) => { - console.log('FFmpeg command: ' + commandLine); // Verificar o comando que está sendo executado + .on('start', () => { + thumbnailStream.on('data', (chunk) => chunks.push(chunk)); }) .on('error', (err) => { - reject(new Error(`Erro ao gerar thumbnail: ${err.message}`)); + reject(new Error(`Error generating thumbnail: ${err.message}`)); }) .on('end', () => { resolve(Buffer.concat(chunks)); }) .pipe(thumbnailStream, { end: true }); + }); + } + + private async convertAudioToWH( + inputPath: string, + format: { input?: string; to?: string } = { input: 'mp3', to: 'aac' }, + ) { + return new Promise((resolve, reject) => { + if (!existsSync(inputPath)) { + reject(new Error(`Input file not found: ${inputPath}`)); + return; + } - thumbnailStream.on('data', (chunk) => chunks.push(chunk)); + try { + accessSync(inputPath, constants.R_OK); + } catch (error) { + reject(new Error(`No read permissions for file: ${inputPath}`)); + return; + } + + const chunks: Buffer[] = []; + const audioStream = new PassThrough(); + const normalizedPath = normalize(inputPath); + + const inputFormat = + format.input === 'mpga' || 'bin' + ? 'mp3' + : format.input === 'oga' + ? 'ogg' + : format.input; + const audioCodec = format.to === 'ogg' ? 'libvorbis' : 'aac'; + const outputFormat = format.to === 'ogg' ? 'ogg' : 'adts'; + + const command = ffmpeg(normalizedPath) + .inputFormat(inputFormat) + .audioCodec(audioCodec) + .outputFormat(outputFormat); + + command + .on('start', (commandLine) => { + console.log('FFmpeg started with command:', commandLine); + audioStream.on('data', (chunk) => chunks.push(chunk)); + }) + .on('error', (err, stdout, stderr) => { + console.error('FFmpeg error:', err.message); + console.error('FFmpeg stderr:', stderr); + + ffmpeg(normalizedPath) + .inputFormat(inputFormat) + .outputFormat('wav') + .on('end', () => { + console.log('Converted to WAV, retrying final conversion...'); + const intermediatePath = normalizedPath.replace(/\.[^/.]+$/, '.wav'); + const secondCommand = ffmpeg(intermediatePath) + .audioCodec(audioCodec) + .outputFormat(outputFormat); + + secondCommand + .on('error', (err2, stdout2, stderr2) => { + console.error('Second FFmpeg error:', err2.message); + reject( + new Error( + `Final conversion failed: ${err2.message}\nFFmpeg stderr: ${stderr2}`, + ), + ); + }) + .on('end', () => { + console.log('Final conversion to target format successful'); + resolve(Buffer.concat(chunks)); + }) + .pipe(audioStream, { end: true }); + }) + .on('error', (err1) => + reject(new Error(`WAV conversion failed: ${err1.message}`)), + ) + .pipe(audioStream, { end: true }); + }) + .on('end', () => { + console.log('FFmpeg processing finished'); + resolve(Buffer.concat(chunks)); + }) + .pipe(audioStream, { end: true }); }); } - private async prepareMediaMessage(mediaMessage: MediaMessage & { mimetype?: string }) { + private async prepareMediaMessage( + mediaMessage: MediaMessage & { mimetype?: string; convert?: boolean }, + ) { + const uploadPath = join(ROOT_DIR, 'uploads'); + let fileName = join(uploadPath, mediaMessage?.fileName || ''); + try { let preview: Buffer; let media: Buffer; let mimetype = mediaMessage.mimetype; + + let ext = mediaMessage.extension; + if (isURL(mediaMessage.media as string)) { const response = await axios.get(mediaMessage.media as string, { responseType: 'arraybuffer', }); - media = response.data; mimetype = response.headers['content-type']; + if (!ext) { + ext = mime.extension(mimetype) as string; + } + + if (!mediaMessage?.fileName) { + fileName = join(uploadPath, ulid() + '.' + ext); + } + + writeFileSync(fileName, Buffer.from(response.data)); + if (mediaMessage.mediatype === 'image') { preview = response.data; } - } else { - media = mediaMessage.media as Buffer; } if (mediaMessage.mediatype === 'video') { try { - preview = await this.generateVideoThumbnailFromStream(media); + preview = await this.generateVideoThumbnailFromStream(fileName); } catch (error) { preview = readFileSync(join(ROOT_DIR, 'public', 'images', 'video-cover.png')); } } + const isAccOrOgg = /aac|ogg/.test(mediaMessage?.mimetype || mimetype); + if (mediaMessage.convert && isAccOrOgg) { + if (['ogg', 'oga'].includes(ext)) { + media = readFileSync(fileName); + } else { + media = await this.convertAudioToWH(fileName, { + input: ext as string, + to: 'ogg', + }); + } + } + + if (!media) { + media = readFileSync(fileName); + } + const prepareMedia = await prepareWAMessageMedia( { [mediaMessage.mediatype]: media } as any, { upload: this.client.waUploadToServer }, @@ -1401,7 +1519,7 @@ export class WAStartupService { prepareMedia[mediaType].mimetype = mediaMessage?.mimetype || mimetype; prepareMedia[mediaType].fileName = mediaMessage.fileName; - if (mediaMessage?.mimetype === 'audio/aac') { + if (isAccOrOgg) { prepareMedia.audioMessage.ptt = true; } @@ -1435,10 +1553,17 @@ export class WAStartupService { this.logger.error(error); throw new InternalServerErrorException(error?.toString() || error); + } finally { + if (existsSync(fileName)) { + unlinkSync(fileName); + } } } public async mediaMessage(data: SendMediaDto) { + if (data.mediaMessage?.fileName) { + data.mediaMessage.extension = data.mediaMessage.fileName.split('.').pop(); + } const generate = await this.prepareMediaMessage(data.mediaMessage); return await this.sendMessageWithTyping( @@ -1448,12 +1573,14 @@ export class WAStartupService { ); } - public async mediaFileMessage(data: MediaFileDto, file: Express.Multer.File) { + public async mediaFileMessage(data: MediaFileDto, fileName: string) { + const ext = fileName.split('.').pop(); const generate = await this.prepareMediaMessage({ - fileName: file.originalname, - media: file.buffer, + fileName: fileName, + media: fileName, mediatype: data.mediatype, caption: data?.caption, + extension: ext, }); return await this.sendMessageWithTyping( @@ -1471,6 +1598,7 @@ export class WAStartupService { media: data.audioMessage.audio, mimetype: 'audio/aac', mediatype: 'audio', + convert: data?.options?.convertAudio, }); return this.sendMessageWithTyping( @@ -1480,12 +1608,15 @@ export class WAStartupService { ); } - public async audioWhatsAppFile(data: AudioMessageFileDto, file: Express.Multer.File) { + public async audioWhatsAppFile(data: AudioMessageFileDto, fileName: string) { + const ext = fileName.split('.').pop(); const generate = await this.prepareMediaMessage({ - fileName: file.originalname, - media: file.buffer, + fileName: fileName, + media: fileName, mediatype: 'audio', mimetype: 'audio/aac', + convert: data?.convertAudio as boolean, + extension: ext, }); return this.sendMessageWithTyping(