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) { return null; } 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, 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', 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.status === 'closed') { return; } // Skip if session exists and status is paused if (session && session.status === 'paused') { this.logger.warn(`Session for ${remoteJid} is paused, skipping message processing`); 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); } } }