From 69b4f1aa0224bd123a4f1ce31c75acb5cae0726e Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Sat, 17 May 2025 16:22:13 -0300 Subject: [PATCH] feat(chatbot): implement base chatbot structure and enhance integration capabilities - Introduced a base structure for chatbot integrations, including BaseChatbotController and BaseChatbotService. - Added common DTOs for chatbot settings and data to streamline integration processes. - Updated existing chatbot controllers (Dify, Evoai, N8n) to extend from the new base classes, improving code reusability and maintainability. - Enhanced media message handling across integrations, including audio transcription capabilities using OpenAI's Whisper API. - Refactored service methods to accommodate new message structures and improve error handling. --- .../chatbot/base-chatbot.controller.ts | 921 ++++++++++++++++++ .../integrations/chatbot/base-chatbot.dto.ts | 42 + .../chatbot/base-chatbot.service.ts | 443 +++++++++ .../dify/controllers/dify.controller.ts | 845 ++-------------- .../integrations/chatbot/dify/dto/dify.dto.ts | 35 +- .../chatbot/dify/services/dify.service.ts | 489 +++++----- .../evoai/controllers/evoai.controller.ts | 837 ++-------------- .../chatbot/evoai/dto/evoai.dto.ts | 35 +- .../chatbot/evoai/services/evoai.service.ts | 495 ++-------- .../chatbot/n8n/controllers/n8n.controller.ts | 844 ++-------------- .../integrations/chatbot/n8n/dto/n8n.dto.ts | 14 +- .../chatbot/n8n/services/n8n.service.ts | 379 +++---- src/api/server.module.ts | 2 +- 13 files changed, 2152 insertions(+), 3229 deletions(-) create mode 100644 src/api/integrations/chatbot/base-chatbot.controller.ts create mode 100644 src/api/integrations/chatbot/base-chatbot.dto.ts create mode 100644 src/api/integrations/chatbot/base-chatbot.service.ts diff --git a/src/api/integrations/chatbot/base-chatbot.controller.ts b/src/api/integrations/chatbot/base-chatbot.controller.ts new file mode 100644 index 00000000..b3583af5 --- /dev/null +++ b/src/api/integrations/chatbot/base-chatbot.controller.ts @@ -0,0 +1,921 @@ +import { IgnoreJidDto } from '@api/dto/chatbot.dto'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { PrismaRepository } from '@api/repository/repository.service'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { Logger } from '@config/logger.config'; +import { BadRequestException } from '@exceptions'; +import { TriggerOperator, TriggerType } from '@prisma/client'; +import { getConversationMessage } from '@utils/getConversationMessage'; + +import { BaseChatbotDto } from './base-chatbot.dto'; +import { ChatbotController, ChatbotControllerInterface, EmitData } from './chatbot.controller'; + +// Common settings interface for all chatbot integrations +export interface ChatbotSettings { + expire: number; + keywordFinish: string[]; + delayMessage: number; + unknownMessage: string; + listeningFromMe: boolean; + stopBotFromMe: boolean; + keepOpen: boolean; + debounceTime: number; + ignoreJids: string[]; + splitMessages: boolean; + timePerChar: number; + [key: string]: any; +} + +// Common bot properties for all chatbot integrations +export interface BaseBotData { + enabled?: boolean; + description: string; + expire?: number; + keywordFinish?: string[]; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + triggerType: string | TriggerType; + triggerOperator?: string | TriggerOperator; + triggerValue?: string; + ignoreJids?: string[]; + splitMessages?: boolean; + timePerChar?: number; + [key: string]: any; +} + +export abstract class BaseChatbotController extends ChatbotController implements ChatbotControllerInterface { + public readonly logger: Logger; + + integrationEnabled: boolean; + botRepository: any; + settingsRepository: any; + sessionRepository: any; + userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + + // Name of the integration, to be set by the derived class + protected abstract readonly integrationName: string; + + // Method to process bot-specific logic + protected abstract processBot( + waInstance: any, + remoteJid: string, + bot: BotType, + session: any, + settings: ChatbotSettings, + content: string, + pushName?: string, + msg?: any, + ): Promise; + + // Method to get the fallback bot ID from settings + protected abstract getFallbackBotId(settings: any): string | undefined; + + constructor( + prismaRepository: PrismaRepository, + waMonitor: WAMonitoringService, + ) { + super(prismaRepository, waMonitor); + + this.sessionRepository = this.prismaRepository.integrationSession; + } + + // Base create bot implementation + public async createBot(instance: InstanceDto, data: BotData) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + // Set default settings if not provided + if ( + !data.expire || + !data.keywordFinish || + !data.delayMessage || + !data.unknownMessage || + !data.listeningFromMe || + !data.stopBotFromMe || + !data.keepOpen || + !data.debounceTime || + !data.ignoreJids || + !data.splitMessages || + !data.timePerChar + ) { + const defaultSettingCheck = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + }); + + if (data.expire === undefined || data.expire === null) data.expire = defaultSettingCheck?.expire; + if (data.keywordFinish === undefined || data.keywordFinish === null) + data.keywordFinish = defaultSettingCheck?.keywordFinish; + if (data.delayMessage === undefined || data.delayMessage === null) + data.delayMessage = defaultSettingCheck?.delayMessage; + if (data.unknownMessage === undefined || data.unknownMessage === null) + data.unknownMessage = defaultSettingCheck?.unknownMessage; + if (data.listeningFromMe === undefined || data.listeningFromMe === null) + data.listeningFromMe = defaultSettingCheck?.listeningFromMe; + if (data.stopBotFromMe === undefined || data.stopBotFromMe === null) + data.stopBotFromMe = defaultSettingCheck?.stopBotFromMe; + if (data.keepOpen === undefined || data.keepOpen === null) data.keepOpen = defaultSettingCheck?.keepOpen; + if (data.debounceTime === undefined || data.debounceTime === null) + data.debounceTime = defaultSettingCheck?.debounceTime; + if (data.ignoreJids === undefined || data.ignoreJids === null) data.ignoreJids = defaultSettingCheck?.ignoreJids; + if (data.splitMessages === undefined || data.splitMessages === null) + data.splitMessages = defaultSettingCheck?.splitMessages ?? false; + if (data.timePerChar === undefined || data.timePerChar === null) + data.timePerChar = defaultSettingCheck?.timePerChar ?? 0; + + if (!defaultSettingCheck) { + await this.settings(instance, { + expire: data.expire, + keywordFinish: data.keywordFinish, + delayMessage: data.delayMessage, + unknownMessage: data.unknownMessage, + listeningFromMe: data.listeningFromMe, + stopBotFromMe: data.stopBotFromMe, + keepOpen: data.keepOpen, + debounceTime: data.debounceTime, + ignoreJids: data.ignoreJids, + splitMessages: data.splitMessages, + timePerChar: data.timePerChar, + }); + } + } + + const checkTriggerAll = await this.botRepository.findFirst({ + where: { + enabled: true, + triggerType: 'all', + instanceId: instanceId, + }, + }); + + if (checkTriggerAll && data.triggerType === 'all') { + throw new Error(`You already have a ${this.integrationName} with an "All" trigger, you cannot have more bots while it is active`); + } + + // Check for trigger keyword duplicates + if (data.triggerType === 'keyword') { + if (!data.triggerOperator || !data.triggerValue) { + throw new Error('Trigger operator and value are required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + triggerOperator: data.triggerOperator, + triggerValue: data.triggerValue, + instanceId: instanceId, + }, + }); + + if (checkDuplicate) { + throw new Error('Trigger already exists'); + } + } + + // Check for trigger advanced duplicates + if (data.triggerType === 'advanced') { + if (!data.triggerValue) { + throw new Error('Trigger value is required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + triggerValue: data.triggerValue, + instanceId: instanceId, + }, + }); + + if (checkDuplicate) { + throw new Error('Trigger already exists'); + } + } + + // Derived classes should implement the specific duplicate checking before calling this method + // and add bot-specific fields to the data object + + try { + const botData = { + enabled: data?.enabled, + description: data.description, + expire: data.expire, + keywordFinish: data.keywordFinish, + delayMessage: data.delayMessage, + unknownMessage: data.unknownMessage, + listeningFromMe: data.listeningFromMe, + stopBotFromMe: data.stopBotFromMe, + keepOpen: data.keepOpen, + debounceTime: data.debounceTime, + instanceId: instanceId, + triggerType: data.triggerType, + triggerOperator: data.triggerOperator, + triggerValue: data.triggerValue, + ignoreJids: data.ignoreJids, + splitMessages: data.splitMessages, + timePerChar: data.timePerChar, + ...this.getAdditionalBotData(data), + }; + + const bot = await this.botRepository.create({ + data: botData, + }); + + return bot; + } catch (error) { + this.logger.error(error); + throw new Error(`Error creating ${this.integrationName}`); + } + } + + // Additional fields needed for specific bot types + protected abstract getAdditionalBotData(data: BotData): Record; + + // Common implementation for findBot + public async findBot(instance: InstanceDto) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + try { + const bots = await this.botRepository.findMany({ + where: { + instanceId: instanceId, + }, + }); + + return bots; + } catch (error) { + this.logger.error(error); + throw new Error(`Error finding ${this.integrationName}`); + } + } + + // Common implementation for fetchBot + public async fetchBot(instance: InstanceDto, botId: string) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const bot = await this.botRepository.findUnique({ + where: { + id: botId, + }, + }); + + if (!bot) { + throw new Error(`${this.integrationName} not found`); + } + + return bot; + } catch (error) { + this.logger.error(error); + throw new Error(`Error fetching ${this.integrationName}`); + } + } + + // Common implementation for settings + public async settings(instance: InstanceDto, data: any) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const existingSettings = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + }); + + // Get the name of the fallback field for this integration type + const fallbackFieldName = this.getFallbackFieldName(); + + const settingsData = { + expire: data.expire, + keywordFinish: data.keywordFinish, + delayMessage: data.delayMessage, + unknownMessage: data.unknownMessage, + listeningFromMe: data.listeningFromMe, + stopBotFromMe: data.stopBotFromMe, + keepOpen: data.keepOpen, + debounceTime: data.debounceTime, + ignoreJids: data.ignoreJids, + splitMessages: data.splitMessages, + timePerChar: data.timePerChar, + [fallbackFieldName]: data.fallbackId, // Use the correct field name dynamically + }; + + if (existingSettings) { + const settings = await this.settingsRepository.update({ + where: { + id: existingSettings.id, + }, + data: settingsData, + }); + + // Map the specific fallback field to a generic 'fallbackId' in the response + return { + ...settings, + fallbackId: settings[fallbackFieldName] + }; + } else { + const settings = await this.settingsRepository.create({ + data: { + ...settingsData, + instanceId: instanceId, + }, + }); + + // Map the specific fallback field to a generic 'fallbackId' in the response + return { + ...settings, + fallbackId: settings[fallbackFieldName] + }; + } + } catch (error) { + this.logger.error(error); + throw new Error('Error setting default settings'); + } + } + + // Abstract method to get the field name for the fallback ID + protected abstract getFallbackFieldName(): string; + + // Abstract method to get the integration type (dify, n8n, evoai, etc.) + protected abstract getIntegrationType(): string; + + // Common implementation for fetchSettings + public async fetchSettings(instance: InstanceDto) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const settings = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + include: { + Fallback: true, + }, + }); + + // Get the name of the fallback field for this integration type + const fallbackFieldName = this.getFallbackFieldName(); + + if (!settings) { + return { + expire: 300, + keywordFinish: ['bye', 'exit', 'quit', 'stop'], + delayMessage: 1000, + unknownMessage: 'Sorry, I dont understand', + listeningFromMe: true, + stopBotFromMe: true, + keepOpen: false, + debounceTime: 1, + ignoreJids: [], + splitMessages: false, + timePerChar: 0, + fallbackId: '', + fallback: null, + }; + } + + // Return with standardized fallbackId field + return { + ...settings, + fallbackId: settings[fallbackFieldName], + fallback: settings.Fallback, + }; + } catch (error) { + this.logger.error(error); + throw new Error('Error fetching settings'); + } + } + + // Common implementation for changeStatus + public async changeStatus(instance: InstanceDto, data: any) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const defaultSettingCheck = await this.settingsRepository.findFirst({ + where: { + instanceId, + }, + }); + + const remoteJid = data.remoteJid; + const status = data.status; + + if (status === 'delete') { + await this.sessionRepository.deleteMany({ + where: { + remoteJid: remoteJid, + botId: { not: null }, + }, + }); + + return { bot: { remoteJid: remoteJid, status: status } }; + } + + if (status === 'closed') { + if (defaultSettingCheck?.keepOpen) { + await this.sessionRepository.updateMany({ + where: { + remoteJid: remoteJid, + botId: { not: null }, + }, + data: { + status: 'closed', + }, + }); + } else { + await this.sessionRepository.deleteMany({ + where: { + remoteJid: remoteJid, + botId: { not: null }, + }, + }); + } + + return { bot: { ...instance, bot: { remoteJid: remoteJid, status: status } } }; + } else { + const session = await this.sessionRepository.updateMany({ + where: { + instanceId: instanceId, + remoteJid: remoteJid, + botId: { not: null }, + }, + data: { + status: status, + }, + }); + + const botData = { + remoteJid: remoteJid, + status: status, + session, + }; + + return { bot: { ...instance, bot: botData } }; + } + } catch (error) { + this.logger.error(error); + throw new Error(`Error changing ${this.integrationName} status`); + } + } + + // Common implementation for fetchSessions + public async fetchSessions(instance: InstanceDto, botId: string, remoteJid?: string) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const bot = await this.botRepository.findFirst({ + where: { + id: botId, + }, + }); + + if (bot && bot.instanceId !== instanceId) { + throw new Error(`${this.integrationName} not found`); + } + + // Get the integration type (dify, n8n, evoai, etc.) + const integrationType = this.getIntegrationType(); + + return await this.sessionRepository.findMany({ + where: { + instanceId: instanceId, + remoteJid, + botId: bot ? botId : { not: null }, + type: integrationType, + }, + }); + } catch (error) { + this.logger.error(error); + throw new Error('Error fetching sessions'); + } + } + + // Common implementation for ignoreJid + public async ignoreJid(instance: InstanceDto, data: IgnoreJidDto) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const settings = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + }); + + if (!settings) { + throw new Error('Settings not found'); + } + + let ignoreJids: any = settings?.ignoreJids || []; + + if (data.action === 'add') { + if (ignoreJids.includes(data.remoteJid)) return { ignoreJids: ignoreJids }; + + ignoreJids.push(data.remoteJid); + } else { + ignoreJids = ignoreJids.filter((jid) => jid !== data.remoteJid); + } + + const updateSettings = await this.settingsRepository.update({ + where: { + id: settings.id, + }, + data: { + ignoreJids: ignoreJids, + }, + }); + + return { + ignoreJids: updateSettings.ignoreJids, + }; + } catch (error) { + this.logger.error(error); + throw new Error('Error setting default settings'); + } + } + + // Base implementation for updateBot + public async updateBot(instance: InstanceDto, botId: string, data: BotData) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const bot = await this.botRepository.findFirst({ + where: { + id: botId, + }, + }); + + if (!bot) { + throw new Error(`${this.integrationName} not found`); + } + + if (bot.instanceId !== instanceId) { + throw new Error(`${this.integrationName} not found`); + } + + // Check for "all" trigger type conflicts + if (data.triggerType === 'all') { + const checkTriggerAll = await this.botRepository.findFirst({ + where: { + enabled: true, + triggerType: 'all', + id: { + not: botId, + }, + instanceId: instanceId, + }, + }); + + if (checkTriggerAll) { + throw new Error(`You already have a ${this.integrationName} with an "All" trigger, you cannot have more bots while it is active`); + } + } + + // Let subclasses check for integration-specific duplicates + await this.validateNoDuplicatesOnUpdate(botId, instanceId, data); + + // Check for keyword trigger duplicates + if (data.triggerType === 'keyword') { + if (!data.triggerOperator || !data.triggerValue) { + throw new Error('Trigger operator and value are required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + triggerOperator: data.triggerOperator, + triggerValue: data.triggerValue, + id: { not: botId }, + instanceId: instanceId, + }, + }); + + if (checkDuplicate) { + throw new Error('Trigger already exists'); + } + } + + // Check for advanced trigger duplicates + if (data.triggerType === 'advanced') { + if (!data.triggerValue) { + throw new Error('Trigger value is required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + triggerValue: data.triggerValue, + id: { not: botId }, + instanceId: instanceId, + }, + }); + + if (checkDuplicate) { + throw new Error('Trigger already exists'); + } + } + + // Combine common fields with bot-specific fields + const updateData = { + enabled: data?.enabled, + description: data.description, + expire: data.expire, + keywordFinish: data.keywordFinish, + delayMessage: data.delayMessage, + unknownMessage: data.unknownMessage, + listeningFromMe: data.listeningFromMe, + stopBotFromMe: data.stopBotFromMe, + keepOpen: data.keepOpen, + debounceTime: data.debounceTime, + instanceId: instanceId, + triggerType: data.triggerType, + triggerOperator: data.triggerOperator, + triggerValue: data.triggerValue, + ignoreJids: data.ignoreJids, + splitMessages: data.splitMessages, + timePerChar: data.timePerChar, + ...this.getAdditionalUpdateFields(data), + }; + + const updatedBot = await this.botRepository.update({ + where: { + id: botId, + }, + data: updateData, + }); + + return updatedBot; + } catch (error) { + this.logger.error(error); + throw new Error(`Error updating ${this.integrationName}`); + } + } + + // Abstract method for validating bot-specific duplicates on update + protected abstract validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: BotData): Promise; + + // Abstract method for getting additional fields for update + protected abstract getAdditionalUpdateFields(data: BotData): Record; + + // Base implementation for deleteBot + public async deleteBot(instance: InstanceDto, botId: string) { + if (!this.integrationEnabled) throw new BadRequestException(`${this.integrationName} is disabled`); + + try { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const bot = await this.botRepository.findFirst({ + where: { + id: botId, + }, + }); + + if (!bot) { + throw new Error(`${this.integrationName} not found`); + } + + if (bot.instanceId !== instanceId) { + throw new Error(`${this.integrationName} not found`); + } + + await this.prismaRepository.integrationSession.deleteMany({ + where: { + botId: botId, + }, + }); + + await this.botRepository.delete({ + where: { + id: botId, + }, + }); + + return { bot: { id: botId } }; + } catch (error) { + this.logger.error(error); + throw new Error(`Error deleting ${this.integrationName} bot`); + } + } + + // Base implementation for emit + public async emit({ instance, remoteJid, msg }: EmitData) { + if (!this.integrationEnabled) return; + + try { + const settings = await this.settingsRepository.findFirst({ + where: { + instanceId: instance.instanceId, + }, + }); + + if (this.checkIgnoreJids(settings?.ignoreJids, remoteJid)) return; + + const session = await this.getSession(remoteJid, instance); + + const content = getConversationMessage(msg); + + // Get integration type + const integrationType = this.getIntegrationType(); + + // Find a bot for this message + let findBot: any = await this.findBotTrigger(this.botRepository, content, instance, session); + + // If no bot is found, try to use fallback + if (!findBot) { + const fallback = await this.settingsRepository.findFirst({ + where: { + instanceId: instance.instanceId, + }, + }); + + // Get the fallback ID for this integration type + const fallbackId = this.getFallbackBotId(fallback); + + if (fallbackId) { + const findFallback = await this.botRepository.findFirst({ + where: { + id: fallbackId, + }, + }); + + findBot = findFallback; + } else { + return; + } + } + + // If we still don't have a bot, return + if (!findBot) { + return; + } + + // Collect settings with fallbacks to default settings + let expire = findBot.expire; + let keywordFinish = findBot.keywordFinish; + let delayMessage = findBot.delayMessage; + let unknownMessage = findBot.unknownMessage; + let listeningFromMe = findBot.listeningFromMe; + let stopBotFromMe = findBot.stopBotFromMe; + let keepOpen = findBot.keepOpen; + let debounceTime = findBot.debounceTime; + let ignoreJids = findBot.ignoreJids; + let splitMessages = findBot.splitMessages; + let timePerChar = findBot.timePerChar; + + if (expire === undefined || expire === null) expire = settings.expire; + if (keywordFinish === undefined || keywordFinish === null) keywordFinish = settings.keywordFinish; + if (delayMessage === undefined || delayMessage === null) delayMessage = settings.delayMessage; + if (unknownMessage === undefined || unknownMessage === null) unknownMessage = settings.unknownMessage; + if (listeningFromMe === undefined || listeningFromMe === null) listeningFromMe = settings.listeningFromMe; + if (stopBotFromMe === undefined || stopBotFromMe === null) stopBotFromMe = settings.stopBotFromMe; + if (keepOpen === undefined || keepOpen === null) keepOpen = settings.keepOpen; + if (debounceTime === undefined || debounceTime === null) debounceTime = settings.debounceTime; + if (ignoreJids === undefined || ignoreJids === null) ignoreJids = settings.ignoreJids; + if (splitMessages === undefined || splitMessages === null) splitMessages = settings?.splitMessages ?? false; + if (timePerChar === undefined || timePerChar === null) timePerChar = settings?.timePerChar ?? 0; + + const key = msg.key as { + id: string; + remoteJid: string; + fromMe: boolean; + participant: string; + }; + + // Handle stopping the bot if message is from me + if (stopBotFromMe && key.fromMe && session) { + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'paused', + }, + }); + return; + } + + // Skip if not listening to messages from me + if (!listeningFromMe && key.fromMe) { + return; + } + + // Skip if session exists but not awaiting user input + if (session && !session.awaitUser) { + return; + } + + // Merged settings + const mergedSettings = { + ...settings, + expire, + keywordFinish, + delayMessage, + unknownMessage, + listeningFromMe, + stopBotFromMe, + keepOpen, + debounceTime, + ignoreJids, + splitMessages, + timePerChar, + }; + + // Process with debounce if needed + if (debounceTime && debounceTime > 0) { + this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { + await this.processBot( + this.waMonitor.waInstances[instance.instanceName], + remoteJid, + findBot, + session, + mergedSettings, + debouncedContent, + msg?.pushName, + msg, + ); + }); + } else { + await this.processBot( + this.waMonitor.waInstances[instance.instanceName], + remoteJid, + findBot, + session, + mergedSettings, + content, + msg?.pushName, + msg, + ); + } + } catch (error) { + this.logger.error(error); + } + } +} \ No newline at end of file diff --git a/src/api/integrations/chatbot/base-chatbot.dto.ts b/src/api/integrations/chatbot/base-chatbot.dto.ts new file mode 100644 index 00000000..f79ae6d8 --- /dev/null +++ b/src/api/integrations/chatbot/base-chatbot.dto.ts @@ -0,0 +1,42 @@ +import { TriggerOperator, TriggerType } from '@prisma/client'; + +/** + * Base DTO for all chatbot integrations + * Contains common properties shared by all chatbot types + */ +export class BaseChatbotDto { + enabled?: boolean; + description: string; + expire?: number; + keywordFinish?: string[]; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + triggerType: TriggerType; + triggerOperator?: TriggerOperator; + triggerValue?: string; + ignoreJids?: string[]; + splitMessages?: boolean; + timePerChar?: number; +} + +/** + * Base settings DTO for all chatbot integrations + */ +export class BaseChatbotSettingDto { + expire?: number; + keywordFinish?: string[]; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + ignoreJids?: any; + splitMessages?: boolean; + timePerChar?: number; + fallbackId?: string; // Unified fallback ID field for all integrations +} \ No newline at end of file diff --git a/src/api/integrations/chatbot/base-chatbot.service.ts b/src/api/integrations/chatbot/base-chatbot.service.ts new file mode 100644 index 00000000..669cc1e4 --- /dev/null +++ b/src/api/integrations/chatbot/base-chatbot.service.ts @@ -0,0 +1,443 @@ +import { InstanceDto } from '@api/dto/instance.dto'; +import { PrismaRepository } from '@api/repository/repository.service'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { ConfigService, Language } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { IntegrationSession } from '@prisma/client'; +import { Integration } from '@api/types/wa.types'; +import axios from 'axios'; +import FormData from 'form-data'; + +/** + * Base class for all chatbot service implementations + * Contains common methods shared across different chatbot integrations + */ +export abstract class BaseChatbotService { + protected readonly logger: Logger; + protected readonly waMonitor: WAMonitoringService; + protected readonly prismaRepository: PrismaRepository; + protected readonly configService?: ConfigService; + + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + loggerName: string, + configService?: ConfigService, + ) { + this.waMonitor = waMonitor; + this.prismaRepository = prismaRepository; + this.logger = new Logger(loggerName); + this.configService = configService; + } + + /** + * Check if a message contains an image + */ + protected isImageMessage(content: string): boolean { + return content.includes('imageMessage'); + } + + /** + * Check if a message contains audio + */ + protected isAudioMessage(content: string): boolean { + return content.includes('audioMessage'); + } + + /** + * Check if a string is valid JSON + */ + protected isJSON(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } + } + + /** + * Determine the media type from a URL based on its extension + */ + protected getMediaType(url: string): string | null { + const extension = url.split('.').pop()?.toLowerCase(); + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; + const audioExtensions = ['mp3', 'wav', 'aac', 'ogg']; + const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']; + const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']; + + if (imageExtensions.includes(extension || '')) return 'image'; + if (audioExtensions.includes(extension || '')) return 'audio'; + if (videoExtensions.includes(extension || '')) return 'video'; + if (documentExtensions.includes(extension || '')) return 'document'; + return null; + } + + /** + * Transcribes audio to text using OpenAI's Whisper API + */ + protected async speechToText(audioBuffer: Buffer): Promise { + if (!this.configService) { + this.logger.error('ConfigService not available for speech-to-text transcription'); + return null; + } + + try { + // Try to get the API key from process.env directly since ConfigService might not access it correctly + const apiKey = this.configService.get('OPENAI')?.API_KEY || process.env.OPENAI_API_KEY; + if (!apiKey) { + this.logger.error('No OpenAI API key set for Whisper transcription'); + return null; + } + + const lang = this.configService.get('LANGUAGE').includes('pt') + ? 'pt' + : this.configService.get('LANGUAGE'); + + const formData = new FormData(); + formData.append('file', audioBuffer, 'audio.ogg'); + formData.append('model', 'whisper-1'); + formData.append('language', lang); + + const response = await axios.post('https://api.openai.com/v1/audio/transcriptions', formData, { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${apiKey}`, + }, + }); + + return response?.data?.text || null; + } catch (err) { + this.logger.error(`Whisper transcription failed: ${err}`); + return null; + } + } + + /** + * Create a new chatbot session + */ + public async createNewSession(instance: InstanceDto | any, data: any, type: string) { + try { + // Extract pushName safely - if data.pushName is an object with a pushName property, use that + const pushNameValue = typeof data.pushName === 'object' && data.pushName?.pushName + ? data.pushName.pushName + : (typeof data.pushName === 'string' ? data.pushName : null); + + // Extract remoteJid safely + const remoteJidValue = typeof data.remoteJid === 'object' && data.remoteJid?.remoteJid + ? data.remoteJid.remoteJid + : data.remoteJid; + + const session = await this.prismaRepository.integrationSession.create({ + data: { + remoteJid: remoteJidValue, + pushName: pushNameValue, + sessionId: remoteJidValue, + status: 'opened', + awaitUser: false, + botId: data.botId, + instanceId: instance.instanceId, + type: type, + }, + }); + + return { session }; + } catch (error) { + this.logger.error(error); + return; + } + } + + /** + * Standard implementation for processing incoming messages + * This handles the common workflow across all chatbot types: + * 1. Check for existing session or create new one + * 2. Handle message based on session state + */ + public async process( + instance: any, + remoteJid: string, + bot: BotType, + session: IntegrationSession, + settings: SettingsType, + content: string, + pushName?: string, + msg?: any, + ): Promise { + try { + // For new sessions or sessions awaiting initialization + if (!session || session.status === 'paused') { + await this.initNewSession(instance, remoteJid, bot, settings, session, content, pushName, msg); + return; + } + + // For existing sessions, keywords might indicate the conversation should end + const keywordFinish = (settings as any)?.keywordFinish || []; + const normalizedContent = content.toLowerCase().trim(); + if ( + keywordFinish.length > 0 && + keywordFinish.some((keyword: string) => normalizedContent === keyword.toLowerCase()) + ) { + // Update session to closed and return + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'closed', + }, + }); + return; + } + + // Forward the message to the chatbot API + await this.sendMessageToBot(instance, session, settings, bot, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(`Error in process: ${error}`); + return; + } + } + + /** + * Standard implementation for sending messages to WhatsApp + * This handles common patterns like markdown links and formatting + */ + protected async sendMessageWhatsApp( + instance: any, + remoteJid: string, + message: string, + settings: SettingsType + ): Promise { + if (!message) return; + + const linkRegex = /(!?)\[(.*?)\]\((.*?)\)/g; + let textBuffer = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + const splitMessages = (settings as any)?.splitMessages ?? false; + const timePerChar = (settings as any)?.timePerChar ?? 0; + const minDelay = 1000; + const maxDelay = 20000; + + while ((match = linkRegex.exec(message)) !== null) { + const [fullMatch, exclamation, altText, url] = match; + const mediaType = this.getMediaType(url); + const beforeText = message.slice(lastIndex, match.index); + + if (beforeText) { + textBuffer += beforeText; + } + + if (mediaType) { + // Send accumulated text before sending media + if (textBuffer.trim()) { + await this.sendFormattedText(instance, remoteJid, textBuffer.trim(), settings, splitMessages); + textBuffer = ''; + } + + // Handle sending the media + try { + switch (mediaType) { + case 'image': + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: (settings as any)?.delayMessage || 1000, + caption: altText, + media: url, + }); + break; + case 'video': + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: (settings as any)?.delayMessage || 1000, + caption: altText, + media: url, + }); + break; + case 'document': + await instance.documentMessage({ + number: remoteJid.split('@')[0], + delay: (settings as any)?.delayMessage || 1000, + fileName: altText || 'document', + media: url, + }); + break; + case 'audio': + await instance.audioMessage({ + number: remoteJid.split('@')[0], + delay: (settings as any)?.delayMessage || 1000, + media: url, + }); + break; + } + } catch (error) { + this.logger.error(`Error sending media: ${error}`); + // If media fails, at least send the alt text and URL + textBuffer += `${altText}: ${url}`; + } + } else { + // It's a regular link, keep it in the text + textBuffer += `[${altText}](${url})`; + } + + lastIndex = match.index + fullMatch.length; + } + + // Add any remaining text after the last match + if (lastIndex < message.length) { + textBuffer += message.slice(lastIndex); + } + + // Send any remaining text + if (textBuffer.trim()) { + await this.sendFormattedText(instance, remoteJid, textBuffer.trim(), settings, splitMessages); + } + } + + /** + * Helper method to send formatted text with proper typing indicators and delays + */ + private async sendFormattedText( + instance: any, + remoteJid: string, + text: string, + settings: any, + splitMessages: boolean + ): Promise { + const timePerChar = settings?.timePerChar ?? 0; + const minDelay = 1000; + const maxDelay = 20000; + + if (splitMessages) { + const multipleMessages = text.split('\n\n'); + for (let index = 0; index < multipleMessages.length; index++) { + const message = multipleMessages[index]; + if (!message.trim()) continue; + + const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: message, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } + } + } else { + const delay = Math.min(Math.max(text.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: text, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } + } + } + + /** + * Standard implementation for initializing a new session + * This method should be overridden if a subclass needs specific initialization + */ + protected async initNewSession( + instance: any, + remoteJid: string, + bot: BotType, + settings: SettingsType, + session: IntegrationSession, + content: string, + pushName?: string | any, + msg?: any, + ): Promise { + // Create a session if none exists + if (!session) { + // Extract pushName properly - if it's an object with pushName property, use that + const pushNameValue = typeof pushName === 'object' && pushName?.pushName + ? pushName.pushName + : (typeof pushName === 'string' ? pushName : null); + + session = ( + await this.createNewSession( + { + instanceName: instance.instanceName, + instanceId: instance.instanceId + }, + { + remoteJid, + pushName: pushNameValue, + botId: (bot as any).id, + }, + this.getBotType(), + ) + )?.session; + } + + // Update session status to opened + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'opened', + awaitUser: false, + }, + }); + + // Forward the message to the chatbot + await this.sendMessageToBot(instance, session, settings, bot, remoteJid, pushName || '', content, msg); + } + + /** + * Get the bot type identifier (e.g., 'dify', 'n8n', 'evoai') + * This should match the type field used in the IntegrationSession + */ + protected abstract getBotType(): string; + + /** + * Send a message to the chatbot API + * This is specific to each chatbot integration + */ + protected abstract sendMessageToBot( + instance: any, + session: IntegrationSession, + settings: SettingsType, + bot: BotType, + remoteJid: string, + pushName: string, + content: string, + msg?: any, + ): Promise; +} \ No newline at end of file diff --git a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts index 05834fb3..70173663 100644 --- a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts +++ b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts @@ -1,4 +1,4 @@ -import { IgnoreJidDto } from '@api/dto/chatbot.dto'; + import { InstanceDto } from '@api/dto/instance.dto'; import { DifyDto } from '@api/integrations/chatbot/dify/dto/dify.dto'; import { DifyService } from '@api/integrations/chatbot/dify/services/dify.service'; @@ -7,12 +7,11 @@ import { WAMonitoringService } from '@api/services/monitor.service'; import { configService, Dify } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; -import { Dify as DifyModel } from '@prisma/client'; -import { getConversationMessage } from '@utils/getConversationMessage'; +import { Dify as DifyModel, IntegrationSession } from '@prisma/client'; -import { ChatbotController, ChatbotControllerInterface, EmitData } from '../../chatbot.controller'; +import { BaseChatbotController } from '../../base-chatbot.controller'; -export class DifyController extends ChatbotController implements ChatbotControllerInterface { +export class DifyController extends BaseChatbotController { constructor( private readonly difyService: DifyService, prismaRepository: PrismaRepository, @@ -26,6 +25,7 @@ export class DifyController extends ChatbotController implements ChatbotControll } public readonly logger = new Logger('DifyController'); + protected readonly integrationName = 'Dify'; integrationEnabled = configService.get('DIFY').ENABLED; botRepository: any; @@ -33,6 +33,54 @@ export class DifyController extends ChatbotController implements ChatbotControll sessionRepository: any; userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + protected getFallbackBotId(settings: any): string | undefined { + return settings?.fallbackId; + } + + protected getFallbackFieldName(): string { + return 'difyIdFallback'; + } + + protected getIntegrationType(): string { + return 'dify'; + } + + protected getAdditionalBotData(data: DifyDto): Record { + return { + botType: data.botType, + apiUrl: data.apiUrl, + apiKey: data.apiKey, + }; + } + + // Implementation for bot-specific updates + protected getAdditionalUpdateFields(data: DifyDto): Record { + return { + botType: data.botType, + apiUrl: data.apiUrl, + apiKey: data.apiKey, + }; + } + + // Implementation for bot-specific duplicate validation on update + protected async validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: DifyDto): Promise { + const checkDuplicate = await this.botRepository.findFirst({ + where: { + id: { + not: botId, + }, + instanceId: instanceId, + botType: data.botType, + apiUrl: data.apiUrl, + apiKey: data.apiKey, + }, + }); + + if (checkDuplicate) { + throw new Error('Dify already exists'); + } + } + // Bots public async createBot(instance: InstanceDto, data: DifyDto) { if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); @@ -45,74 +93,7 @@ export class DifyController extends ChatbotController implements ChatbotControll }) .then((instance) => instance.id); - if ( - !data.expire || - !data.keywordFinish || - !data.delayMessage || - !data.unknownMessage || - !data.listeningFromMe || - !data.stopBotFromMe || - !data.keepOpen || - !data.debounceTime || - !data.ignoreJids || - !data.splitMessages || - !data.timePerChar - ) { - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (data.expire === undefined || data.expire === null) data.expire = defaultSettingCheck.expire; - if (data.keywordFinish === undefined || data.keywordFinish === null) - data.keywordFinish = defaultSettingCheck.keywordFinish; - if (data.delayMessage === undefined || data.delayMessage === null) - data.delayMessage = defaultSettingCheck.delayMessage; - if (data.unknownMessage === undefined || data.unknownMessage === null) - data.unknownMessage = defaultSettingCheck.unknownMessage; - if (data.listeningFromMe === undefined || data.listeningFromMe === null) - data.listeningFromMe = defaultSettingCheck.listeningFromMe; - if (data.stopBotFromMe === undefined || data.stopBotFromMe === null) - data.stopBotFromMe = defaultSettingCheck.stopBotFromMe; - if (data.keepOpen === undefined || data.keepOpen === null) data.keepOpen = defaultSettingCheck.keepOpen; - if (data.debounceTime === undefined || data.debounceTime === null) - data.debounceTime = defaultSettingCheck.debounceTime; - if (data.ignoreJids === undefined || data.ignoreJids === null) data.ignoreJids = defaultSettingCheck.ignoreJids; - if (data.splitMessages === undefined || data.splitMessages === null) - data.splitMessages = defaultSettingCheck?.splitMessages ?? false; - if (data.timePerChar === undefined || data.timePerChar === null) - data.timePerChar = defaultSettingCheck?.timePerChar ?? 0; - - if (!defaultSettingCheck) { - await this.settings(instance, { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }); - } - } - - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - instanceId: instanceId, - }, - }); - - if (checkTriggerAll && data.triggerType === 'all') { - throw new Error('You already have a dify with an "All" trigger, you cannot have more bots while it is active'); - } - + // Check for Dify-specific duplicate const checkDuplicate = await this.botRepository.findFirst({ where: { instanceId: instanceId, @@ -126,72 +107,8 @@ export class DifyController extends ChatbotController implements ChatbotControll throw new Error('Dify already exists'); } - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.create({ - data: { - enabled: data?.enabled, - description: data.description, - botType: data.botType, - apiUrl: data.apiUrl, - apiKey: data.apiKey, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error creating dify'); - } + // Let the base class handle the rest of the bot creation process + return super.createBot(instance, data); } public async findBot(instance: InstanceDto) { @@ -246,643 +163,17 @@ export class DifyController extends ChatbotController implements ChatbotControll return bot; } - public async updateBot(instance: InstanceDto, botId: string, data: DifyDto) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('Dify not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Dify not found'); - } - - if (data.triggerType === 'all') { - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - id: { - not: botId, - }, - instanceId: instanceId, - }, - }); - - if (checkTriggerAll) { - throw new Error('You already have a dify with an "All" trigger, you cannot have more bots while it is active'); - } - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - id: { - not: botId, - }, - instanceId: instanceId, - botType: data.botType, - apiUrl: data.apiUrl, - apiKey: data.apiKey, - }, - }); - - if (checkDuplicate) { - throw new Error('Dify already exists'); - } - - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.update({ - where: { - id: botId, - }, - data: { - enabled: data?.enabled, - description: data.description, - botType: data.botType, - apiUrl: data.apiUrl, - apiKey: data.apiKey, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error updating dify'); - } - } - - public async deleteBot(instance: InstanceDto, botId: string) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('Dify not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Dify not found'); - } - try { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: botId, - }, - }); - - await this.botRepository.delete({ - where: { - id: botId, - }, - }); - - return { bot: { id: botId } }; - } catch (error) { - this.logger.error(error); - throw new Error('Error deleting dify bot'); - } - } - - // Settings - public async settings(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (settings) { - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - difyIdFallback: data.difyIdFallback, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: updateSettings.expire, - keywordFinish: updateSettings.keywordFinish, - delayMessage: updateSettings.delayMessage, - unknownMessage: updateSettings.unknownMessage, - listeningFromMe: updateSettings.listeningFromMe, - stopBotFromMe: updateSettings.stopBotFromMe, - keepOpen: updateSettings.keepOpen, - debounceTime: updateSettings.debounceTime, - difyIdFallback: updateSettings.difyIdFallback, - ignoreJids: updateSettings.ignoreJids, - splitMessages: updateSettings.splitMessages, - timePerChar: updateSettings.timePerChar, - }; - } - - const newSetttings = await this.settingsRepository.create({ - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - difyIdFallback: data.difyIdFallback, - ignoreJids: data.ignoreJids, - instanceId: instanceId, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: newSetttings.expire, - keywordFinish: newSetttings.keywordFinish, - delayMessage: newSetttings.delayMessage, - unknownMessage: newSetttings.unknownMessage, - listeningFromMe: newSetttings.listeningFromMe, - stopBotFromMe: newSetttings.stopBotFromMe, - keepOpen: newSetttings.keepOpen, - debounceTime: newSetttings.debounceTime, - difyIdFallback: newSetttings.difyIdFallback, - ignoreJids: newSetttings.ignoreJids, - splitMessages: newSetttings.splitMessages, - timePerChar: newSetttings.timePerChar, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - public async fetchSettings(instance: InstanceDto) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - include: { - Fallback: true, - }, - }); - - if (!settings) { - return { - expire: 0, - keywordFinish: '', - delayMessage: 0, - unknownMessage: '', - listeningFromMe: false, - stopBotFromMe: false, - keepOpen: false, - ignoreJids: [], - splitMessages: false, - timePerChar: 0, - difyIdFallback: '', - fallback: null, - }; - } - - return { - expire: settings.expire, - keywordFinish: settings.keywordFinish, - delayMessage: settings.delayMessage, - unknownMessage: settings.unknownMessage, - listeningFromMe: settings.listeningFromMe, - stopBotFromMe: settings.stopBotFromMe, - keepOpen: settings.keepOpen, - ignoreJids: settings.ignoreJids, - splitMessages: settings.splitMessages, - timePerChar: settings.timePerChar, - difyIdFallback: settings.difyIdFallback, - fallback: settings.Fallback, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching default settings'); - } - } - - // Sessions - public async changeStatus(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId, - }, - }); - - const remoteJid = data.remoteJid; - const status = data.status; - - if (status === 'delete') { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - - return { bot: { remoteJid: remoteJid, status: status } }; - } - - if (status === 'closed') { - if (defaultSettingCheck?.keepOpen) { - await this.sessionRepository.updateMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - } - - return { bot: { ...instance, bot: { remoteJid: remoteJid, status: status } } }; - } else { - const session = await this.sessionRepository.updateMany({ - where: { - instanceId: instanceId, - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: status, - }, - }); - - const botData = { - remoteJid: remoteJid, - status: status, - session, - }; - - return { bot: { ...instance, bot: botData } }; - } - } catch (error) { - this.logger.error(error); - throw new Error('Error changing status'); - } - } - - public async fetchSessions(instance: InstanceDto, botId: string, remoteJid?: string) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (bot && bot.instanceId !== instanceId) { - throw new Error('Dify not found'); - } - - return await this.sessionRepository.findMany({ - where: { - instanceId: instanceId, - remoteJid, - botId: bot ? botId : { not: null }, - type: 'dify', - }, - }); - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching sessions'); - } - } - - public async ignoreJid(instance: InstanceDto, data: IgnoreJidDto) { - if (!this.integrationEnabled) throw new BadRequestException('Dify is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (!settings) { - throw new Error('Settings not found'); - } - - let ignoreJids: any = settings?.ignoreJids || []; - - if (data.action === 'add') { - if (ignoreJids.includes(data.remoteJid)) return { ignoreJids: ignoreJids }; - - ignoreJids.push(data.remoteJid); - } else { - ignoreJids = ignoreJids.filter((jid) => jid !== data.remoteJid); - } - - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - ignoreJids: ignoreJids, - }, - }); - - return { - ignoreJids: updateSettings.ignoreJids, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - // Emit - public async emit({ instance, remoteJid, msg }: EmitData) { - if (!this.integrationEnabled) return; - - try { - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (this.checkIgnoreJids(settings?.ignoreJids, remoteJid)) return; - - const session = await this.getSession(remoteJid, instance); - - const content = getConversationMessage(msg); - - let findBot = (await this.findBotTrigger(this.botRepository, content, instance, session)) as DifyModel; - - if (!findBot) { - const fallback = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (fallback?.difyIdFallback) { - const findFallback = await this.botRepository.findFirst({ - where: { - id: fallback.difyIdFallback, - }, - }); - - findBot = findFallback; - } else { - return; - } - } - - let expire = findBot?.expire; - let keywordFinish = findBot?.keywordFinish; - let delayMessage = findBot?.delayMessage; - let unknownMessage = findBot?.unknownMessage; - let listeningFromMe = findBot?.listeningFromMe; - let stopBotFromMe = findBot?.stopBotFromMe; - let keepOpen = findBot?.keepOpen; - let debounceTime = findBot?.debounceTime; - let ignoreJids = findBot?.ignoreJids; - let splitMessages = findBot?.splitMessages; - let timePerChar = findBot?.timePerChar; - - if (expire === undefined || expire === null) expire = settings.expire; - if (keywordFinish === undefined || keywordFinish === null) keywordFinish = settings.keywordFinish; - if (delayMessage === undefined || delayMessage === null) delayMessage = settings.delayMessage; - if (unknownMessage === undefined || unknownMessage === null) unknownMessage = settings.unknownMessage; - if (listeningFromMe === undefined || listeningFromMe === null) listeningFromMe = settings.listeningFromMe; - if (stopBotFromMe === undefined || stopBotFromMe === null) stopBotFromMe = settings.stopBotFromMe; - if (keepOpen === undefined || keepOpen === null) keepOpen = settings.keepOpen; - if (debounceTime === undefined || debounceTime === null) debounceTime = settings.debounceTime; - if (ignoreJids === undefined || ignoreJids === null) ignoreJids = settings.ignoreJids; - if (splitMessages === undefined || splitMessages === null) splitMessages = settings?.splitMessages ?? false; - if (timePerChar === undefined || timePerChar === null) timePerChar = settings?.timePerChar ?? 0; - - const key = msg.key as { - id: string; - remoteJid: string; - fromMe: boolean; - participant: string; - }; - - if (stopBotFromMe && key.fromMe && session) { - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'paused', - }, - }); - return; - } - - if (!listeningFromMe && key.fromMe) { - return; - } - - if (session && !session.awaitUser) { - return; - } - - if (debounceTime && debounceTime > 0) { - this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { - await this.difyService.processDify( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - debouncedContent, - msg?.pushName, - ); - }); - } else { - await this.difyService.processDify( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - content, - msg?.pushName, - ); - } - - return; - } catch (error) { - this.logger.error(error); - return; - } + // Process Dify-specific bot logic + protected async processBot( + instance: any, + remoteJid: string, + bot: DifyModel, + session: IntegrationSession, + settings: any, + content: string, + pushName?: string, + msg?: any, + ) { + this.difyService.process(instance, remoteJid, bot, session, settings, content, pushName, msg); } } diff --git a/src/api/integrations/chatbot/dify/dto/dify.dto.ts b/src/api/integrations/chatbot/dify/dto/dify.dto.ts index ff9bba05..ddb36626 100644 --- a/src/api/integrations/chatbot/dify/dto/dify.dto.ts +++ b/src/api/integrations/chatbot/dify/dto/dify.dto.ts @@ -1,38 +1,13 @@ import { $Enums, TriggerOperator, TriggerType } from '@prisma/client'; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; -export class DifyDto { - enabled?: boolean; - description?: string; +export class DifyDto extends BaseChatbotDto { + // Dify specific fields botType?: $Enums.DifyBotType; apiUrl?: string; apiKey?: string; - expire?: number; - keywordFinish?: string; - delayMessage?: number; - unknownMessage?: string; - listeningFromMe?: boolean; - stopBotFromMe?: boolean; - keepOpen?: boolean; - debounceTime?: number; - triggerType?: TriggerType; - triggerOperator?: TriggerOperator; - triggerValue?: string; - ignoreJids?: any; - splitMessages?: boolean; - timePerChar?: number; } -export class DifySettingDto { - expire?: number; - keywordFinish?: string; - delayMessage?: number; - unknownMessage?: string; - listeningFromMe?: boolean; - stopBotFromMe?: boolean; - keepOpen?: boolean; - debounceTime?: number; - difyIdFallback?: string; - ignoreJids?: any; - splitMessages?: boolean; - timePerChar?: number; +export class DifySettingDto extends BaseChatbotSettingDto { + // Dify specific fields } diff --git a/src/api/integrations/chatbot/dify/services/dify.service.ts b/src/api/integrations/chatbot/dify/services/dify.service.ts index 348ee70c..fcfcb337 100644 --- a/src/api/integrations/chatbot/dify/services/dify.service.ts +++ b/src/api/integrations/chatbot/dify/services/dify.service.ts @@ -1,60 +1,37 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { InstanceDto } from '@api/dto/instance.dto'; import { PrismaRepository } from '@api/repository/repository.service'; import { WAMonitoringService } from '@api/services/monitor.service'; import { Integration } from '@api/types/wa.types'; -import { Auth, ConfigService, HttpServer } from '@config/env.config'; -import { Logger } from '@config/logger.config'; import { Dify, DifySetting, IntegrationSession } from '@prisma/client'; import { sendTelemetry } from '@utils/sendTelemetry'; import axios from 'axios'; -import { Readable } from 'stream'; +import { downloadMediaMessage } from 'baileys'; -export class DifyService { +import { BaseChatbotService } from '../../base-chatbot.service'; +import { ConfigService, HttpServer } from '@config/env.config'; +import { Auth } from '@config/env.config'; + +export class DifyService extends BaseChatbotService { constructor( - private readonly waMonitor: WAMonitoringService, - private readonly configService: ConfigService, - private readonly prismaRepository: PrismaRepository, - ) {} + waMonitor: WAMonitoringService, + configService: ConfigService, + prismaRepository: PrismaRepository, + ) { + super(waMonitor, prismaRepository, 'DifyService', configService); + } - private readonly logger = new Logger('DifyService'); + /** + * Return the bot type for Dify + */ + protected getBotType(): string { + return 'dify'; + } public async createNewSession(instance: InstanceDto, data: any) { - try { - const session = await this.prismaRepository.integrationSession.create({ - data: { - remoteJid: data.remoteJid, - pushName: data.pushName, - sessionId: data.remoteJid, - status: 'opened', - awaitUser: false, - botId: data.botId, - instanceId: instance.instanceId, - type: 'dify', - }, - }); - - return { session }; - } catch (error) { - this.logger.error(error); - return; - } + return super.createNewSession(instance, data, 'dify'); } - private isImageMessage(content: string) { - return content.includes('imageMessage'); - } - - private isJSON(str: string): boolean { - try { - JSON.parse(str); - return true; - } catch (e) { - return false; - } - } - - private async sendMessageToBot( + protected async sendMessageToBot( instance: any, session: IntegrationSession, settings: DifySetting, @@ -62,6 +39,7 @@ export class DifyService { remoteJid: string, pushName: string, content: string, + msg?: any, ) { try { let endpoint: string = dify.apiUrl; @@ -82,6 +60,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -94,6 +73,23 @@ export class DifyService { ]; payload.query = contentSplit[2] || content; } + + // Handle audio messages + if (this.isAudioMessage(content) && msg) { + try { + this.logger.debug(`[Dify] Downloading audio for Whisper transcription`); + const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); + const transcribedText = await this.speechToText(mediaBuffer); + if (transcribedText) { + payload.query = transcribedText; + } else { + payload.query = '[Audio message could not be transcribed]'; + } + } catch (err) { + this.logger.error(`[Dify] Failed to transcribe audio: ${err}`); + payload.query = '[Audio message could not be transcribed]'; + } + } if (instance.integration === Integration.WHATSAPP_BAILEYS) { await instance.client.presenceSubscribe(remoteJid); @@ -142,6 +138,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -154,6 +151,23 @@ export class DifyService { ]; payload.inputs.query = contentSplit[2] || content; } + + // Handle audio messages + if (this.isAudioMessage(content) && msg) { + try { + this.logger.debug(`[Dify] Downloading audio for Whisper transcription`); + const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); + const transcribedText = await this.speechToText(mediaBuffer); + if (transcribedText) { + payload.inputs.query = transcribedText; + } else { + payload.inputs.query = '[Audio message could not be transcribed]'; + } + } catch (err) { + this.logger.error(`[Dify] Failed to transcribe audio: ${err}`); + payload.inputs.query = '[Audio message could not be transcribed]'; + } + } if (instance.integration === Integration.WHATSAPP_BAILEYS) { await instance.client.presenceSubscribe(remoteJid); @@ -202,6 +216,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -214,6 +229,23 @@ export class DifyService { ]; payload.query = contentSplit[2] || content; } + + // Handle audio messages + if (this.isAudioMessage(content) && msg) { + try { + this.logger.debug(`[Dify] Downloading audio for Whisper transcription`); + const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); + const transcribedText = await this.speechToText(mediaBuffer); + if (transcribedText) { + payload.query = transcribedText; + } else { + payload.query = '[Audio message could not be transcribed]'; + } + } catch (err) { + this.logger.error(`[Dify] Failed to transcribe audio: ${err}`); + payload.query = '[Audio message could not be transcribed]'; + } + } if (instance.integration === Integration.WHATSAPP_BAILEYS) { await instance.client.presenceSubscribe(remoteJid); @@ -248,9 +280,7 @@ export class DifyService { if (instance.integration === Integration.WHATSAPP_BAILEYS) await instance.client.sendPresenceUpdate('paused', remoteJid); - const message = answer; - - await this.sendMessageWhatsApp(instance, remoteJid, message, settings); + await this.sendMessageWhatsApp(instance, remoteJid, answer, settings); await this.prismaRepository.integrationSession.update({ where: { @@ -259,70 +289,9 @@ export class DifyService { data: { status: 'opened', awaitUser: true, - sessionId: conversationId, + sessionId: session.sessionId === remoteJid ? conversationId : session.sessionId, }, }); - - return; - } - - if (dify.botType === 'workflow') { - endpoint += '/workflows/run'; - const payload: any = { - inputs: { - query: content, - remoteJid: remoteJid, - pushName: pushName, - instanceName: instance.instanceName, - serverUrl: this.configService.get('SERVER').URL, - apiKey: this.configService.get('AUTHENTICATION').API_KEY.KEY, - }, - response_mode: 'blocking', - user: remoteJid, - }; - - if (this.isImageMessage(content)) { - const contentSplit = content.split('|'); - - payload.files = [ - { - type: 'image', - transfer_method: 'remote_url', - url: contentSplit[1].split('?')[0], - }, - ]; - payload.inputs.query = contentSplit[2] || content; - } - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.presenceSubscribe(remoteJid); - await instance.client.sendPresenceUpdate('composing', remoteJid); - } - - const response = await axios.post(endpoint, payload, { - headers: { - Authorization: `Bearer ${dify.apiKey}`, - }, - }); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) - await instance.client.sendPresenceUpdate('paused', remoteJid); - - const message = response?.data?.data.outputs.text; - - await this.sendMessageWhatsApp(instance, remoteJid, message, settings); - - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'opened', - awaitUser: true, - }, - }); - - return; } } catch (error) { this.logger.error(error.response?.data || error); @@ -330,33 +299,17 @@ export class DifyService { } } - private async sendMessageWhatsApp(instance: any, remoteJid: string, message: string, settings: DifySetting) { + protected async sendMessageWhatsApp(instance: any, remoteJid: string, message: string, settings: DifySetting) { const linkRegex = /(!?)\[(.*?)\]\((.*?)\)/g; - let textBuffer = ''; let lastIndex = 0; - let match: RegExpExecArray | null; - const getMediaType = (url: string): string | null => { - const extension = url.split('.').pop()?.toLowerCase(); - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; - const audioExtensions = ['mp3', 'wav', 'aac', 'ogg']; - const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']; - const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']; - - if (imageExtensions.includes(extension || '')) return 'image'; - if (audioExtensions.includes(extension || '')) return 'audio'; - if (videoExtensions.includes(extension || '')) return 'video'; - if (documentExtensions.includes(extension || '')) return 'document'; - return null; - }; - while ((match = linkRegex.exec(message)) !== null) { - const [fullMatch, exclMark, altText, url] = match; - const mediaType = getMediaType(url); - + const [fullMatch, exclamation, altText, url] = match; + const mediaType = this.getMediaType(url); const beforeText = message.slice(lastIndex, match.index); + if (beforeText) { textBuffer += beforeText; } @@ -370,10 +323,8 @@ export class DifyService { if (textBuffer.trim()) { if (splitMessages) { const multipleMessages = textBuffer.trim().split('\n\n'); - for (let index = 0; index < multipleMessages.length; index++) { const message = multipleMessages[index]; - const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); if (instance.integration === Integration.WHATSAPP_BAILEYS) { @@ -400,64 +351,90 @@ export class DifyService { } } } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); + const delay = Math.min(Math.max(textBuffer.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: textBuffer, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } } - textBuffer = ''; } - if (mediaType === 'audio') { - await instance.audioWhatsapp({ + textBuffer = ''; + + if (mediaType === 'image') { + await instance.mediaMessage({ number: remoteJid.split('@')[0], delay: settings?.delayMessage || 1000, - audio: url, - caption: altText, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'image', + media: url, + }); + } else if (mediaType === 'video') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'video', + media: url, + }); + } else if (mediaType === 'audio') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + mediatype: 'audio', + media: url, + }); + } else if (mediaType === 'document') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'document', + media: url, + fileName: altText || 'file', }); - } else { - await instance.mediaMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - mediatype: mediaType, - media: url, - caption: altText, - }, - null, - false, - ); } } else { textBuffer += `[${altText}](${url})`; } - lastIndex = linkRegex.lastIndex; + lastIndex = match.index + fullMatch.length; } - if (lastIndex < message.length) { - const remainingText = message.slice(lastIndex); - if (remainingText.trim()) { - textBuffer += remainingText; - } + const remainingText = message.slice(lastIndex); + if (remainingText) { + textBuffer += remainingText; } - const splitMessages = settings.splitMessages ?? false; - const timePerChar = settings.timePerChar ?? 0; - const minDelay = 1000; - const maxDelay = 20000; - if (textBuffer.trim()) { + const splitMessages = settings.splitMessages ?? false; + const timePerChar = settings.timePerChar ?? 0; + const minDelay = 1000; + const maxDelay = 20000; + if (splitMessages) { const multipleMessages = textBuffer.trim().split('\n\n'); - for (let index = 0; index < multipleMessages.length; index++) { const message = multipleMessages[index]; - const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); if (instance.integration === Integration.WHATSAPP_BAILEYS) { @@ -484,21 +461,35 @@ export class DifyService { } } } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); + const delay = Math.min(Math.max(textBuffer.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: textBuffer, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } } } - - sendTelemetry('/message/sendText'); } - private async initNewSession( + protected async initNewSession( instance: any, remoteJid: string, dify: Dify, @@ -506,46 +497,30 @@ export class DifyService { session: IntegrationSession, content: string, pushName?: string, + msg?: any, ) { - const data = await this.createNewSession(instance, { - remoteJid, - pushName, - botId: dify.id, - }); - - if (data.session) { - session = data.session; - } - - await this.sendMessageToBot(instance, session, settings, dify, remoteJid, pushName, content); - - return; - } - - public async processDify( - instance: any, - remoteJid: string, - dify: Dify, - session: IntegrationSession, - settings: DifySetting, - content: string, - pushName?: string, - ) { - if (session && session.status !== 'opened') { + try { + await this.sendMessageToBot(instance, session, settings, dify, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(error); return; } + } - if (session && settings.expire && settings.expire > 0) { - const now = Date.now(); - - const sessionUpdatedAt = new Date(session.updatedAt).getTime(); - - const diff = now - sessionUpdatedAt; - - const diffInMinutes = Math.floor(diff / 1000 / 60); - - if (diffInMinutes > settings.expire) { - if (settings.keepOpen) { + public async process( + instance: any, + remoteJid: string, + dify: Dify, + session: IntegrationSession, + settings: DifySetting, + content: string, + pushName?: string, + msg?: any, + ) { + try { + // Handle keyword finish + if (settings?.keywordFinish?.includes(content.toLowerCase())) { + if (settings?.keepOpen) { await this.prismaRepository.integrationSession.update({ where: { id: session.id, @@ -555,73 +530,65 @@ export class DifyService { }, }); } else { - await this.prismaRepository.integrationSession.deleteMany({ + await this.prismaRepository.integrationSession.delete({ where: { - botId: dify.id, - remoteJid: remoteJid, + id: session.id, }, }); } - await this.initNewSession(instance, remoteJid, dify, settings, session, content, pushName); + await sendTelemetry('/dify/session/finish'); return; } - } - if (!session) { - await this.initNewSession(instance, remoteJid, dify, settings, session, content, pushName); - return; - } + // If session is new or doesn't exist + if (!session) { + const data = { + remoteJid, + pushName, + botId: dify.id, + }; - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'opened', - awaitUser: false, - }, - }); - - if (!content) { - if (settings.unknownMessage) { - this.waMonitor.waInstances[instance.instanceName].textMessage( - { - number: remoteJid.split('@')[0], - delay: settings.delayMessage || 1000, - text: settings.unknownMessage, - }, - false, + const createSession = await this.createNewSession( + { instanceName: instance.instanceName, instanceId: instance.instanceId }, + data, ); - sendTelemetry('/message/sendText'); - } - return; - } + await this.initNewSession( + instance, + remoteJid, + dify, + settings, + createSession.session, + content, + pushName, + msg, + ); - if (settings.keywordFinish && content.toLowerCase() === settings.keywordFinish.toLowerCase()) { - if (settings.keepOpen) { + await sendTelemetry('/dify/session/start'); + return; + } + + // If session exists but is paused + if (session.status === 'paused') { await this.prismaRepository.integrationSession.update({ where: { id: session.id, }, data: { - status: 'closed', - }, - }); - } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: dify.id, - remoteJid: remoteJid, + status: 'opened', + awaitUser: true, }, }); + + return; } + + // Regular message for ongoing session + await this.sendMessageToBot(instance, session, settings, dify, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(error); return; } - - await this.sendMessageToBot(instance, session, settings, dify, remoteJid, pushName, content); - - return; } } diff --git a/src/api/integrations/chatbot/evoai/controllers/evoai.controller.ts b/src/api/integrations/chatbot/evoai/controllers/evoai.controller.ts index 2efb97ca..cd632e80 100644 --- a/src/api/integrations/chatbot/evoai/controllers/evoai.controller.ts +++ b/src/api/integrations/chatbot/evoai/controllers/evoai.controller.ts @@ -7,12 +7,11 @@ import { WAMonitoringService } from '@api/services/monitor.service'; import { configService, Evoai } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; -import { Evoai as EvoaiModel } from '@prisma/client'; -import { getConversationMessage } from '@utils/getConversationMessage'; +import { Evoai as EvoaiModel, IntegrationSession } from '@prisma/client'; -import { ChatbotController, ChatbotControllerInterface, EmitData } from '../../chatbot.controller'; +import { BaseChatbotController, ChatbotSettings } from '../../base-chatbot.controller'; -export class EvoaiController extends ChatbotController implements ChatbotControllerInterface { +export class EvoaiController extends BaseChatbotController { constructor( private readonly evoaiService: EvoaiService, prismaRepository: PrismaRepository, @@ -26,6 +25,7 @@ export class EvoaiController extends ChatbotController implements ChatbotControl } public readonly logger = new Logger('EvoaiController'); + protected readonly integrationName = 'Evoai'; integrationEnabled = configService.get('EVOAI').ENABLED; botRepository: any; @@ -33,6 +33,51 @@ export class EvoaiController extends ChatbotController implements ChatbotControl sessionRepository: any; userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + protected getFallbackBotId(settings: any): string | undefined { + return settings?.fallbackId; + } + + protected getFallbackFieldName(): string { + return 'evoaiIdFallback'; + } + + protected getIntegrationType(): string { + return 'evoai'; + } + + protected getAdditionalBotData(data: EvoaiDto): Record { + return { + agentUrl: data.agentUrl, + apiKey: data.apiKey, + }; + } + + // Implementation for bot-specific updates + protected getAdditionalUpdateFields(data: EvoaiDto): Record { + return { + agentUrl: data.agentUrl, + apiKey: data.apiKey, + }; + } + + // Implementation for bot-specific duplicate validation on update + protected async validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: EvoaiDto): Promise { + const checkDuplicate = await this.botRepository.findFirst({ + where: { + id: { + not: botId, + }, + instanceId: instanceId, + agentUrl: data.agentUrl, + apiKey: data.apiKey, + }, + }); + + if (checkDuplicate) { + throw new Error('Evoai already exists'); + } + } + // Bots public async createBot(instance: InstanceDto, data: EvoaiDto) { if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); @@ -45,74 +90,6 @@ export class EvoaiController extends ChatbotController implements ChatbotControl }) .then((instance) => instance.id); - if ( - !data.expire || - !data.keywordFinish || - !data.delayMessage || - !data.unknownMessage || - !data.listeningFromMe || - !data.stopBotFromMe || - !data.keepOpen || - !data.debounceTime || - !data.ignoreJids || - !data.splitMessages || - !data.timePerChar - ) { - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (data.expire === undefined || data.expire === null) data.expire = defaultSettingCheck.expire; - if (data.keywordFinish === undefined || data.keywordFinish === null) - data.keywordFinish = defaultSettingCheck.keywordFinish; - if (data.delayMessage === undefined || data.delayMessage === null) - data.delayMessage = defaultSettingCheck.delayMessage; - if (data.unknownMessage === undefined || data.unknownMessage === null) - data.unknownMessage = defaultSettingCheck.unknownMessage; - if (data.listeningFromMe === undefined || data.listeningFromMe === null) - data.listeningFromMe = defaultSettingCheck.listeningFromMe; - if (data.stopBotFromMe === undefined || data.stopBotFromMe === null) - data.stopBotFromMe = defaultSettingCheck.stopBotFromMe; - if (data.keepOpen === undefined || data.keepOpen === null) data.keepOpen = defaultSettingCheck.keepOpen; - if (data.debounceTime === undefined || data.debounceTime === null) - data.debounceTime = defaultSettingCheck.debounceTime; - if (data.ignoreJids === undefined || data.ignoreJids === null) data.ignoreJids = defaultSettingCheck.ignoreJids; - if (data.splitMessages === undefined || data.splitMessages === null) - data.splitMessages = defaultSettingCheck?.splitMessages ?? false; - if (data.timePerChar === undefined || data.timePerChar === null) - data.timePerChar = defaultSettingCheck?.timePerChar ?? 0; - - if (!defaultSettingCheck) { - await this.settings(instance, { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }); - } - } - - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - instanceId: instanceId, - }, - }); - - if (checkTriggerAll && data.triggerType === 'all') { - throw new Error('You already have a evoai with an "All" trigger, you cannot have more bots while it is active'); - } - const checkDuplicate = await this.botRepository.findFirst({ where: { instanceId: instanceId, @@ -125,71 +102,7 @@ export class EvoaiController extends ChatbotController implements ChatbotControl throw new Error('Evoai already exists'); } - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.create({ - data: { - enabled: data?.enabled, - description: data.description, - agentUrl: data.agentUrl, - apiKey: data.apiKey, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error creating evoai'); - } + return super.createBot(instance, data); } public async findBot(instance: InstanceDto) { @@ -244,643 +157,17 @@ export class EvoaiController extends ChatbotController implements ChatbotControl return bot; } - public async updateBot(instance: InstanceDto, botId: string, data: EvoaiDto) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('Evoai not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Evoai not found'); - } - - if (data.triggerType === 'all') { - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - id: { - not: botId, - }, - instanceId: instanceId, - }, - }); - - if (checkTriggerAll) { - throw new Error('You already have a evoai with an "All" trigger, you cannot have more bots while it is active'); - } - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - id: { - not: botId, - }, - instanceId: instanceId, - agentUrl: data.agentUrl, - apiKey: data.apiKey, - }, - }); - - if (checkDuplicate) { - throw new Error('Evoai already exists'); - } - - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.update({ - where: { - id: botId, - }, - data: { - enabled: data?.enabled, - description: data.description, - agentUrl: data.agentUrl, - apiKey: data.apiKey, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error updating evoai'); - } - } - - public async deleteBot(instance: InstanceDto, botId: string) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('Evoai not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Evoai not found'); - } - try { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: botId, - }, - }); - - await this.botRepository.delete({ - where: { - id: botId, - }, - }); - - return { bot: { id: botId } }; - } catch (error) { - this.logger.error(error); - throw new Error('Error deleting evoai bot'); - } - } - - // Settings - public async settings(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (settings) { - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - evoaiIdFallback: data.evoaiIdFallback, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: updateSettings.expire, - keywordFinish: updateSettings.keywordFinish, - delayMessage: updateSettings.delayMessage, - unknownMessage: updateSettings.unknownMessage, - listeningFromMe: updateSettings.listeningFromMe, - stopBotFromMe: updateSettings.stopBotFromMe, - keepOpen: updateSettings.keepOpen, - debounceTime: updateSettings.debounceTime, - evoaiIdFallback: updateSettings.evoaiIdFallback, - ignoreJids: updateSettings.ignoreJids, - splitMessages: updateSettings.splitMessages, - timePerChar: updateSettings.timePerChar, - }; - } - - const newSetttings = await this.settingsRepository.create({ - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - evoaiIdFallback: data.evoaiIdFallback, - ignoreJids: data.ignoreJids, - instanceId: instanceId, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: newSetttings.expire, - keywordFinish: newSetttings.keywordFinish, - delayMessage: newSetttings.delayMessage, - unknownMessage: newSetttings.unknownMessage, - listeningFromMe: newSetttings.listeningFromMe, - stopBotFromMe: newSetttings.stopBotFromMe, - keepOpen: newSetttings.keepOpen, - debounceTime: newSetttings.debounceTime, - evoaiIdFallback: newSetttings.evoaiIdFallback, - ignoreJids: newSetttings.ignoreJids, - splitMessages: newSetttings.splitMessages, - timePerChar: newSetttings.timePerChar, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - public async fetchSettings(instance: InstanceDto) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - include: { - Fallback: true, - }, - }); - - if (!settings) { - return { - expire: 0, - keywordFinish: '', - delayMessage: 0, - unknownMessage: '', - listeningFromMe: false, - stopBotFromMe: false, - keepOpen: false, - ignoreJids: [], - splitMessages: false, - timePerChar: 0, - evoaiIdFallback: '', - fallback: null, - }; - } - - return { - expire: settings.expire, - keywordFinish: settings.keywordFinish, - delayMessage: settings.delayMessage, - unknownMessage: settings.unknownMessage, - listeningFromMe: settings.listeningFromMe, - stopBotFromMe: settings.stopBotFromMe, - keepOpen: settings.keepOpen, - ignoreJids: settings.ignoreJids, - splitMessages: settings.splitMessages, - timePerChar: settings.timePerChar, - evoaiIdFallback: settings.evoaiIdFallback, - fallback: settings.Fallback, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching default settings'); - } - } - - // Sessions - public async changeStatus(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId, - }, - }); - - const remoteJid = data.remoteJid; - const status = data.status; - - if (status === 'delete') { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - - return { bot: { remoteJid: remoteJid, status: status } }; - } - - if (status === 'closed') { - if (defaultSettingCheck?.keepOpen) { - await this.sessionRepository.updateMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - } - - return { bot: { ...instance, bot: { remoteJid: remoteJid, status: status } } }; - } else { - const session = await this.sessionRepository.updateMany({ - where: { - instanceId: instanceId, - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: status, - }, - }); - - const botData = { - remoteJid: remoteJid, - status: status, - session, - }; - - return { bot: { ...instance, bot: botData } }; - } - } catch (error) { - this.logger.error(error); - throw new Error('Error changing status'); - } - } - - public async fetchSessions(instance: InstanceDto, botId: string, remoteJid?: string) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (bot && bot.instanceId !== instanceId) { - throw new Error('Evoai not found'); - } - - return await this.sessionRepository.findMany({ - where: { - instanceId: instanceId, - remoteJid, - botId: bot ? botId : { not: null }, - type: 'evoai', - }, - }); - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching sessions'); - } - } - - public async ignoreJid(instance: InstanceDto, data: IgnoreJidDto) { - if (!this.integrationEnabled) throw new BadRequestException('Evoai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (!settings) { - throw new Error('Settings not found'); - } - - let ignoreJids: any = settings?.ignoreJids || []; - - if (data.action === 'add') { - if (ignoreJids.includes(data.remoteJid)) return { ignoreJids: ignoreJids }; - - ignoreJids.push(data.remoteJid); - } else { - ignoreJids = ignoreJids.filter((jid) => jid !== data.remoteJid); - } - - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - ignoreJids: ignoreJids, - }, - }); - - return { - ignoreJids: updateSettings.ignoreJids, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - // Emit - public async emit({ instance, remoteJid, msg }: EmitData) { - if (!this.integrationEnabled) return; - - try { - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (this.checkIgnoreJids(settings?.ignoreJids, remoteJid)) return; - - const session = await this.getSession(remoteJid, instance); - - const content = getConversationMessage(msg); - - let findBot = (await this.findBotTrigger(this.botRepository, content, instance, session)) as EvoaiModel; - - if (!findBot) { - const fallback = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (fallback?.evoaiIdFallback) { - const findFallback = await this.botRepository.findFirst({ - where: { - id: fallback.evoaiIdFallback, - }, - }); - - findBot = findFallback; - } else { - return; - } - } - - let expire = findBot?.expire; - let keywordFinish = findBot?.keywordFinish; - let delayMessage = findBot?.delayMessage; - let unknownMessage = findBot?.unknownMessage; - let listeningFromMe = findBot?.listeningFromMe; - let stopBotFromMe = findBot?.stopBotFromMe; - let keepOpen = findBot?.keepOpen; - let debounceTime = findBot?.debounceTime; - let ignoreJids = findBot?.ignoreJids; - let splitMessages = findBot?.splitMessages; - let timePerChar = findBot?.timePerChar; - - if (expire === undefined || expire === null) expire = settings.expire; - if (keywordFinish === undefined || keywordFinish === null) keywordFinish = settings.keywordFinish; - if (delayMessage === undefined || delayMessage === null) delayMessage = settings.delayMessage; - if (unknownMessage === undefined || unknownMessage === null) unknownMessage = settings.unknownMessage; - if (listeningFromMe === undefined || listeningFromMe === null) listeningFromMe = settings.listeningFromMe; - if (stopBotFromMe === undefined || stopBotFromMe === null) stopBotFromMe = settings.stopBotFromMe; - if (keepOpen === undefined || keepOpen === null) keepOpen = settings.keepOpen; - if (debounceTime === undefined || debounceTime === null) debounceTime = settings.debounceTime; - if (ignoreJids === undefined || ignoreJids === null) ignoreJids = settings.ignoreJids; - if (splitMessages === undefined || splitMessages === null) splitMessages = settings?.splitMessages ?? false; - if (timePerChar === undefined || timePerChar === null) timePerChar = settings?.timePerChar ?? 0; - - const key = msg.key as { - id: string; - remoteJid: string; - fromMe: boolean; - participant: string; - }; - - if (stopBotFromMe && key.fromMe && session) { - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'paused', - }, - }); - return; - } - - if (!listeningFromMe && key.fromMe) { - return; - } - - if (session && !session.awaitUser) { - return; - } - - if (debounceTime && debounceTime > 0) { - this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { - await this.evoaiService.processEvoai( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - debouncedContent, - msg?.pushName, - msg, - ); - }); - } else { - await this.evoaiService.processEvoai( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - content, - msg?.pushName, - msg, - ); - } - - return; - } catch (error) { - this.logger.error(error); - return; - } + // Process Evoai-specific bot logic + protected async processBot( + instance: any, + remoteJid: string, + bot: EvoaiModel, + session: IntegrationSession, + settings: any, + content: string, + pushName?: string, + msg?: any, + ) { + this.evoaiService.process(instance, remoteJid, bot, session, settings, content, pushName, msg); } } diff --git a/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts b/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts index 30991bbe..0d42bc3c 100644 --- a/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts +++ b/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts @@ -1,37 +1,12 @@ import { TriggerOperator, TriggerType } from '@prisma/client'; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; -export class EvoaiDto { - enabled?: boolean; - description?: string; +export class EvoaiDto extends BaseChatbotDto { + // Evoai specific fields agentUrl?: string; apiKey?: string; - expire?: number; - keywordFinish?: string; - delayMessage?: number; - unknownMessage?: string; - listeningFromMe?: boolean; - stopBotFromMe?: boolean; - keepOpen?: boolean; - debounceTime?: number; - triggerType?: TriggerType; - triggerOperator?: TriggerOperator; - triggerValue?: string; - ignoreJids?: any; - splitMessages?: boolean; - timePerChar?: number; } -export class EvoaiSettingDto { - expire?: number; - keywordFinish?: string; - delayMessage?: number; - unknownMessage?: string; - listeningFromMe?: boolean; - stopBotFromMe?: boolean; - keepOpen?: boolean; - debounceTime?: number; - evoaiIdFallback?: string; - ignoreJids?: any; - splitMessages?: boolean; - timePerChar?: number; +export class EvoaiSettingDto extends BaseChatbotSettingDto { + // Evoai specific fields } diff --git a/src/api/integrations/chatbot/evoai/services/evoai.service.ts b/src/api/integrations/chatbot/evoai/services/evoai.service.ts index 431766a9..e580359c 100644 --- a/src/api/integrations/chatbot/evoai/services/evoai.service.ts +++ b/src/api/integrations/chatbot/evoai/services/evoai.service.ts @@ -1,84 +1,85 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { InstanceDto } from '@api/dto/instance.dto'; import { PrismaRepository } from '@api/repository/repository.service'; import { WAMonitoringService } from '@api/services/monitor.service'; import { Integration } from '@api/types/wa.types'; -import { ConfigService, Language } from '@config/env.config'; -import { Logger } from '@config/logger.config'; +import { ConfigService } from '@config/env.config'; import { Evoai, EvoaiSetting, IntegrationSession } from '@prisma/client'; -import { sendTelemetry } from '@utils/sendTelemetry'; import axios from 'axios'; import { downloadMediaMessage } from 'baileys'; -import FormData from 'form-data'; import { v4 as uuidv4 } from 'uuid'; -export class EvoaiService { - constructor( - private readonly waMonitor: WAMonitoringService, - private readonly prismaRepository: PrismaRepository, - private readonly configService: ConfigService, - ) {} +import { BaseChatbotService } from '../../base-chatbot.service'; - private readonly logger = new Logger('EvoaiService'); +export class EvoaiService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(waMonitor, prismaRepository, 'EvoaiService', configService); + } + + /** + * Return the bot type for EvoAI + */ + protected getBotType(): string { + return 'evoai'; + } public async createNewSession(instance: InstanceDto, data: any) { - try { - const session = await this.prismaRepository.integrationSession.create({ - data: { - remoteJid: data.remoteJid, - pushName: data.pushName, - sessionId: data.remoteJid, - status: 'opened', - awaitUser: false, - botId: data.botId, - instanceId: instance.instanceId, - type: 'evoai', - }, - }); + return super.createNewSession(instance, data, 'evoai'); + } - return { session }; + /** + * Override the process method to directly handle audio messages + */ + public async process( + instance: any, + remoteJid: string, + bot: Evoai, + session: IntegrationSession, + settings: EvoaiSetting, + content: string, + pushName?: string, + msg?: any, + ): Promise { + try { + this.logger.debug(`[EvoAI] Processing message with custom process method`); + + // Check if this is an audio message that we should try to transcribe + if (msg?.messageType === 'audioMessage' && msg?.message?.audioMessage) { + this.logger.debug(`[EvoAI] Detected audio message, attempting transcription`); + + try { + // Download the audio using the whole msg object + const mediaBuffer = await downloadMediaMessage(msg, 'buffer', {}); + this.logger.debug(`[EvoAI] Downloaded audio: ${mediaBuffer?.length || 0} bytes`); + + // Transcribe with OpenAI's Whisper + const transcribedText = await this.speechToText(mediaBuffer); + this.logger.debug(`[EvoAI] Transcription result: ${transcribedText || 'FAILED'}`); + + if (transcribedText) { + // Use the transcribed text instead of the original content + this.logger.debug(`[EvoAI] Using transcribed text: ${transcribedText}`); + + // Call the parent process method with the transcribed text + return super.process(instance, remoteJid, bot, session, settings, transcribedText, pushName, msg); + } + } catch (err) { + this.logger.error(`[EvoAI] Audio transcription error: ${err}`); + } + } + + // For non-audio messages or if transcription failed, proceed normally + return super.process(instance, remoteJid, bot, session, settings, content, pushName, msg); } catch (error) { - this.logger.error(error); + this.logger.error(`[EvoAI] Error in process: ${error}`); return; } } - private isImageMessage(content: string) { - return content.includes('imageMessage'); - } - - private isAudioMessage(content: string) { - return content.includes('audioMessage'); - } - - private async speechToText(audioBuffer: Buffer): Promise { - try { - const apiKey = this.configService.get('OPENAI')?.API_KEY; - if (!apiKey) { - this.logger.error('[EvoAI] No OpenAI API key set for Whisper transcription'); - return null; - } - const lang = this.configService.get('LANGUAGE').includes('pt') - ? 'pt' - : this.configService.get('LANGUAGE'); - const formData = new FormData(); - formData.append('file', audioBuffer, 'audio.ogg'); - formData.append('model', 'whisper-1'); - formData.append('language', lang); - const response = await axios.post('https://api.openai.com/v1/audio/transcriptions', formData, { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${apiKey}`, - }, - }); - return response?.data?.text || null; - } catch (err) { - this.logger.error(`[EvoAI] Whisper transcription failed: ${err}`); - return null; - } - } - - private async sendMessageToBot( + protected async sendMessageToBot( instance: any, session: IntegrationSession, settings: EvoaiSetting, @@ -89,64 +90,41 @@ export class EvoaiService { msg?: any, ) { try { + this.logger.debug(`[EvoAI] Sending message to bot with content: ${content}`); + const endpoint: string = evoai.agentUrl; const callId = `call-${uuidv4()}`; const taskId = `task-${uuidv4()}`; // Prepare message parts - const parts: any[] = [ + const parts = [ { type: 'text', text: content, }, ]; - // If content indicates an image/file, fetch and encode as base64, then send as a file part - if ((this.isImageMessage(content) || this.isAudioMessage(content)) && msg) { - const isImage = this.isImageMessage(content); - const isAudio = this.isAudioMessage(content); - this.logger.debug(`[EvoAI] Media message detected: ${content}`); - - let transcribedText = null; - if (isAudio) { - try { - this.logger.debug(`[EvoAI] Downloading audio for Whisper transcription`); - const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); - transcribedText = await this.speechToText(mediaBuffer); - if (transcribedText) { - parts[0].text = transcribedText; - } else { - parts[0].text = '[Audio message could not be transcribed]'; - } - } catch (err) { - this.logger.error(`[EvoAI] Failed to transcribe audio: ${err}`); - parts[0].text = '[Audio message could not be transcribed]'; - } - } else if (isImage) { - const contentSplit = content.split('|'); - parts[0].text = contentSplit[2] || content; - let fileContent = null, - fileName = null, - mimeType = null; - try { - this.logger.debug( - `[EvoAI] Fetching image using downloadMediaMessage with msg.key: ${JSON.stringify(msg.key)}`, - ); - const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); - fileContent = Buffer.from(mediaBuffer).toString('base64'); - fileName = contentSplit[2] || `${msg.key.id}.jpg`; - mimeType = 'image/jpeg'; - parts.push({ - type: 'file', - file: { - name: fileName, - bytes: fileContent, - mimeType: mimeType, - }, - }); - } catch (fileErr) { - this.logger.error(`[EvoAI] Failed to fetch or encode image for EvoAI: ${fileErr}`); - } + // Handle image message if present + if (this.isImageMessage(content) && msg) { + const contentSplit = content.split('|'); + parts[0].text = contentSplit[2] || content; + + try { + // Download the image + const mediaBuffer = await downloadMediaMessage(msg, 'buffer', {}); + const fileContent = Buffer.from(mediaBuffer).toString('base64'); + const fileName = contentSplit[2] || `${msg.key?.id || 'image'}.jpg`; + + parts.push({ + type: 'file', + file: { + name: fileName, + bytes: fileContent, + mimeType: 'image/jpeg', + }, + } as any); + } catch (fileErr) { + this.logger.error(`[EvoAI] Failed to process image: ${fileErr}`); } } @@ -224,301 +202,4 @@ export class EvoaiService { return; } } - - private async sendMessageWhatsApp(instance: any, remoteJid: string, message: string, settings: EvoaiSetting) { - const linkRegex = /(!?)\[(.*?)\]\((.*?)\)/g; - - let textBuffer = ''; - let lastIndex = 0; - - let match: RegExpExecArray | null; - - const getMediaType = (url: string): string | null => { - const extension = url.split('.').pop()?.toLowerCase(); - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; - const audioExtensions = ['mp3', 'wav', 'aac', 'ogg']; - const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']; - const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']; - - if (imageExtensions.includes(extension || '')) return 'image'; - if (audioExtensions.includes(extension || '')) return 'audio'; - if (videoExtensions.includes(extension || '')) return 'video'; - if (documentExtensions.includes(extension || '')) return 'document'; - return null; - }; - - while ((match = linkRegex.exec(message)) !== null) { - const [fullMatch, exclMark, altText, url] = match; - const mediaType = getMediaType(url); - - const beforeText = message.slice(lastIndex, match.index); - if (beforeText) { - textBuffer += beforeText; - } - - if (mediaType) { - const splitMessages = settings.splitMessages ?? false; - const timePerChar = settings.timePerChar ?? 0; - const minDelay = 1000; - const maxDelay = 20000; - - if (textBuffer.trim()) { - if (splitMessages) { - const multipleMessages = textBuffer.trim().split('\n\n'); - - for (let index = 0; index < multipleMessages.length; index++) { - const message = multipleMessages[index]; - - const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.presenceSubscribe(remoteJid); - await instance.client.sendPresenceUpdate('composing', remoteJid); - } - - await new Promise((resolve) => { - setTimeout(async () => { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: message, - }, - false, - ); - resolve(); - }, delay); - }); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.sendPresenceUpdate('paused', remoteJid); - } - } - } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); - } - textBuffer = ''; - } - - if (mediaType === 'audio') { - await instance.audioWhatsapp({ - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - audio: url, - caption: altText, - }); - } else { - await instance.mediaMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - mediatype: mediaType, - media: url, - caption: altText, - }, - null, - false, - ); - } - } else { - textBuffer += `[${altText}](${url})`; - } - - lastIndex = linkRegex.lastIndex; - } - - if (lastIndex < message.length) { - const remainingText = message.slice(lastIndex); - if (remainingText.trim()) { - textBuffer += remainingText; - } - } - - const splitMessages = settings.splitMessages ?? false; - const timePerChar = settings.timePerChar ?? 0; - const minDelay = 1000; - const maxDelay = 20000; - - if (textBuffer.trim()) { - if (splitMessages) { - const multipleMessages = textBuffer.trim().split('\n\n'); - - for (let index = 0; index < multipleMessages.length; index++) { - const message = multipleMessages[index]; - - const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.presenceSubscribe(remoteJid); - await instance.client.sendPresenceUpdate('composing', remoteJid); - } - - await new Promise((resolve) => { - setTimeout(async () => { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: message, - }, - false, - ); - resolve(); - }, delay); - }); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.sendPresenceUpdate('paused', remoteJid); - } - } - } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); - } - } - - sendTelemetry('/message/sendText'); - } - - private async initNewSession( - instance: any, - remoteJid: string, - evoai: Evoai, - settings: EvoaiSetting, - session: IntegrationSession, - content: string, - pushName?: string, - msg?: any, - ) { - const data = await this.createNewSession(instance, { - remoteJid, - pushName, - botId: evoai.id, - }); - - if (data.session) { - session = data.session; - } - - await this.sendMessageToBot(instance, session, settings, evoai, remoteJid, pushName, content, msg); - - return; - } - - public async processEvoai( - instance: any, - remoteJid: string, - evoai: Evoai, - session: IntegrationSession, - settings: EvoaiSetting, - content: string, - pushName?: string, - msg?: any, - ) { - if (session && session.status !== 'opened') { - return; - } - - if (session && settings.expire && settings.expire > 0) { - const now = Date.now(); - - const sessionUpdatedAt = new Date(session.updatedAt).getTime(); - - const diff = now - sessionUpdatedAt; - - const diffInMinutes = Math.floor(diff / 1000 / 60); - - if (diffInMinutes > settings.expire) { - if (settings.keepOpen) { - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: evoai.id, - remoteJid: remoteJid, - }, - }); - } - - await this.initNewSession(instance, remoteJid, evoai, settings, session, content, pushName, msg); - return; - } - } - - if (!session) { - await this.initNewSession(instance, remoteJid, evoai, settings, session, content, pushName, msg); - return; - } - - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'opened', - awaitUser: false, - }, - }); - - if (!content) { - if (settings.unknownMessage) { - this.waMonitor.waInstances[instance.instanceName].textMessage( - { - number: remoteJid.split('@')[0], - delay: settings.delayMessage || 1000, - text: settings.unknownMessage, - }, - false, - ); - - sendTelemetry('/message/sendText'); - } - return; - } - - if (settings.keywordFinish && content.toLowerCase() === settings.keywordFinish.toLowerCase()) { - if (settings.keepOpen) { - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: evoai.id, - remoteJid: remoteJid, - }, - }); - } - return; - } - - await this.sendMessageToBot(instance, session, settings, evoai, remoteJid, pushName, content, msg); - - return; - } } diff --git a/src/api/integrations/chatbot/n8n/controllers/n8n.controller.ts b/src/api/integrations/chatbot/n8n/controllers/n8n.controller.ts index a58a386d..34e5828a 100644 --- a/src/api/integrations/chatbot/n8n/controllers/n8n.controller.ts +++ b/src/api/integrations/chatbot/n8n/controllers/n8n.controller.ts @@ -1,4 +1,3 @@ -import { IgnoreJidDto } from '@api/dto/chatbot.dto'; import { InstanceDto } from '@api/dto/instance.dto'; import { N8nDto } from '@api/integrations/chatbot/n8n/dto/n8n.dto'; import { N8nService } from '@api/integrations/chatbot/n8n/services/n8n.service'; @@ -7,12 +6,11 @@ import { WAMonitoringService } from '@api/services/monitor.service'; import { configService } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; -import { N8n as N8nModel } from '@prisma/client'; -import { getConversationMessage } from '@utils/getConversationMessage'; +import { IntegrationSession, N8n as N8nModel } from '@prisma/client'; -import { ChatbotController, ChatbotControllerInterface, EmitData } from '../../chatbot.controller'; +import { BaseChatbotController } from '../../base-chatbot.controller'; -export class N8nController extends ChatbotController implements ChatbotControllerInterface { +export class N8nController extends BaseChatbotController { constructor( private readonly n8nService: N8nService, prismaRepository: PrismaRepository, @@ -26,6 +24,7 @@ export class N8nController extends ChatbotController implements ChatbotControlle } public readonly logger = new Logger('N8nController'); + protected readonly integrationName = 'N8n'; integrationEnabled = configService.get('N8N').ENABLED; botRepository: any; @@ -33,6 +32,54 @@ export class N8nController extends ChatbotController implements ChatbotControlle sessionRepository: any; userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + protected getFallbackBotId(settings: any): string | undefined { + return settings?.fallbackId; + } + + protected getFallbackFieldName(): string { + return 'n8nIdFallback'; + } + + protected getIntegrationType(): string { + return 'n8n'; + } + + protected getAdditionalBotData(data: N8nDto): Record { + return { + webhookUrl: data.webhookUrl, + basicAuthUser: data.basicAuthUser, + basicAuthPass: data.basicAuthPass, + }; + } + + // Implementation for bot-specific updates + protected getAdditionalUpdateFields(data: N8nDto): Record { + return { + webhookUrl: data.webhookUrl, + basicAuthUser: data.basicAuthUser, + basicAuthPass: data.basicAuthPass, + }; + } + + // Implementation for bot-specific duplicate validation on update + protected async validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: N8nDto): Promise { + const checkDuplicate = await this.botRepository.findFirst({ + where: { + id: { + not: botId, + }, + instanceId: instanceId, + webhookUrl: data.webhookUrl, + basicAuthUser: data.basicAuthUser, + basicAuthPass: data.basicAuthPass, + }, + }); + + if (checkDuplicate) { + throw new Error('N8n already exists'); + } + } + // Bots public async createBot(instance: InstanceDto, data: N8nDto) { if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); @@ -45,74 +92,7 @@ export class N8nController extends ChatbotController implements ChatbotControlle }) .then((instance) => instance.id); - if ( - !data.expire || - !data.keywordFinish || - !data.delayMessage || - !data.unknownMessage || - !data.listeningFromMe || - !data.stopBotFromMe || - !data.keepOpen || - !data.debounceTime || - !data.ignoreJids || - !data.splitMessages || - !data.timePerChar - ) { - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (data.expire === undefined || data.expire === null) data.expire = defaultSettingCheck.expire; - if (data.keywordFinish === undefined || data.keywordFinish === null) - data.keywordFinish = defaultSettingCheck.keywordFinish; - if (data.delayMessage === undefined || data.delayMessage === null) - data.delayMessage = defaultSettingCheck.delayMessage; - if (data.unknownMessage === undefined || data.unknownMessage === null) - data.unknownMessage = defaultSettingCheck.unknownMessage; - if (data.listeningFromMe === undefined || data.listeningFromMe === null) - data.listeningFromMe = defaultSettingCheck.listeningFromMe; - if (data.stopBotFromMe === undefined || data.stopBotFromMe === null) - data.stopBotFromMe = defaultSettingCheck.stopBotFromMe; - if (data.keepOpen === undefined || data.keepOpen === null) data.keepOpen = defaultSettingCheck.keepOpen; - if (data.debounceTime === undefined || data.debounceTime === null) - data.debounceTime = defaultSettingCheck.debounceTime; - if (data.ignoreJids === undefined || data.ignoreJids === null) data.ignoreJids = defaultSettingCheck.ignoreJids; - if (data.splitMessages === undefined || data.splitMessages === null) - data.splitMessages = defaultSettingCheck?.splitMessages ?? false; - if (data.timePerChar === undefined || data.timePerChar === null) - data.timePerChar = defaultSettingCheck?.timePerChar ?? 0; - - if (!defaultSettingCheck) { - await this.settings(instance, { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }); - } - } - - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - instanceId: instanceId, - }, - }); - - if (checkTriggerAll && data.triggerType === 'all') { - throw new Error('You already have an n8n with an "All" trigger, you cannot have more bots while it is active'); - } - + // Check for N8n-specific duplicate const checkDuplicate = await this.botRepository.findFirst({ where: { instanceId: instanceId, @@ -126,72 +106,8 @@ export class N8nController extends ChatbotController implements ChatbotControlle throw new Error('N8n already exists'); } - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.create({ - data: { - enabled: data?.enabled, - description: data.description, - webhookUrl: data.webhookUrl, - basicAuthUser: data.basicAuthUser, - basicAuthPass: data.basicAuthPass, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error creating n8n'); - } + // Let the base class handle the rest of the bot creation process + return super.createBot(instance, data); } public async findBot(instance: InstanceDto) { @@ -246,643 +162,17 @@ export class N8nController extends ChatbotController implements ChatbotControlle return bot; } - public async updateBot(instance: InstanceDto, botId: string, data: N8nDto) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('N8n not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('N8n not found'); - } - - if (data.triggerType === 'all') { - const checkTriggerAll = await this.botRepository.findFirst({ - where: { - enabled: true, - triggerType: 'all', - id: { - not: botId, - }, - instanceId: instanceId, - }, - }); - - if (checkTriggerAll) { - throw new Error('You already have an n8n with an "All" trigger, you cannot have more bots while it is active'); - } - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - id: { - not: botId, - }, - instanceId: instanceId, - webhookUrl: data.webhookUrl, - basicAuthUser: data.basicAuthUser, - basicAuthPass: data.basicAuthPass, - }, - }); - - if (checkDuplicate) { - throw new Error('N8n already exists'); - } - - if (data.triggerType === 'keyword') { - if (!data.triggerOperator || !data.triggerValue) { - throw new Error('Trigger operator and value are required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - if (data.triggerType === 'advanced') { - if (!data.triggerValue) { - throw new Error('Trigger value is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: { - triggerValue: data.triggerValue, - id: { not: botId }, - instanceId: instanceId, - }, - }); - - if (checkDuplicate) { - throw new Error('Trigger already exists'); - } - } - - try { - const bot = await this.botRepository.update({ - where: { - id: botId, - }, - data: { - enabled: data?.enabled, - description: data.description, - webhookUrl: data.webhookUrl, - basicAuthUser: data.basicAuthUser, - basicAuthPass: data.basicAuthPass, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - instanceId: instanceId, - triggerType: data.triggerType, - triggerOperator: data.triggerOperator, - triggerValue: data.triggerValue, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return bot; - } catch (error) { - this.logger.error(error); - throw new Error('Error updating n8n'); - } - } - - public async deleteBot(instance: InstanceDto, botId: string) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (!bot) { - throw new Error('N8n not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('N8n not found'); - } - try { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: botId, - }, - }); - - await this.botRepository.delete({ - where: { - id: botId, - }, - }); - - return { bot: { id: botId } }; - } catch (error) { - this.logger.error(error); - throw new Error('Error deleting n8n bot'); - } - } - - // Settings - public async settings(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (settings) { - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - n8nIdFallback: data.n8nIdFallback, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: updateSettings.expire, - keywordFinish: updateSettings.keywordFinish, - delayMessage: updateSettings.delayMessage, - unknownMessage: updateSettings.unknownMessage, - listeningFromMe: updateSettings.listeningFromMe, - stopBotFromMe: updateSettings.stopBotFromMe, - keepOpen: updateSettings.keepOpen, - debounceTime: updateSettings.debounceTime, - n8nIdFallback: updateSettings.n8nIdFallback, - ignoreJids: updateSettings.ignoreJids, - splitMessages: updateSettings.splitMessages, - timePerChar: updateSettings.timePerChar, - }; - } - - const newSettings = await this.settingsRepository.create({ - data: { - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - n8nIdFallback: data.n8nIdFallback, - ignoreJids: data.ignoreJids, - instanceId: instanceId, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - expire: newSettings.expire, - keywordFinish: newSettings.keywordFinish, - delayMessage: newSettings.delayMessage, - unknownMessage: newSettings.unknownMessage, - listeningFromMe: newSettings.listeningFromMe, - stopBotFromMe: newSettings.stopBotFromMe, - keepOpen: newSettings.keepOpen, - debounceTime: newSettings.debounceTime, - n8nIdFallback: newSettings.n8nIdFallback, - ignoreJids: newSettings.ignoreJids, - splitMessages: newSettings.splitMessages, - timePerChar: newSettings.timePerChar, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - public async fetchSettings(instance: InstanceDto) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - include: { - Fallback: true, - }, - }); - - if (!settings) { - return { - expire: 0, - keywordFinish: '', - delayMessage: 0, - unknownMessage: '', - listeningFromMe: false, - stopBotFromMe: false, - keepOpen: false, - ignoreJids: [], - splitMessages: false, - timePerChar: 0, - n8nIdFallback: '', - fallback: null, - }; - } - - return { - expire: settings.expire, - keywordFinish: settings.keywordFinish, - delayMessage: settings.delayMessage, - unknownMessage: settings.unknownMessage, - listeningFromMe: settings.listeningFromMe, - stopBotFromMe: settings.stopBotFromMe, - keepOpen: settings.keepOpen, - ignoreJids: settings.ignoreJids, - splitMessages: settings.splitMessages, - timePerChar: settings.timePerChar, - n8nIdFallback: settings.n8nIdFallback, - fallback: settings.Fallback, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching default settings'); - } - } - - // Sessions - public async changeStatus(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const defaultSettingCheck = await this.settingsRepository.findFirst({ - where: { - instanceId, - }, - }); - - const remoteJid = data.remoteJid; - const status = data.status; - - if (status === 'delete') { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - - return { bot: { remoteJid: remoteJid, status: status } }; - } - - if (status === 'closed') { - if (defaultSettingCheck?.keepOpen) { - await this.sessionRepository.updateMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - }, - }); - } - - return { bot: { ...instance, bot: { remoteJid: remoteJid, status: status } } }; - } else { - const session = await this.sessionRepository.updateMany({ - where: { - instanceId: instanceId, - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: status, - }, - }); - - const botData = { - remoteJid: remoteJid, - status: status, - session, - }; - - return { bot: { ...instance, bot: botData } }; - } - } catch (error) { - this.logger.error(error); - throw new Error('Error changing status'); - } - } - - public async fetchSessions(instance: InstanceDto, botId: string, remoteJid?: string) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (bot && bot.instanceId !== instanceId) { - throw new Error('N8n not found'); - } - - return await this.sessionRepository.findMany({ - where: { - instanceId: instanceId, - remoteJid, - botId: bot ? botId : { not: null }, - type: 'n8n', - }, - }); - } catch (error) { - this.logger.error(error); - throw new Error('Error fetching sessions'); - } - } - - public async ignoreJid(instance: InstanceDto, data: IgnoreJidDto) { - if (!this.integrationEnabled) throw new BadRequestException('N8n is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - }); - - if (!settings) { - throw new Error('Settings not found'); - } - - let ignoreJids: any = settings?.ignoreJids || []; - - if (data.action === 'add') { - if (ignoreJids.includes(data.remoteJid)) return { ignoreJids: ignoreJids }; - - ignoreJids.push(data.remoteJid); - } else { - ignoreJids = ignoreJids.filter((jid) => jid !== data.remoteJid); - } - - const updateSettings = await this.settingsRepository.update({ - where: { - id: settings.id, - }, - data: { - ignoreJids: ignoreJids, - }, - }); - - return { - ignoreJids: updateSettings.ignoreJids, - }; - } catch (error) { - this.logger.error(error); - throw new Error('Error setting default settings'); - } - } - - // Emit - public async emit({ instance, remoteJid, msg }: EmitData) { - if (!this.integrationEnabled) return; - - try { - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (this.checkIgnoreJids(settings?.ignoreJids, remoteJid)) return; - - const session = await this.getSession(remoteJid, instance); - - const content = getConversationMessage(msg); - - let findBot = (await this.findBotTrigger(this.botRepository, content, instance, session)) as N8nModel; - - if (!findBot) { - const fallback = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (fallback?.n8nIdFallback) { - const findFallback = await this.botRepository.findFirst({ - where: { - id: fallback.n8nIdFallback, - }, - }); - - findBot = findFallback; - } else { - return; - } - } - - let expire = findBot?.expire; - let keywordFinish = findBot?.keywordFinish; - let delayMessage = findBot?.delayMessage; - let unknownMessage = findBot?.unknownMessage; - let listeningFromMe = findBot?.listeningFromMe; - let stopBotFromMe = findBot?.stopBotFromMe; - let keepOpen = findBot?.keepOpen; - let debounceTime = findBot?.debounceTime; - let ignoreJids = findBot?.ignoreJids; - let splitMessages = findBot?.splitMessages; - let timePerChar = findBot?.timePerChar; - - if (expire === undefined || expire === null) expire = settings.expire; - if (keywordFinish === undefined || keywordFinish === null) keywordFinish = settings.keywordFinish; - if (delayMessage === undefined || delayMessage === null) delayMessage = settings.delayMessage; - if (unknownMessage === undefined || unknownMessage === null) unknownMessage = settings.unknownMessage; - if (listeningFromMe === undefined || listeningFromMe === null) listeningFromMe = settings.listeningFromMe; - if (stopBotFromMe === undefined || stopBotFromMe === null) stopBotFromMe = settings.stopBotFromMe; - if (keepOpen === undefined || keepOpen === null) keepOpen = settings.keepOpen; - if (debounceTime === undefined || debounceTime === null) debounceTime = settings.debounceTime; - if (ignoreJids === undefined || ignoreJids === null) ignoreJids = settings.ignoreJids; - if (splitMessages === undefined || splitMessages === null) splitMessages = settings?.splitMessages ?? false; - if (timePerChar === undefined || timePerChar === null) timePerChar = settings?.timePerChar ?? 0; - - const key = msg.key as { - id: string; - remoteJid: string; - fromMe: boolean; - participant: string; - }; - - if (stopBotFromMe && key.fromMe && session) { - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'paused', - }, - }); - return; - } - - if (!listeningFromMe && key.fromMe) { - return; - } - - if (session && !session.awaitUser) { - return; - } - - if (debounceTime && debounceTime > 0) { - this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { - await this.n8nService.processN8n( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - debouncedContent, - msg?.pushName, - ); - }); - } else { - await this.n8nService.processN8n( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - content, - msg?.pushName, - ); - } - - return; - } catch (error) { - this.logger.error(error); - return; - } + // Process N8n-specific bot logic + protected async processBot( + instance: any, + remoteJid: string, + bot: N8nModel, + session: IntegrationSession, + settings: any, + content: string, + pushName?: string, + msg?: any, + ) { + this.n8nService.process(instance, remoteJid, bot, session, settings, content, pushName, msg); } } diff --git a/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts b/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts index fb3df189..1c9b1c86 100644 --- a/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts +++ b/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts @@ -1,18 +1,18 @@ import { TriggerOperator, TriggerType } from '@prisma/client'; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; -export class N8nDto { - enabled?: boolean; - description?: string; +export class N8nDto extends BaseChatbotDto { + // N8n specific fields webhookUrl?: string; basicAuthUser?: string; basicAuthPass?: string; // Advanced bot properties (copied from DifyDto style) - triggerType?: TriggerType; + triggerType: TriggerType; triggerOperator?: TriggerOperator; triggerValue?: string; expire?: number; - keywordFinish?: string; + keywordFinish?: string[]; delayMessage?: number; unknownMessage?: string; listeningFromMe?: boolean; @@ -24,8 +24,8 @@ export class N8nDto { timePerChar?: number; } -export class N8nSettingDto { - // Add settings fields here if needed for compatibility +export class N8nSettingDto extends BaseChatbotSettingDto { + // N8n specific fields } export class N8nMessageDto { diff --git a/src/api/integrations/chatbot/n8n/services/n8n.service.ts b/src/api/integrations/chatbot/n8n/services/n8n.service.ts index a2556882..461218bf 100644 --- a/src/api/integrations/chatbot/n8n/services/n8n.service.ts +++ b/src/api/integrations/chatbot/n8n/services/n8n.service.ts @@ -1,22 +1,29 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { PrismaRepository } from '@api/repository/repository.service'; import { WAMonitoringService } from '@api/services/monitor.service'; -import { Logger } from '@config/logger.config'; +import { ConfigService } from '@config/env.config'; import { IntegrationSession, N8n, N8nSetting } from '@prisma/client'; import { sendTelemetry } from '@utils/sendTelemetry'; import axios from 'axios'; +import { downloadMediaMessage } from 'baileys'; +import { BaseChatbotService } from '../../base-chatbot.service'; import { N8nDto } from '../dto/n8n.dto'; -export class N8nService { - private readonly logger = new Logger('N8nService'); - private readonly waMonitor: WAMonitoringService; - +export class N8nService extends BaseChatbotService { constructor( waMonitor: WAMonitoringService, - private readonly prismaRepository: PrismaRepository, + prismaRepository: PrismaRepository, + configService: ConfigService, ) { - this.waMonitor = waMonitor; + super(waMonitor, prismaRepository, 'N8nService', configService); + } + + /** + * Return the bot type for N8n + */ + protected getBotType(): string { + return 'n8n'; } /** @@ -122,40 +129,10 @@ export class N8nService { } public async createNewSession(instance: InstanceDto, data: any) { - try { - const session = await this.prismaRepository.integrationSession.create({ - data: { - remoteJid: data.remoteJid, - pushName: data.pushName, - sessionId: data.remoteJid, - status: 'opened', - awaitUser: false, - botId: data.botId, - instanceId: instance.instanceId, - type: 'n8n', - }, - }); - return { session }; - } catch (error) { - this.logger.error(error); - return; - } + return super.createNewSession(instance, data, 'n8n'); } - private isImageMessage(content: string) { - return content.includes('imageMessage'); - } - - private isJSON(str: string): boolean { - try { - JSON.parse(str); - return true; - } catch (e) { - return false; - } - } - - private async sendMessageToBot( + protected async sendMessageToBot( instance: any, session: IntegrationSession, settings: N8nSetting, @@ -163,6 +140,7 @@ export class N8nService { remoteJid: string, pushName: string, content: string, + msg?: any, ) { try { const endpoint: string = n8n.webhookUrl; @@ -170,6 +148,24 @@ export class N8nService { chatInput: content, sessionId: session.sessionId, }; + + // Handle audio messages + if (this.isAudioMessage(content) && msg) { + try { + this.logger.debug(`[N8n] Downloading audio for Whisper transcription`); + const mediaBuffer = await downloadMediaMessage({ key: msg.key, message: msg.message }, 'buffer', {}); + const transcribedText = await this.speechToText(mediaBuffer); + if (transcribedText) { + payload.chatInput = transcribedText; + } else { + payload.chatInput = '[Audio message could not be transcribed]'; + } + } catch (err) { + this.logger.error(`[N8n] Failed to transcribe audio: ${err}`); + payload.chatInput = '[Audio message could not be transcribed]'; + } + } + const headers: Record = {}; if (n8n.basicAuthUser && n8n.basicAuthPass) { const auth = Buffer.from(`${n8n.basicAuthUser}:${n8n.basicAuthPass}`).toString('base64'); @@ -193,45 +189,39 @@ export class N8nService { } } - private async sendMessageWhatsApp(instance: any, remoteJid: string, message: string, settings: N8nSetting) { + protected async sendMessageWhatsApp(instance: any, remoteJid: string, message: string, settings: N8nSetting) { const linkRegex = /(!?)\[(.*?)\]\((.*?)\)/g; let textBuffer = ''; let lastIndex = 0; let match: RegExpExecArray | null; - const getMediaType = (url: string): string | null => { - const extension = url.split('.').pop()?.toLowerCase(); - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; - const audioExtensions = ['mp3', 'wav', 'aac', 'ogg']; - const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']; - const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']; - if (imageExtensions.includes(extension || '')) return 'image'; - if (audioExtensions.includes(extension || '')) return 'audio'; - if (videoExtensions.includes(extension || '')) return 'video'; - if (documentExtensions.includes(extension || '')) return 'document'; - return null; - }; + while ((match = linkRegex.exec(message)) !== null) { - const [altText, url] = match; - const mediaType = getMediaType(url); + const [fullMatch, exclamation, altText, url] = match; + const mediaType = this.getMediaType(url); const beforeText = message.slice(lastIndex, match.index); + if (beforeText) { textBuffer += beforeText; } + if (mediaType) { const splitMessages = settings.splitMessages ?? false; const timePerChar = settings.timePerChar ?? 0; const minDelay = 1000; const maxDelay = 20000; + if (textBuffer.trim()) { if (splitMessages) { const multipleMessages = textBuffer.trim().split('\n\n'); for (let index = 0; index < multipleMessages.length; index++) { const message = multipleMessages[index]; const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); + if (instance.integration === 'WHATSAPP_BAILEYS') { await instance.client.presenceSubscribe(remoteJid); await instance.client.sendPresenceUpdate('composing', remoteJid); } + await new Promise((resolve) => { setTimeout(async () => { await instance.textMessage( @@ -245,67 +235,103 @@ export class N8nService { resolve(); }, delay); }); + if (instance.integration === 'WHATSAPP_BAILEYS') { await instance.client.sendPresenceUpdate('paused', remoteJid); } } } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); + const delay = Math.min(Math.max(textBuffer.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === 'WHATSAPP_BAILEYS') { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: textBuffer, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === 'WHATSAPP_BAILEYS') { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } } - textBuffer = ''; } - if (mediaType === 'audio') { - await instance.audioWhatsapp({ + + textBuffer = ''; + + if (mediaType === 'image') { + await instance.mediaMessage({ number: remoteJid.split('@')[0], delay: settings?.delayMessage || 1000, - audio: url, - caption: altText, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'image', + media: url, + }); + } else if (mediaType === 'video') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'video', + media: url, + }); + } else if (mediaType === 'audio') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + mediatype: 'audio', + media: url, + }); + } else if (mediaType === 'document') { + await instance.mediaMessage({ + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + caption: exclamation === '!' ? undefined : altText, + mediatype: 'document', + media: url, + fileName: altText || 'file', }); - } else { - await instance.mediaMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - mediatype: mediaType, - media: url, - caption: altText, - }, - null, - false, - ); } } else { textBuffer += `[${altText}](${url})`; } - lastIndex = linkRegex.lastIndex; + + lastIndex = match.index + fullMatch.length; } - if (lastIndex < message.length) { - const remainingText = message.slice(lastIndex); - if (remainingText.trim()) { - textBuffer += remainingText; - } + + const remainingText = message.slice(lastIndex); + if (remainingText) { + textBuffer += remainingText; } - const splitMessages = settings.splitMessages ?? false; - const timePerChar = settings.timePerChar ?? 0; - const minDelay = 1000; - const maxDelay = 20000; + if (textBuffer.trim()) { + const splitMessages = settings.splitMessages ?? false; + const timePerChar = settings.timePerChar ?? 0; + const minDelay = 1000; + const maxDelay = 20000; + if (splitMessages) { const multipleMessages = textBuffer.trim().split('\n\n'); for (let index = 0; index < multipleMessages.length; index++) { const message = multipleMessages[index]; const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay); + if (instance.integration === 'WHATSAPP_BAILEYS') { await instance.client.presenceSubscribe(remoteJid); await instance.client.sendPresenceUpdate('composing', remoteJid); } + await new Promise((resolve) => { setTimeout(async () => { await instance.textMessage( @@ -319,25 +345,41 @@ export class N8nService { resolve(); }, delay); }); + if (instance.integration === 'WHATSAPP_BAILEYS') { await instance.client.sendPresenceUpdate('paused', remoteJid); } } } else { - await instance.textMessage( - { - number: remoteJid.split('@')[0], - delay: settings?.delayMessage || 1000, - text: textBuffer.trim(), - }, - false, - ); + const delay = Math.min(Math.max(textBuffer.length * timePerChar, minDelay), maxDelay); + + if (instance.integration === 'WHATSAPP_BAILEYS') { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + await new Promise((resolve) => { + setTimeout(async () => { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: textBuffer, + }, + false, + ); + resolve(); + }, delay); + }); + + if (instance.integration === 'WHATSAPP_BAILEYS') { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } } } - sendTelemetry('/message/sendText'); } - private async initNewSession( + protected async initNewSession( instance: any, remoteJid: string, n8n: N8n, @@ -345,88 +387,97 @@ export class N8nService { session: IntegrationSession, content: string, pushName?: string, + msg?: any, ) { - const data = await this.createNewSession(instance, { - remoteJid, - pushName, - botId: n8n.id, - }); - if (data.session) { - session = data.session; - } - await this.sendMessageToBot(instance, session, settings, n8n, remoteJid, pushName, content); - return; - } - - public async processN8n( - instance: any, - remoteJid: string, - n8n: N8n, - session: IntegrationSession, - settings: N8nSetting, - content: string, - pushName?: string, - ) { - if (session && session.status !== 'opened') { + try { + await this.sendMessageToBot(instance, session, settings, n8n, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(error); return; } - if (session && settings.expire && settings.expire > 0) { - const now = Date.now(); - const sessionUpdatedAt = new Date(session.updatedAt).getTime(); - const diff = now - sessionUpdatedAt; - const diffInMinutes = Math.floor(diff / 1000 / 60); - if (diffInMinutes > settings.expire) { - if (settings.keepOpen) { + } + + public async process( + instance: any, + remoteJid: string, + n8n: N8n, + session: IntegrationSession, + settings: N8nSetting, + content: string, + pushName?: string, + msg?: any, + ) { + try { + // Handle keyword finish + if (settings?.keywordFinish?.includes(content.toLowerCase())) { + if (settings?.keepOpen) { await this.prismaRepository.integrationSession.update({ - where: { id: session.id }, - data: { status: 'closed' }, + where: { + id: session.id, + }, + data: { + status: 'closed', + }, }); } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { botId: n8n.id, remoteJid: remoteJid }, + await this.prismaRepository.integrationSession.delete({ + where: { + id: session.id, + }, }); } - await this.initNewSession(instance, remoteJid, n8n, settings, session, content, pushName); + return; } - } - if (!session) { - await this.initNewSession(instance, remoteJid, n8n, settings, session, content, pushName); - return; - } - await this.prismaRepository.integrationSession.update({ - where: { id: session.id }, - data: { status: 'opened', awaitUser: false }, - }); - if (!content) { - if (settings.unknownMessage) { - this.waMonitor.waInstances[instance.instanceName].textMessage( - { - number: remoteJid.split('@')[0], - delay: settings.delayMessage || 1000, - text: settings.unknownMessage, - }, - false, + + // If session is new or doesn't exist + if (!session) { + const data = { + remoteJid, + pushName, + botId: n8n.id, + }; + + const createSession = await this.createNewSession( + { instanceName: instance.instanceName, instanceId: instance.instanceId }, + data, ); - sendTelemetry('/message/sendText'); + await this.initNewSession( + instance, + remoteJid, + n8n, + settings, + createSession.session, + content, + pushName, + msg, + ); + + await sendTelemetry('/n8n/session/start'); + return; } - return; - } - if (settings.keywordFinish && content.toLowerCase() === settings.keywordFinish.toLowerCase()) { - if (settings.keepOpen) { + + // If session exists but is paused + if (session.status === 'paused') { await this.prismaRepository.integrationSession.update({ - where: { id: session.id }, - data: { status: 'closed' }, - }); - } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { botId: n8n.id, remoteJid: remoteJid }, + where: { + id: session.id, + }, + data: { + status: 'opened', + awaitUser: true, + }, }); + + return; } + + // Regular message for ongoing session + await this.sendMessageToBot(instance, session, settings, n8n, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(error); return; } - await this.sendMessageToBot(instance, session, settings, n8n, remoteJid, pushName, content); - return; } } diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 5070ca06..a9aca0a7 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -131,7 +131,7 @@ export const evolutionBotController = new EvolutionBotController(evolutionBotSer const flowiseService = new FlowiseService(waMonitor, configService, prismaRepository); export const flowiseController = new FlowiseController(flowiseService, prismaRepository, waMonitor); -const n8nService = new N8nService(waMonitor, prismaRepository); +const n8nService = new N8nService(waMonitor, prismaRepository, configService); export const n8nController = new N8nController(n8nService, prismaRepository, waMonitor); const evoaiService = new EvoaiService(waMonitor, prismaRepository, configService);