diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index dba02751..b3b937fe 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -165,11 +165,7 @@ export class EvolutionStartupService extends ChannelStartupService { openAiDefaultSettings.speechToText && received?.message?.audioMessage ) { - messageRaw.message.speechToText = await this.openaiService.speechToText( - openAiDefaultSettings.OpenaiCreds, - received, - this.client.updateMediaMessage, - ); + messageRaw.message.speechToText = await this.openaiService.speechToText(received); } } diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index ba617c5b..bcfd1ce4 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -501,16 +501,12 @@ export class BusinessStartupService extends ChannelStartupService { openAiDefaultSettings.speechToText && audioMessage ) { - messageRaw.message.speechToText = await this.openaiService.speechToText( - openAiDefaultSettings.OpenaiCreds, - { - message: { - mediaUrl: messageRaw.message.mediaUrl, - ...messageRaw, - }, + messageRaw.message.speechToText = await this.openaiService.speechToText({ + message: { + mediaUrl: messageRaw.message.mediaUrl, + ...messageRaw, }, - () => {}, - ); + }); } } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index ddb73d5f..d09b8c26 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1298,11 +1298,7 @@ export class BaileysStartupService extends ChannelStartupService { }); if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { - messageRaw.message.speechToText = await this.openaiService.speechToText( - openAiDefaultSettings.OpenaiCreds, - received, - this.client.updateMediaMessage, - ); + messageRaw.message.speechToText = await this.openaiService.speechToText(received); } } @@ -2328,11 +2324,7 @@ export class BaileysStartupService extends ChannelStartupService { }); if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { - messageRaw.message.speechToText = await this.openaiService.speechToText( - openAiDefaultSettings.OpenaiCreds, - messageRaw, - this.client.updateMediaMessage, - ); + messageRaw.message.speechToText = await this.openaiService.speechToText(messageRaw); } } 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..abe0bc8d --- /dev/null +++ b/src/api/integrations/chatbot/base-chatbot.controller.ts @@ -0,0 +1,930 @@ +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, + Instance: { + connect: { + id: 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); + } + } +} 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..7ba3f50f --- /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 +} 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..b1c148a5 --- /dev/null +++ b/src/api/integrations/chatbot/base-chatbot.service.ts @@ -0,0 +1,448 @@ +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 { IntegrationSession } from '@prisma/client'; +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; +} diff --git a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts index 05834fb3..5d2c9b5f 100644 --- a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts +++ b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts @@ -1,4 +1,3 @@ -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 +6,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 +24,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 +32,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 +92,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 +106,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 +162,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..ab3bf438 100644 --- a/src/api/integrations/chatbot/dify/dto/dify.dto.ts +++ b/src/api/integrations/chatbot/dify/dto/dify.dto.ts @@ -1,38 +1,14 @@ import { $Enums, TriggerOperator, TriggerType } from '@prisma/client'; -export class DifyDto { - enabled?: boolean; - description?: string; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; + +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..01e433f6 100644 --- a/src/api/integrations/chatbot/dify/services/dify.service.ts +++ b/src/api/integrations/chatbot/dify/services/dify.service.ts @@ -1,60 +1,32 @@ -/* 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 { - constructor( - private readonly waMonitor: WAMonitoringService, - private readonly configService: ConfigService, - private readonly prismaRepository: PrismaRepository, - ) {} +import { BaseChatbotService } from '../../base-chatbot.service'; - private readonly logger = new Logger('DifyService'); +export class DifyService extends BaseChatbotService { + constructor(waMonitor: WAMonitoringService, configService: ConfigService, prismaRepository: PrismaRepository) { + super(waMonitor, prismaRepository, 'DifyService', configService); + } + + /** + * 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 +34,7 @@ export class DifyService { remoteJid: string, pushName: string, content: string, + msg?: any, ) { try { let endpoint: string = dify.apiUrl; @@ -82,6 +55,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -95,6 +69,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); await instance.client.sendPresenceUpdate('composing', remoteJid); @@ -142,6 +133,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -155,6 +147,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); await instance.client.sendPresenceUpdate('composing', remoteJid); @@ -202,6 +211,7 @@ export class DifyService { user: remoteJid, }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); @@ -215,6 +225,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); await instance.client.sendPresenceUpdate('composing', remoteJid); @@ -248,9 +275,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 +284,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 +294,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 +318,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 +346,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 +456,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 +492,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 +525,56 @@ 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..342a8863 100644 --- a/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts +++ b/src/api/integrations/chatbot/evoai/dto/evoai.dto.ts @@ -1,37 +1,13 @@ import { TriggerOperator, TriggerType } from '@prisma/client'; -export class EvoaiDto { - enabled?: boolean; - description?: string; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; + +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..4c282cf3 100644 --- a/src/api/integrations/chatbot/evoai/services/evoai.service.ts +++ b/src/api/integrations/chatbot/evoai/services/evoai.service.ts @@ -1,84 +1,81 @@ -/* 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 +86,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}`); + // Handle image message if present + if (this.isImageMessage(content) && msg) { + const contentSplit = content.split('|'); + parts[0].text = contentSplit[2] || 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}`); - } + 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 +198,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..95f3a6a2 100644 --- a/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts +++ b/src/api/integrations/chatbot/n8n/dto/n8n.dto.ts @@ -1,18 +1,19 @@ import { TriggerOperator, TriggerType } from '@prisma/client'; -export class N8nDto { - enabled?: boolean; - description?: string; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; + +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 +25,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..39fbf6a8 100644 --- a/src/api/integrations/chatbot/n8n/services/n8n.service.ts +++ b/src/api/integrations/chatbot/n8n/services/n8n.service.ts @@ -1,22 +1,25 @@ 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, prismaRepository: PrismaRepository, configService: ConfigService) { + super(waMonitor, prismaRepository, 'N8nService', configService); + } - constructor( - waMonitor: WAMonitoringService, - private readonly prismaRepository: PrismaRepository, - ) { - this.waMonitor = waMonitor; + /** + * Return the bot type for N8n + */ + protected getBotType(): string { + return 'n8n'; } /** @@ -122,40 +125,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 +136,7 @@ export class N8nService { remoteJid: string, pushName: string, content: string, + msg?: any, ) { try { const endpoint: string = n8n.webhookUrl; @@ -170,6 +144,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 +185,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 +231,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 +341,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 +383,88 @@ 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/integrations/chatbot/openai/controllers/openai.controller.ts b/src/api/integrations/chatbot/openai/controllers/openai.controller.ts index 4bb2bcdb..44f2005a 100644 --- a/src/api/integrations/chatbot/openai/controllers/openai.controller.ts +++ b/src/api/integrations/chatbot/openai/controllers/openai.controller.ts @@ -7,13 +7,12 @@ import { WAMonitoringService } from '@api/services/monitor.service'; import { configService, Openai } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; -import { OpenaiBot } from '@prisma/client'; -import { getConversationMessage } from '@utils/getConversationMessage'; +import { IntegrationSession, OpenaiBot } from '@prisma/client'; import OpenAI from 'openai'; -import { ChatbotController, ChatbotControllerInterface, EmitData } from '../../chatbot.controller'; +import { BaseChatbotController } from '../../base-chatbot.controller'; -export class OpenaiController extends ChatbotController implements ChatbotControllerInterface { +export class OpenaiController extends BaseChatbotController { constructor( private readonly openaiService: OpenaiService, prismaRepository: PrismaRepository, @@ -28,6 +27,7 @@ export class OpenaiController extends ChatbotController implements ChatbotContro } public readonly logger = new Logger('OpenaiController'); + protected readonly integrationName = 'Openai'; integrationEnabled = configService.get('OPENAI').ENABLED; botRepository: any; @@ -37,7 +37,243 @@ export class OpenaiController extends ChatbotController implements ChatbotContro private client: OpenAI; private credsRepository: any; - // Credentials + protected getFallbackBotId(settings: any): string | undefined { + return settings?.openaiIdFallback; + } + + protected getFallbackFieldName(): string { + return 'openaiIdFallback'; + } + + protected getIntegrationType(): string { + return 'openai'; + } + + protected getAdditionalBotData(data: OpenaiDto): Record { + return { + openaiCredsId: data.openaiCredsId, + botType: data.botType, + assistantId: data.assistantId, + functionUrl: data.functionUrl, + model: data.model, + systemMessages: data.systemMessages, + assistantMessages: data.assistantMessages, + userMessages: data.userMessages, + maxTokens: data.maxTokens, + }; + } + + // Implementation for bot-specific updates + protected getAdditionalUpdateFields(data: OpenaiDto): Record { + return { + openaiCredsId: data.openaiCredsId, + botType: data.botType, + assistantId: data.assistantId, + functionUrl: data.functionUrl, + model: data.model, + systemMessages: data.systemMessages, + assistantMessages: data.assistantMessages, + userMessages: data.userMessages, + maxTokens: data.maxTokens, + }; + } + + // Implementation for bot-specific duplicate validation on update + protected async validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: OpenaiDto): Promise { + let whereDuplication: any = { + id: { + not: botId, + }, + instanceId: instanceId, + }; + + if (data.botType === 'assistant') { + if (!data.assistantId) throw new Error('Assistant ID is required'); + + whereDuplication = { + ...whereDuplication, + assistantId: data.assistantId, + botType: data.botType, + }; + } else if (data.botType === 'chatCompletion') { + if (!data.model) throw new Error('Model is required'); + if (!data.maxTokens) throw new Error('Max tokens is required'); + + whereDuplication = { + ...whereDuplication, + model: data.model, + maxTokens: data.maxTokens, + botType: data.botType, + }; + } else { + throw new Error('Bot type is required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: whereDuplication, + }); + + if (checkDuplicate) { + throw new Error('OpenAI Bot already exists'); + } + } + + // Bots + public async createBot(instance: InstanceDto, data: OpenaiDto) { + if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + // OpenAI specific validation + let whereDuplication: any = { + instanceId: instanceId, + }; + + if (data.botType === 'assistant') { + if (!data.assistantId) throw new Error('Assistant ID is required'); + + whereDuplication = { + ...whereDuplication, + assistantId: data.assistantId, + botType: data.botType, + }; + } else if (data.botType === 'chatCompletion') { + if (!data.model) throw new Error('Model is required'); + if (!data.maxTokens) throw new Error('Max tokens is required'); + + whereDuplication = { + ...whereDuplication, + model: data.model, + maxTokens: data.maxTokens, + botType: data.botType, + }; + } else { + throw new Error('Bot type is required'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: whereDuplication, + }); + + if (checkDuplicate) { + throw new Error('Openai Bot already exists'); + } + + // Check if settings exist and create them if not + const existingSettings = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + }); + + if (!existingSettings) { + // Create default settings with the OpenAI credentials + await this.settings(instance, { + openaiCredsId: data.openaiCredsId, + expire: data.expire || 300, + keywordFinish: data.keywordFinish || 'bye,exit,quit,stop', + delayMessage: data.delayMessage || 1000, + unknownMessage: data.unknownMessage || 'Sorry, I dont understand', + listeningFromMe: data.listeningFromMe !== undefined ? data.listeningFromMe : true, + stopBotFromMe: data.stopBotFromMe !== undefined ? data.stopBotFromMe : true, + keepOpen: data.keepOpen !== undefined ? data.keepOpen : false, + debounceTime: data.debounceTime || 1, + ignoreJids: data.ignoreJids || [], + speechToText: false, + }); + } else if (!existingSettings.openaiCredsId && data.openaiCredsId) { + // Update settings with OpenAI credentials if they're missing + await this.settingsRepository.update({ + where: { + id: existingSettings.id, + }, + data: { + OpenaiCreds: { + connect: { + id: data.openaiCredsId, + }, + }, + }, + }); + } + + // Let the base class handle the rest of the bot creation process + return super.createBot(instance, data); + } + + public async findBot(instance: InstanceDto) { + if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + const bots = await this.botRepository.findMany({ + where: { + instanceId: instanceId, + }, + }); + + if (!bots.length) { + return null; + } + + return bots; + } + + public async fetchBot(instance: InstanceDto, botId: string) { + if (!this.integrationEnabled) throw new BadRequestException('Openai 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('Openai Bot not found'); + } + + if (bot.instanceId !== instanceId) { + throw new Error('Openai Bot not found'); + } + + return bot; + } + + // Process OpenAI-specific bot logic + protected async processBot( + instance: any, + remoteJid: string, + bot: OpenaiBot, + session: IntegrationSession, + settings: any, + content: string, + pushName?: string, + msg?: any, + ) { + await this.openaiService.process(instance, remoteJid, bot, session, settings, content, pushName, msg); + } + + // Credentials - OpenAI specific functionality public async createOpenaiCreds(instance: InstanceDto, data: OpenaiCredsDto) { if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); @@ -130,7 +366,90 @@ export class OpenaiController extends ChatbotController implements ChatbotContro } } - // Models + // Override the settings method to handle the OpenAI credentials + public async settings(instance: InstanceDto, data: any) { + if (!this.integrationEnabled) throw new BadRequestException('Openai 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, + }, + }); + + // Convert keywordFinish to string if it's an array + const keywordFinish = Array.isArray(data.keywordFinish) ? data.keywordFinish.join(',') : data.keywordFinish; + + // Additional OpenAI-specific fields + const settingsData = { + expire: data.expire, + 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, + openaiIdFallback: data.fallbackId, + OpenaiCreds: data.openaiCredsId + ? { + connect: { + id: data.openaiCredsId, + }, + } + : undefined, + speechToText: data.speechToText, + }; + + 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.openaiIdFallback, + }; + } else { + const settings = await this.settingsRepository.create({ + data: { + ...settingsData, + Instance: { + connect: { + id: instanceId, + }, + }, + }, + }); + + // Map the specific fallback field to a generic 'fallbackId' in the response + return { + ...settings, + fallbackId: settings.openaiIdFallback, + }; + } + } catch (error) { + this.logger.error(error); + throw new Error('Error setting default settings'); + } + } + + // Models - OpenAI specific functionality public async getModels(instance: InstanceDto) { if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); @@ -155,6 +474,9 @@ export class OpenaiController extends ChatbotController implements ChatbotContro if (!defaultSettings) throw new Error('Settings not found'); + if (!defaultSettings.OpenaiCreds) + throw new Error('OpenAI credentials not found. Please create credentials and associate them with the settings.'); + const { apiKey } = defaultSettings.OpenaiCreds; try { @@ -168,959 +490,4 @@ export class OpenaiController extends ChatbotController implements ChatbotContro throw new Error('Error fetching models'); } } - - // Bots - public async createBot(instance: InstanceDto, data: OpenaiDto) { - if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - if ( - !data.openaiCredsId || - !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 (!data.openaiCredsId) { - throw new Error('Openai Creds Id is required'); - } - - if (!defaultSettingCheck) { - await this.settings(instance, { - openaiCredsId: data.openaiCredsId, - 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 openai with an "All" trigger, you cannot have more bots while it is active'); - } - - let whereDuplication: any = { - instanceId: instanceId, - }; - - if (data.botType === 'assistant') { - if (!data.assistantId) throw new Error('Assistant ID is required'); - - whereDuplication = { - ...whereDuplication, - assistantId: data.assistantId, - botType: data.botType, - }; - } else if (data.botType === 'chatCompletion') { - if (!data.model) throw new Error('Model is required'); - if (!data.maxTokens) throw new Error('Max tokens is required'); - - whereDuplication = { - ...whereDuplication, - model: data.model, - maxTokens: data.maxTokens, - botType: data.botType, - }; - } else { - throw new Error('Bot type is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: whereDuplication, - }); - - if (checkDuplicate) { - throw new Error('Openai Bot 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, - openaiCredsId: data.openaiCredsId, - botType: data.botType, - assistantId: data.assistantId, - functionUrl: data.functionUrl, - model: data.model, - systemMessages: data.systemMessages, - assistantMessages: data.assistantMessages, - userMessages: data.userMessages, - maxTokens: data.maxTokens, - 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 openai bot'); - } - } - - public async findBot(instance: InstanceDto) { - if (!this.integrationEnabled) throw new BadRequestException('Openai is disabled'); - - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const bots = await this.botRepository.findMany({ - where: { - instanceId, - }, - }); - - if (!bots.length) { - return null; - } - - return bots; - } - - public async fetchBot(instance: InstanceDto, botId: string) { - if (!this.integrationEnabled) throw new BadRequestException('Openai 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('Openai Bot not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Openai Bot not found'); - } - - return bot; - } - - public async updateBot(instance: InstanceDto, botId: string, data: OpenaiDto) { - if (!this.integrationEnabled) throw new BadRequestException('Openai 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('Openai Bot not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Openai Bot 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 openai bot with an "All" trigger, you cannot have more bots while it is active', - ); - } - } - - let whereDuplication: any = { - id: { - not: botId, - }, - instanceId: instanceId, - }; - - if (data.botType === 'assistant') { - if (!data.assistantId) throw new Error('Assistant ID is required'); - - whereDuplication = { - ...whereDuplication, - assistantId: data.assistantId, - }; - } else if (data.botType === 'chatCompletion') { - if (!data.model) throw new Error('Model is required'); - if (!data.maxTokens) throw new Error('Max tokens is required'); - - whereDuplication = { - ...whereDuplication, - model: data.model, - maxTokens: data.maxTokens, - }; - } else { - throw new Error('Bot type is required'); - } - - const checkDuplicate = await this.botRepository.findFirst({ - where: whereDuplication, - }); - - if (checkDuplicate) { - throw new Error('Openai Bot 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, - openaiCredsId: data.openaiCredsId, - botType: data.botType, - assistantId: data.assistantId, - functionUrl: data.functionUrl, - model: data.model, - systemMessages: data.systemMessages, - assistantMessages: data.assistantMessages, - userMessages: data.userMessages, - maxTokens: data.maxTokens, - 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 openai bot'); - } - } - - public async deleteBot(instance: InstanceDto, botId: string) { - if (!this.integrationEnabled) throw new BadRequestException('Openai 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('Openai bot not found'); - } - - if (bot.instanceId !== instanceId) { - throw new Error('Openai bot not found'); - } - try { - await this.sessionRepository.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 openai bot'); - } - } - - // Settings - public async settings(instance: InstanceDto, data: any) { - if (!this.integrationEnabled) throw new BadRequestException('Openai 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: { - openaiCredsId: data.openaiCredsId, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - speechToText: data.speechToText, - openaiIdFallback: data.openaiIdFallback, - ignoreJids: data.ignoreJids, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - openaiCredsId: updateSettings.openaiCredsId, - expire: updateSettings.expire, - keywordFinish: updateSettings.keywordFinish, - delayMessage: updateSettings.delayMessage, - unknownMessage: updateSettings.unknownMessage, - listeningFromMe: updateSettings.listeningFromMe, - stopBotFromMe: updateSettings.stopBotFromMe, - keepOpen: updateSettings.keepOpen, - debounceTime: updateSettings.debounceTime, - speechToText: updateSettings.speechToText, - openaiIdFallback: updateSettings.openaiIdFallback, - ignoreJids: updateSettings.ignoreJids, - splitMessages: updateSettings.splitMessages, - timePerChar: updateSettings.timePerChar, - }; - } - - const newSetttings = await this.settingsRepository.create({ - data: { - openaiCredsId: data.openaiCredsId, - expire: data.expire, - keywordFinish: data.keywordFinish, - delayMessage: data.delayMessage, - unknownMessage: data.unknownMessage, - listeningFromMe: data.listeningFromMe, - stopBotFromMe: data.stopBotFromMe, - keepOpen: data.keepOpen, - debounceTime: data.debounceTime, - openaiIdFallback: data.openaiIdFallback, - ignoreJids: data.ignoreJids, - speechToText: data.speechToText, - instanceId: instanceId, - splitMessages: data.splitMessages, - timePerChar: data.timePerChar, - }, - }); - - return { - openaiCredsId: newSetttings.openaiCredsId, - expire: newSetttings.expire, - keywordFinish: newSetttings.keywordFinish, - delayMessage: newSetttings.delayMessage, - unknownMessage: newSetttings.unknownMessage, - listeningFromMe: newSetttings.listeningFromMe, - stopBotFromMe: newSetttings.stopBotFromMe, - keepOpen: newSetttings.keepOpen, - debounceTime: newSetttings.debounceTime, - openaiIdFallback: newSetttings.openaiIdFallback, - ignoreJids: newSetttings.ignoreJids, - speechToText: newSetttings.speechToText, - 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('Openai is disabled'); - - try { - const instanceId = ( - await this.prismaRepository.instance.findFirst({ - select: { id: true }, - where: { - name: instance.instanceName, - }, - }) - )?.id; - - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instanceId, - }, - include: { - Fallback: true, - }, - }); - - if (!settings) { - return { - openaiCredsId: null, - expire: 0, - keywordFinish: '', - delayMessage: 0, - unknownMessage: '', - listeningFromMe: false, - stopBotFromMe: false, - keepOpen: false, - ignoreJids: [], - splitMessages: false, - timePerChar: 0, - openaiIdFallback: null, - speechToText: false, - fallback: null, - }; - } - - return { - openaiCredsId: settings.openaiCredsId, - 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, - openaiIdFallback: settings.openaiIdFallback, - speechToText: settings.speechToText, - 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('Openai 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 { openai: { remoteJid: remoteJid, status: status } }; - } - - if (status === 'closed') { - if (defaultSettingCheck?.keepOpen) { - await this.sessionRepository.updateMany({ - where: { - remoteJid: remoteJid, - botId: { not: null }, - status: { not: 'closed' }, - }, - data: { - status: 'closed', - }, - }); - } else { - await this.sessionRepository.deleteMany({ - where: { - remoteJid: remoteJid, - }, - }); - } - - return { openai: { ...instance, openai: { remoteJid: remoteJid, status: status } } }; - } else { - const session = await this.sessionRepository.updateMany({ - where: { - instanceId: instanceId, - remoteJid: remoteJid, - botId: { not: null }, - }, - data: { - status: status, - }, - }); - - const openaiData = { - remoteJid: remoteJid, - status: status, - session, - }; - - return { openai: { ...instance, openai: openaiData } }; - } - } 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('Openai is disabled'); - - try { - const instanceId = await this.prismaRepository.instance - .findFirst({ - where: { - name: instance.instanceName, - }, - }) - .then((instance) => instance.id); - - const openaiBot = await this.botRepository.findFirst({ - where: { - id: botId, - }, - }); - - if (openaiBot && openaiBot.instanceId !== instanceId) { - throw new Error('Openai Bot not found'); - } - - return await this.sessionRepository.findMany({ - where: { - instanceId: instanceId, - remoteJid, - botId: openaiBot ? botId : { not: null }, - type: 'openai', - }, - }); - } 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('Openai 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, pushName }: EmitData) { - if (!this.integrationEnabled) return; - - try { - const settings = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (this.checkIgnoreJids(settings?.ignoreJids, remoteJid)) return; - - let session = await this.getSession(remoteJid, instance); - - const content = getConversationMessage(msg); - - let findBot = (await this.findBotTrigger(this.botRepository, content, instance, session)) as OpenaiBot; - - if (!findBot) { - const fallback = await this.settingsRepository.findFirst({ - where: { - instanceId: instance.instanceId, - }, - }); - - if (fallback?.openaiIdFallback) { - const findFallback = await this.botRepository.findFirst({ - where: { - id: fallback.openaiIdFallback, - }, - }); - - 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) { - session = await this.sessionRepository.update({ - where: { - id: session.id, - }, - data: { - status: 'paused', - }, - }); - } - - if (!listeningFromMe && key.fromMe) { - return; - } - - if (session && !session.awaitUser) { - return; - } - - if (debounceTime && debounceTime > 0) { - this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { - if (findBot.botType === 'assistant') { - await this.openaiService.processOpenaiAssistant( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - pushName, - key.fromMe, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - debouncedContent, - ); - } - - if (findBot.botType === 'chatCompletion') { - await this.openaiService.processOpenaiChatCompletion( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - pushName, - findBot, - session, - { - ...settings, - expire, - keywordFinish, - delayMessage, - unknownMessage, - listeningFromMe, - stopBotFromMe, - keepOpen, - debounceTime, - ignoreJids, - splitMessages, - timePerChar, - }, - debouncedContent, - ); - } - }); - } else { - if (findBot.botType === 'assistant') { - await this.openaiService.processOpenaiAssistant( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - pushName, - key.fromMe, - findBot, - session, - settings, - content, - ); - } - - if (findBot.botType === 'chatCompletion') { - await this.openaiService.processOpenaiChatCompletion( - this.waMonitor.waInstances[instance.instanceName], - remoteJid, - pushName, - findBot, - session, - settings, - content, - ); - } - } - - return; - } catch (error) { - this.logger.error(error); - return; - } - } } diff --git a/src/api/integrations/chatbot/openai/dto/openai.dto.ts b/src/api/integrations/chatbot/openai/dto/openai.dto.ts index a8ac2e4d..f254ac7e 100644 --- a/src/api/integrations/chatbot/openai/dto/openai.dto.ts +++ b/src/api/integrations/chatbot/openai/dto/openai.dto.ts @@ -1,15 +1,15 @@ import { TriggerOperator, TriggerType } from '@prisma/client'; +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; + export class OpenaiCredsDto { name: string; apiKey: string; } -export class OpenaiDto { - enabled?: boolean; - description?: string; +export class OpenaiDto extends BaseChatbotDto { openaiCredsId: string; - botType?: string; + botType: string; assistantId?: string; functionUrl?: string; model?: string; @@ -17,35 +17,10 @@ export class OpenaiDto { assistantMessages?: string[]; userMessages?: string[]; maxTokens?: number; - 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 OpenaiSettingDto { +export class OpenaiSettingDto extends BaseChatbotSettingDto { openaiCredsId?: string; - expire?: number; - keywordFinish?: string; - delayMessage?: number; - unknownMessage?: string; - listeningFromMe?: boolean; - stopBotFromMe?: boolean; - keepOpen?: boolean; - debounceTime?: number; openaiIdFallback?: string; - ignoreJids?: any; speechToText?: boolean; - splitMessages?: boolean; - timePerChar?: number; } diff --git a/src/api/integrations/chatbot/openai/services/openai.service.ts b/src/api/integrations/chatbot/openai/services/openai.service.ts index 16f4ce80..4dc1fcf8 100644 --- a/src/api/integrations/chatbot/openai/services/openai.service.ts +++ b/src/api/integrations/chatbot/openai/services/openai.service.ts @@ -13,104 +13,285 @@ import FormData from 'form-data'; import OpenAI from 'openai'; import P from 'pino'; -export class OpenaiService { - constructor( - private readonly waMonitor: WAMonitoringService, - private readonly configService: ConfigService, - private readonly prismaRepository: PrismaRepository, - ) {} +import { BaseChatbotService } from '../../base-chatbot.service'; - private client: OpenAI; +/** + * OpenAI service that extends the common BaseChatbotService + * Handles both Assistant API and ChatCompletion API + */ +export class OpenaiService extends BaseChatbotService { + protected client: OpenAI; - private readonly logger = new Logger('OpenaiService'); - - private async sendMessageToBot(instance: any, openaiBot: OpenaiBot, remoteJid: string, content: string) { - const systemMessages: any = openaiBot.systemMessages; - - const messagesSystem: any[] = systemMessages.map((message) => { - return { - role: 'system', - content: message, - }; - }); - - const assistantMessages: any = openaiBot.assistantMessages; - - const messagesAssistant: any[] = assistantMessages.map((message) => { - return { - role: 'assistant', - content: message, - }; - }); - - const userMessages: any = openaiBot.userMessages; - - const messagesUser: any[] = userMessages.map((message) => { - return { - role: 'user', - content: message, - }; - }); - - const messageData: any = { - role: 'user', - content: [{ type: 'text', text: content }], - }; - - if (this.isImageMessage(content)) { - const contentSplit = content.split('|'); - - const url = contentSplit[1].split('?')[0]; - - messageData.content = [ - { type: 'text', text: contentSplit[2] || content }, - { - type: 'image_url', - image_url: { - url: url, - }, - }, - ]; - } - - const messages: any[] = [...messagesSystem, ...messagesAssistant, ...messagesUser, messageData]; - - if (instance.integration === Integration.WHATSAPP_BAILEYS) { - await instance.client.presenceSubscribe(remoteJid); - await instance.client.sendPresenceUpdate('composing', remoteJid); - } - - const completions = await this.client.chat.completions.create({ - model: openaiBot.model, - messages: messages, - max_tokens: openaiBot.maxTokens, - }); - - if (instance.integration === Integration.WHATSAPP_BAILEYS) - await instance.client.sendPresenceUpdate('paused', remoteJid); - - const message = completions.choices[0].message.content; - - return message; + constructor(waMonitor: WAMonitoringService, prismaRepository: PrismaRepository, configService: ConfigService) { + super(waMonitor, prismaRepository, 'OpenaiService', configService); } - private async sendMessageToAssistant( + /** + * Return the bot type for OpenAI + */ + protected getBotType(): string { + return 'openai'; + } + + /** + * Create a new session specific to OpenAI + */ + public async createNewSession(instance: InstanceDto, data: any) { + return super.createNewSession(instance, data, 'openai'); + } + + /** + * Initialize the OpenAI client with the provided API key + */ + protected initClient(apiKey: string) { + this.client = new OpenAI({ apiKey }); + return this.client; + } + + /** + * Process a message based on the bot type (assistant or chat completion) + */ + public async process( instance: any, + remoteJid: string, + openaiBot: OpenaiBot, + session: IntegrationSession, + settings: OpenaiSetting, + content: string, + pushName?: string, + msg?: any, + ): Promise { + try { + this.logger.log(`Starting process for remoteJid: ${remoteJid}, bot type: ${openaiBot.botType}`); + + // Handle audio message transcription + if (content.startsWith('audioMessage|') && msg) { + this.logger.log('Detected audio message, attempting to transcribe'); + + // Get OpenAI credentials for transcription + const creds = await this.prismaRepository.openaiCreds.findUnique({ + where: { id: openaiBot.openaiCredsId }, + }); + + if (!creds) { + this.logger.error(`OpenAI credentials not found. CredsId: ${openaiBot.openaiCredsId}`); + return; + } + + // Initialize OpenAI client for transcription + this.initClient(creds.apiKey); + + // Transcribe the audio + const transcription = await this.speechToText(msg); + + if (transcription) { + this.logger.log(`Audio transcribed: ${transcription}`); + // Replace the audio message identifier with the transcription + content = transcription; + } else { + this.logger.error('Failed to transcribe audio'); + await this.sendMessageWhatsApp( + instance, + remoteJid, + "Sorry, I couldn't transcribe your audio message. Could you please type your message instead?", + settings, + ); + return; + } + } else { + // Get the OpenAI credentials + const creds = await this.prismaRepository.openaiCreds.findUnique({ + where: { id: openaiBot.openaiCredsId }, + }); + + if (!creds) { + this.logger.error(`OpenAI credentials not found. CredsId: ${openaiBot.openaiCredsId}`); + return; + } + + // Initialize OpenAI client + this.initClient(creds.apiKey); + } + + // Handle keyword finish + const keywordFinish = settings?.keywordFinish?.split(',') || []; + const normalizedContent = content.toLowerCase().trim(); + if ( + keywordFinish.length > 0 && + keywordFinish.some((keyword: string) => normalizedContent === keyword.toLowerCase().trim()) + ) { + if (settings?.keepOpen) { + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'closed', + }, + }); + } else { + await this.prismaRepository.integrationSession.delete({ + where: { + id: session.id, + }, + }); + } + + await sendTelemetry('/openai/session/finish'); + return; + } + + // If session is new or doesn't exist + if (!session) { + const data = { + remoteJid, + pushName, + botId: openaiBot.id, + }; + + const createSession = await this.createNewSession( + { instanceName: instance.instanceName, instanceId: instance.instanceId }, + data, + ); + + await this.initNewSession( + instance, + remoteJid, + openaiBot, + settings, + createSession.session, + content, + pushName, + msg, + ); + + await sendTelemetry('/openai/session/start'); + return; + } + + // If session exists but is paused + if (session.status === 'paused') { + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'opened', + awaitUser: true, + }, + }); + + return; + } + + // Process with the appropriate API based on bot type + await this.sendMessageToBot(instance, session, settings, openaiBot, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(`Error in process: ${error.message || JSON.stringify(error)}`); + return; + } + } + + /** + * Send message to OpenAI - this handles both Assistant API and ChatCompletion API + */ + protected async sendMessageToBot( + instance: any, + session: IntegrationSession, + settings: OpenaiSetting, + openaiBot: OpenaiBot, + remoteJid: string, + pushName: string, + content: string, + msg?: any, + ): Promise { + this.logger.log(`Sending message to bot for remoteJid: ${remoteJid}, bot type: ${openaiBot.botType}`); + + if (!this.client) { + this.logger.log('Client not initialized, initializing now'); + const creds = await this.prismaRepository.openaiCreds.findUnique({ + where: { id: openaiBot.openaiCredsId }, + }); + + if (!creds) { + this.logger.error(`OpenAI credentials not found in sendMessageToBot. CredsId: ${openaiBot.openaiCredsId}`); + return; + } + + this.initClient(creds.apiKey); + } + + try { + let message: string; + + // Handle different bot types + if (openaiBot.botType === 'assistant') { + this.logger.log('Processing with Assistant API'); + message = await this.processAssistantMessage( + instance, + session, + openaiBot, + remoteJid, + pushName, + false, // Not fromMe + content, + msg, + ); + } else { + this.logger.log('Processing with ChatCompletion API'); + message = await this.processChatCompletionMessage(instance, openaiBot, remoteJid, content, msg); + } + + this.logger.log(`Got response from OpenAI: ${message?.substring(0, 50)}${message?.length > 50 ? '...' : ''}`); + + // Send the response + if (message) { + this.logger.log('Sending message to WhatsApp'); + await this.sendMessageWhatsApp(instance, remoteJid, message, settings); + } else { + this.logger.error('No message to send to WhatsApp'); + } + + // Update session status + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'opened', + awaitUser: true, + }, + }); + } catch (error) { + this.logger.error(`Error in sendMessageToBot: ${error.message || JSON.stringify(error)}`); + if (error.response) { + this.logger.error(`API Response data: ${JSON.stringify(error.response.data || {})}`); + } + return; + } + } + + /** + * Process message using the OpenAI Assistant API + */ + private async processAssistantMessage( + instance: any, + session: IntegrationSession, openaiBot: OpenaiBot, remoteJid: string, pushName: string, fromMe: boolean, content: string, - threadId: string, - ) { + msg?: any, + ): Promise { const messageData: any = { role: fromMe ? 'assistant' : 'user', content: [{ type: 'text', text: content }], }; + // Handle image messages if (this.isImageMessage(content)) { const contentSplit = content.split('|'); - const url = contentSplit[1].split('?')[0]; messageData.content = [ @@ -124,13 +305,18 @@ export class OpenaiService { ]; } + // Get thread ID from session or create new thread + const threadId = session.sessionId === remoteJid ? (await this.client.beta.threads.create()).id : session.sessionId; + + // Add message to thread await this.client.beta.threads.messages.create(threadId, messageData); if (fromMe) { sendTelemetry('/message/sendText'); - return; + return ''; } + // Run the assistant const runAssistant = await this.client.beta.threads.runs.create(threadId, { assistant_id: openaiBot.assistantId, }); @@ -140,716 +326,456 @@ export class OpenaiService { await instance.client.sendPresenceUpdate('composing', remoteJid); } + // Wait for the assistant to complete const response = await this.getAIResponse(threadId, runAssistant.id, openaiBot.functionUrl, remoteJid, pushName); - if (instance.integration === Integration.WHATSAPP_BAILEYS) + if (instance.integration === Integration.WHATSAPP_BAILEYS) { await instance.client.sendPresenceUpdate('paused', remoteJid); - - const message = response?.data[0].content[0].text.value; - - return message; - } - - private async sendMessageWhatsapp( - instance: any, - session: IntegrationSession, - remoteJid: string, - settings: OpenaiSetting, - message: string, - ) { - 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, - ); - } - textBuffer = ''; - } - - sendTelemetry('/message/sendText'); - - await this.prismaRepository.integrationSession.update({ - where: { - id: session.id, - }, - data: { - status: 'opened', - awaitUser: true, - }, - }); - } - - public async createAssistantNewSession(instance: InstanceDto, data: any) { - if (data.remoteJid === 'status@broadcast') return; - - const creds = await this.prismaRepository.openaiCreds.findFirst({ - where: { - id: data.openaiCredsId, - }, - }); - - if (!creds) throw new Error('Openai Creds not found'); - + // Extract the response text safely with type checking try { - this.client = new OpenAI({ - apiKey: creds.apiKey, + const messages = response?.data || []; + if (messages.length > 0) { + const messageContent = messages[0]?.content || []; + if (messageContent.length > 0) { + const textContent = messageContent[0]; + if (textContent && 'text' in textContent && textContent.text && 'value' in textContent.text) { + return textContent.text.value; + } + } + } + } catch (error) { + this.logger.error(`Error extracting response text: ${error}`); + } + + // Return fallback message if unable to extract text + return "I couldn't generate a proper response. Please try again."; + } + + /** + * Process message using the OpenAI ChatCompletion API + */ + private async processChatCompletionMessage( + instance: any, + openaiBot: OpenaiBot, + remoteJid: string, + content: string, + msg?: any, + ): Promise { + this.logger.log('Starting processChatCompletionMessage'); + + // Check if client is initialized + if (!this.client) { + this.logger.log('Client not initialized in processChatCompletionMessage, initializing now'); + const creds = await this.prismaRepository.openaiCreds.findUnique({ + where: { id: openaiBot.openaiCredsId }, }); - const threadId = (await this.client.beta.threads.create({})).id; - - let session = null; - if (threadId) { - session = await this.prismaRepository.integrationSession.create({ - data: { - remoteJid: data.remoteJid, - pushName: data.pushName, - sessionId: threadId, - status: 'opened', - awaitUser: false, - botId: data.botId, - instanceId: instance.instanceId, - type: 'openai', - }, - }); + if (!creds) { + this.logger.error(`OpenAI credentials not found. CredsId: ${openaiBot.openaiCredsId}`); + return 'Error: OpenAI credentials not found'; } - return { session }; - } catch (error) { - this.logger.error(error); - return; - } - } - private async initAssistantNewSession( - instance: any, - remoteJid: string, - pushName: string, - fromMe: boolean, - openaiBot: OpenaiBot, - settings: OpenaiSetting, - session: IntegrationSession, - content: string, - ) { - const data = await this.createAssistantNewSession(instance, { - remoteJid, - pushName, - openaiCredsId: openaiBot.openaiCredsId, - botId: openaiBot.id, + this.initClient(creds.apiKey); + } + + // Check if model is defined + if (!openaiBot.model) { + this.logger.error('OpenAI model not defined'); + return 'Error: OpenAI model not configured'; + } + + this.logger.log(`Using model: ${openaiBot.model}, max tokens: ${openaiBot.maxTokens || 500}`); + + // Get existing conversation history from the session + const session = await this.prismaRepository.integrationSession.findFirst({ + where: { + remoteJid, + botId: openaiBot.id, + status: 'opened', + }, }); - if (data.session) { - session = data.session; + let conversationHistory = []; + + if (session && session.context) { + try { + const sessionData = + typeof session.context === 'string' ? JSON.parse(session.context as string) : session.context; + + conversationHistory = sessionData.history || []; + this.logger.log(`Retrieved conversation history from session, ${conversationHistory.length} messages`); + } catch (error) { + this.logger.error(`Error parsing session context: ${error.message}`); + // Continue with empty history if we can't parse the session data + conversationHistory = []; + } } - const message = await this.sendMessageToAssistant( - instance, - openaiBot, - remoteJid, - pushName, - fromMe, - content, - session.sessionId, - ); + // Log bot data + this.logger.log(`Bot data - systemMessages: ${JSON.stringify(openaiBot.systemMessages || [])}`); + this.logger.log(`Bot data - assistantMessages: ${JSON.stringify(openaiBot.assistantMessages || [])}`); + this.logger.log(`Bot data - userMessages: ${JSON.stringify(openaiBot.userMessages || [])}`); - await this.sendMessageWhatsapp(instance, session, remoteJid, settings, message); + // Prepare system messages + const systemMessages: any = openaiBot.systemMessages || []; + const messagesSystem: any[] = systemMessages.map((message) => { + return { + role: 'system', + content: message, + }; + }); - return; - } + // Prepare assistant messages + const assistantMessages: any = openaiBot.assistantMessages || []; + const messagesAssistant: any[] = assistantMessages.map((message) => { + return { + role: 'assistant', + content: message, + }; + }); - private isJSON(str: string): boolean { + // Prepare user messages + const userMessages: any = openaiBot.userMessages || []; + const messagesUser: any[] = userMessages.map((message) => { + return { + role: 'user', + content: message, + }; + }); + + // Prepare current message + const messageData: any = { + role: 'user', + content: [{ type: 'text', text: content }], + }; + + // Handle image messages + if (this.isImageMessage(content)) { + this.logger.log('Found image message'); + const contentSplit = content.split('|'); + const url = contentSplit[1].split('?')[0]; + + messageData.content = [ + { type: 'text', text: contentSplit[2] || content }, + { + type: 'image_url', + image_url: { + url: url, + }, + }, + ]; + } + + // Combine all messages: system messages, pre-defined messages, conversation history, and current message + const messages: any[] = [ + ...messagesSystem, + ...messagesAssistant, + ...messagesUser, + ...conversationHistory, + messageData, + ]; + + this.logger.log(`Final messages payload: ${JSON.stringify(messages)}`); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + this.logger.log('Setting typing indicator'); + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + // Send the request to OpenAI try { - JSON.parse(str); - return true; - } catch (e) { - return false; + this.logger.log('Sending request to OpenAI API'); + const completions = await this.client.chat.completions.create({ + model: openaiBot.model, + messages: messages, + max_tokens: openaiBot.maxTokens || 500, // Add default if maxTokens is missing + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } + + const responseContent = completions.choices[0].message.content; + this.logger.log(`Received response from OpenAI: ${JSON.stringify(completions.choices[0])}`); + + // Add the current exchange to the conversation history and update the session + conversationHistory.push(messageData); + conversationHistory.push({ + role: 'assistant', + content: responseContent, + }); + + // Limit history length to avoid token limits (keep last 10 messages) + if (conversationHistory.length > 10) { + conversationHistory = conversationHistory.slice(conversationHistory.length - 10); + } + + // Save the updated conversation history to the session + if (session) { + await this.prismaRepository.integrationSession.update({ + where: { id: session.id }, + data: { + context: JSON.stringify({ + history: conversationHistory, + }), + }, + }); + this.logger.log(`Updated session with conversation history, now ${conversationHistory.length} messages`); + } + + return responseContent; + } catch (error) { + this.logger.error(`Error calling OpenAI: ${error.message || JSON.stringify(error)}`); + if (error.response) { + this.logger.error(`API Response status: ${error.response.status}`); + this.logger.error(`API Response data: ${JSON.stringify(error.response.data || {})}`); + } + return `Sorry, there was an error: ${error.message || 'Unknown error'}`; } } + /** + * Wait for and retrieve the AI response + */ private async getAIResponse( threadId: string, runId: string, - functionUrl: string, + functionUrl: string | null, remoteJid: string, pushName: string, ) { - const getRun = await this.client.beta.threads.runs.retrieve(threadId, runId); - let toolCalls; - switch (getRun.status) { - case 'requires_action': - toolCalls = getRun?.required_action?.submit_tool_outputs?.tool_calls; + let status = await this.client.beta.threads.runs.retrieve(threadId, runId); - if (toolCalls) { - for (const toolCall of toolCalls) { - const id = toolCall.id; - const functionName = toolCall?.function?.name; - const functionArgument = this.isJSON(toolCall?.function?.arguments) - ? JSON.parse(toolCall?.function?.arguments) - : toolCall?.function?.arguments; + let maxRetries = 60; // 1 minute with 1s intervals + const checkInterval = 1000; // 1 second - let output = null; + while ( + status.status !== 'completed' && + status.status !== 'failed' && + status.status !== 'cancelled' && + status.status !== 'expired' && + maxRetries > 0 + ) { + await new Promise((resolve) => setTimeout(resolve, checkInterval)); + status = await this.client.beta.threads.runs.retrieve(threadId, runId); + // Handle tool calls + if (status.status === 'requires_action' && status.required_action?.type === 'submit_tool_outputs') { + const toolCalls = status.required_action.submit_tool_outputs.tool_calls; + const toolOutputs = []; + + for (const toolCall of toolCalls) { + if (functionUrl) { try { - const { data } = await axios.post(functionUrl, { - name: functionName, - arguments: { ...functionArgument, remoteJid, pushName }, + const payloadData = JSON.parse(toolCall.function.arguments); + + // Add context + payloadData.remoteJid = remoteJid; + payloadData.pushName = pushName; + + const response = await axios.post(functionUrl, { + functionName: toolCall.function.name, + functionArguments: payloadData, }); - output = JSON.stringify(data) - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + toolOutputs.push({ + tool_call_id: toolCall.id, + output: JSON.stringify(response.data), + }); } catch (error) { - output = JSON.stringify(error) - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + this.logger.error(`Error calling function: ${error}`); + toolOutputs.push({ + tool_call_id: toolCall.id, + output: JSON.stringify({ error: 'Function call failed' }), + }); } - - await this.client.beta.threads.runs.submitToolOutputs(threadId, runId, { - tool_outputs: [ - { - tool_call_id: id, - output, - }, - ], + } else { + toolOutputs.push({ + tool_call_id: toolCall.id, + output: JSON.stringify({ error: 'No function URL configured' }), }); } } - return this.getAIResponse(threadId, runId, functionUrl, remoteJid, pushName); - case 'queued': - await new Promise((resolve) => setTimeout(resolve, 1000)); - return this.getAIResponse(threadId, runId, functionUrl, remoteJid, pushName); - case 'in_progress': - await new Promise((resolve) => setTimeout(resolve, 1000)); - return this.getAIResponse(threadId, runId, functionUrl, remoteJid, pushName); - case 'completed': - return await this.client.beta.threads.messages.list(threadId, { - run_id: runId, - limit: 1, + await this.client.beta.threads.runs.submitToolOutputs(threadId, runId, { + tool_outputs: toolOutputs, }); + } + + maxRetries--; + } + + if (status.status === 'completed') { + const messages = await this.client.beta.threads.messages.list(threadId); + return messages; + } else { + this.logger.error(`Assistant run failed with status: ${status.status}`); + return { data: [{ content: [{ text: { value: 'Failed to get a response from the assistant.' } }] }] }; } } - private isImageMessage(content: string) { + protected isImageMessage(content: string): boolean { return content.includes('imageMessage'); } - public async processOpenaiAssistant( - instance: any, - remoteJid: string, - pushName: string, - fromMe: boolean, - openaiBot: OpenaiBot, - session: IntegrationSession, - settings: OpenaiSetting, - content: string, - ) { - if (session && session.status === 'closed') { - return; - } + /** + * Implementation of speech-to-text transcription for audio messages + * This overrides the base class implementation with extra functionality + * Can be called directly with a message object or with an audio buffer + */ + public async speechToText(msgOrBuffer: any, updateMediaMessage?: any): Promise { + try { + this.logger.log('Starting speechToText transcription'); - if (session && settings.expire && settings.expire > 0) { - const now = Date.now(); + // Handle direct calls with message object + if (msgOrBuffer && (msgOrBuffer.key || msgOrBuffer.message)) { + this.logger.log('Processing message object for audio transcription'); + const audioBuffer = await this.getAudioBufferFromMsg(msgOrBuffer, updateMediaMessage); - 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: openaiBot.id, - remoteJid: remoteJid, - }, - }); + if (!audioBuffer) { + this.logger.error('Failed to get audio buffer from message'); + return null; } - await this.initAssistantNewSession( - instance, - remoteJid, - pushName, - fromMe, - openaiBot, - settings, - session, - content, - ); - return; + this.logger.log(`Got audio buffer of size: ${audioBuffer.length} bytes`); + + // Process the audio buffer with the base implementation + return this.processAudioTranscription(audioBuffer); } + + // Handle calls with a buffer directly (base implementation) + this.logger.log('Processing buffer directly for audio transcription'); + return this.processAudioTranscription(msgOrBuffer); + } catch (err) { + this.logger.error(`Error in speechToText: ${err}`); + return null; } - - if (!session) { - await this.initAssistantNewSession(instance, remoteJid, pushName, fromMe, openaiBot, settings, session, content); - return; - } - - if (session.status !== 'paused') - 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: openaiBot.id, - remoteJid: remoteJid, - }, - }); - } - return; - } - - const creds = await this.prismaRepository.openaiCreds.findFirst({ - where: { - id: openaiBot.openaiCredsId, - }, - }); - - if (!creds) throw new Error('Openai Creds not found'); - - this.client = new OpenAI({ - apiKey: creds.apiKey, - }); - - const threadId = session.sessionId; - - const message = await this.sendMessageToAssistant( - instance, - openaiBot, - remoteJid, - pushName, - fromMe, - content, - threadId, - ); - - await this.sendMessageWhatsapp(instance, session, remoteJid, settings, message); - - return; } - public async createChatCompletionNewSession(instance: InstanceDto, data: any) { - if (data.remoteJid === 'status@broadcast') return; - - const id = Math.floor(Math.random() * 10000000000).toString(); - - const creds = await this.prismaRepository.openaiCreds.findFirst({ - where: { - id: data.openaiCredsId, - }, - }); - - if (!creds) throw new Error('Openai Creds not found'); + /** + * Helper method to process audio buffer for transcription + */ + private async processAudioTranscription(audioBuffer: Buffer): Promise { + if (!this.configService) { + this.logger.error('ConfigService not available for speech-to-text transcription'); + return null; + } try { - const session = await this.prismaRepository.integrationSession.create({ - data: { - remoteJid: data.remoteJid, - pushName: data.pushName, - sessionId: id, - status: 'opened', - awaitUser: false, - botId: data.botId, - instanceId: instance.instanceId, - type: 'openai', - }, - }); + // Use the initialized client's API key if available + let apiKey; - return { session, creds }; - } catch (error) { - this.logger.error(error); - return; - } - } - - private async initChatCompletionNewSession( - instance: any, - remoteJid: string, - pushName: string, - openaiBot: OpenaiBot, - settings: OpenaiSetting, - session: IntegrationSession, - content: string, - ) { - const data = await this.createChatCompletionNewSession(instance, { - remoteJid, - pushName, - openaiCredsId: openaiBot.openaiCredsId, - botId: openaiBot.id, - }); - - session = data.session; - - const creds = data.creds; - - this.client = new OpenAI({ - apiKey: creds.apiKey, - }); - - const message = await this.sendMessageToBot(instance, openaiBot, remoteJid, content); - - await this.sendMessageWhatsapp(instance, session, remoteJid, settings, message); - - return; - } - - public async processOpenaiChatCompletion( - instance: any, - remoteJid: string, - pushName: string, - openaiBot: OpenaiBot, - session: IntegrationSession, - settings: OpenaiSetting, - content: string, - ) { - 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: openaiBot.id, - remoteJid: remoteJid, - }, - }); - } - - await this.initChatCompletionNewSession(instance, remoteJid, pushName, openaiBot, settings, session, content); - return; - } - } - - if (!session) { - await this.initChatCompletionNewSession(instance, remoteJid, pushName, openaiBot, settings, session, content); - 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', - }, - }); + if (this.client) { + // Extract the API key from the initialized client if possible + // OpenAI client doesn't expose the API key directly, so we need to use environment or config + apiKey = this.configService.get('OPENAI')?.API_KEY || process.env.OPENAI_API_KEY; } else { - await this.prismaRepository.integrationSession.deleteMany({ - where: { - botId: openaiBot.id, - remoteJid: remoteJid, - }, - }); + this.logger.log('No OpenAI client initialized, using config API key'); + apiKey = this.configService.get('OPENAI')?.API_KEY || process.env.OPENAI_API_KEY; } - return; + + 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'); + + this.logger.log(`Sending audio for transcription with language: ${lang}`); + + const formData = new FormData(); + formData.append('file', audioBuffer, 'audio.ogg'); + formData.append('model', 'whisper-1'); + formData.append('language', lang); + + this.logger.log('Making API request to OpenAI Whisper transcription'); + + const response = await axios.post('https://api.openai.com/v1/audio/transcriptions', formData, { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${apiKey}`, + }, + }); + + this.logger.log(`Transcription completed: ${response?.data?.text || 'No text returned'}`); + return response?.data?.text || null; + } catch (err) { + this.logger.error(`Whisper transcription failed: ${JSON.stringify(err.response?.data || err.message || err)}`); + return null; } - - const creds = await this.prismaRepository.openaiCreds.findFirst({ - where: { - id: openaiBot.openaiCredsId, - }, - }); - - if (!creds) throw new Error('Openai Creds not found'); - - this.client = new OpenAI({ - apiKey: creds.apiKey, - }); - - const message = await this.sendMessageToBot(instance, openaiBot, remoteJid, content); - - await this.sendMessageWhatsapp(instance, session, remoteJid, settings, message); - - return; } - public async speechToText(creds: OpenaiCreds, msg: any, updateMediaMessage: any) { - let audio; + /** + * Helper method to convert message to audio buffer + */ + private async getAudioBufferFromMsg(msg: any, updateMediaMessage: any): Promise { + try { + this.logger.log('Getting audio buffer from message'); + this.logger.log(`Message type: ${msg.messageType}, has media URL: ${!!msg?.message?.mediaUrl}`); - if (msg?.message?.mediaUrl) { - audio = await axios.get(msg.message.mediaUrl, { responseType: 'arraybuffer' }).then((response) => { - return Buffer.from(response.data, 'binary'); - }); - } else { - audio = await downloadMediaMessage( - { key: msg.key, message: msg?.message }, - 'buffer', - {}, - { - logger: P({ level: 'error' }) as any, - reuploadRequest: updateMediaMessage, - }, - ); + let audio; + + if (msg?.message?.mediaUrl) { + this.logger.log(`Getting audio from media URL: ${msg.message.mediaUrl}`); + audio = await axios.get(msg.message.mediaUrl, { responseType: 'arraybuffer' }).then((response) => { + return Buffer.from(response.data, 'binary'); + }); + } else if (msg?.message?.audioMessage) { + // Handle WhatsApp audio messages + this.logger.log('Getting audio from audioMessage'); + audio = await downloadMediaMessage( + { key: msg.key, message: msg?.message }, + 'buffer', + {}, + { + logger: P({ level: 'error' }) as any, + reuploadRequest: updateMediaMessage, + }, + ); + } else if (msg?.message?.pttMessage) { + // Handle PTT voice messages + this.logger.log('Getting audio from pttMessage'); + audio = await downloadMediaMessage( + { key: msg.key, message: msg?.message }, + 'buffer', + {}, + { + logger: P({ level: 'error' }) as any, + reuploadRequest: updateMediaMessage, + }, + ); + } else { + this.logger.log('No recognized audio format found'); + audio = await downloadMediaMessage( + { key: msg.key, message: msg?.message }, + 'buffer', + {}, + { + logger: P({ level: 'error' }) as any, + reuploadRequest: updateMediaMessage, + }, + ); + } + + if (audio) { + this.logger.log(`Successfully obtained audio buffer of size: ${audio.length} bytes`); + } else { + this.logger.error('Failed to obtain audio buffer'); + } + + return audio; + } catch (error) { + this.logger.error(`Error getting audio buffer: ${error.message || JSON.stringify(error)}`); + if (error.response) { + this.logger.error(`API response status: ${error.response.status}`); + this.logger.error(`API response data: ${JSON.stringify(error.response.data || {})}`); + } + return null; } - - const lang = this.configService.get('LANGUAGE').includes('pt') - ? 'pt' - : this.configService.get('LANGUAGE'); - - const formData = new FormData(); - - formData.append('file', audio, '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: { - 'Content-Type': 'multipart/form-data', - Authorization: `Bearer ${creds.apiKey}`, - }, - }); - - return response?.data?.text; } } diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 5070ca06..598b014b 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -119,7 +119,7 @@ export const baileysController = new BaileysController(waMonitor); const typebotService = new TypebotService(waMonitor, configService, prismaRepository); export const typebotController = new TypebotController(typebotService, prismaRepository, waMonitor); -const openaiService = new OpenaiService(waMonitor, configService, prismaRepository); +const openaiService = new OpenaiService(waMonitor, prismaRepository, configService); export const openaiController = new OpenaiController(openaiService, prismaRepository, waMonitor); const difyService = new DifyService(waMonitor, configService, prismaRepository); @@ -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); diff --git a/src/api/services/channel.service.ts b/src/api/services/channel.service.ts index f89cde08..ee8e83b2 100644 --- a/src/api/services/channel.service.ts +++ b/src/api/services/channel.service.ts @@ -47,7 +47,7 @@ export class ChannelStartupService { public typebotService = new TypebotService(waMonitor, this.configService, this.prismaRepository); - public openaiService = new OpenaiService(waMonitor, this.configService, this.prismaRepository); + public openaiService = new OpenaiService(waMonitor, this.prismaRepository, this.configService); public difyService = new DifyService(waMonitor, this.configService, this.prismaRepository);