From 35bde8498b8b79a27e586f9679580d54b897e154 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 21 Aug 2024 14:42:26 -0300 Subject: [PATCH] feat: generic chatbot --- .../migration.sql | 22 + .../migration.sql | 57 ++ prisma/postgresql-schema.prisma | 46 + .../chatbot/chatbot.controller.ts | 11 +- .../integrations/chatbot/chatbot.router.ts | 3 + .../integrations/chatbot/chatbot.schema.ts | 1 + .../dify/controllers/dify.controller.ts | 2 +- .../chatbot/dify/services/dify.service.ts | 4 +- .../generic/controllers/generic.controller.ts | 806 ++++++++++++++++++ .../chatbot/generic/dto/generic.dto.ts | 33 + .../chatbot/generic/routes/generic.router.ts | 124 +++ .../generic/services/generic.service.ts | 299 +++++++ .../generic/validate/generic.schema.ts | 107 +++ src/api/server.module.ts | 5 + 14 files changed, 1516 insertions(+), 4 deletions(-) create mode 100644 prisma/postgresql-migrations/20240821120816_bot_id_integration_session/migration.sql create mode 100644 prisma/postgresql-migrations/20240821171327_add_generic_bot_table/migration.sql create mode 100644 src/api/integrations/chatbot/generic/controllers/generic.controller.ts create mode 100644 src/api/integrations/chatbot/generic/dto/generic.dto.ts create mode 100644 src/api/integrations/chatbot/generic/routes/generic.router.ts create mode 100644 src/api/integrations/chatbot/generic/services/generic.service.ts create mode 100644 src/api/integrations/chatbot/generic/validate/generic.schema.ts diff --git a/prisma/postgresql-migrations/20240821120816_bot_id_integration_session/migration.sql b/prisma/postgresql-migrations/20240821120816_bot_id_integration_session/migration.sql new file mode 100644 index 00000000..bfe174b6 --- /dev/null +++ b/prisma/postgresql-migrations/20240821120816_bot_id_integration_session/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - You are about to drop the column `difyId` on the `IntegrationSession` table. All the data in the column will be lost. + - You are about to drop the column `openaiBotId` on the `IntegrationSession` table. All the data in the column will be lost. + - You are about to drop the column `typebotId` on the `IntegrationSession` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "IntegrationSession" DROP CONSTRAINT "IntegrationSession_difyId_fkey"; + +-- DropForeignKey +ALTER TABLE "IntegrationSession" DROP CONSTRAINT "IntegrationSession_openaiBotId_fkey"; + +-- DropForeignKey +ALTER TABLE "IntegrationSession" DROP CONSTRAINT "IntegrationSession_typebotId_fkey"; + +-- AlterTable +ALTER TABLE "IntegrationSession" DROP COLUMN "difyId", +DROP COLUMN "openaiBotId", +DROP COLUMN "typebotId", +ADD COLUMN "botId" TEXT; diff --git a/prisma/postgresql-migrations/20240821171327_add_generic_bot_table/migration.sql b/prisma/postgresql-migrations/20240821171327_add_generic_bot_table/migration.sql new file mode 100644 index 00000000..6ea99e2b --- /dev/null +++ b/prisma/postgresql-migrations/20240821171327_add_generic_bot_table/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "GenericBot" ( + "id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "description" VARCHAR(255), + "apiUrl" VARCHAR(255), + "apiKey" VARCHAR(255), + "expire" INTEGER DEFAULT 0, + "keywordFinish" VARCHAR(100), + "delayMessage" INTEGER, + "unknownMessage" VARCHAR(100), + "listeningFromMe" BOOLEAN DEFAULT false, + "stopBotFromMe" BOOLEAN DEFAULT false, + "keepOpen" BOOLEAN DEFAULT false, + "debounceTime" INTEGER, + "ignoreJids" JSONB, + "triggerType" "TriggerType", + "triggerOperator" "TriggerOperator", + "triggerValue" TEXT, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP NOT NULL, + "instanceId" TEXT NOT NULL, + + CONSTRAINT "GenericBot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GenericSetting" ( + "id" TEXT NOT NULL, + "expire" INTEGER DEFAULT 0, + "keywordFinish" VARCHAR(100), + "delayMessage" INTEGER, + "unknownMessage" VARCHAR(100), + "listeningFromMe" BOOLEAN DEFAULT false, + "stopBotFromMe" BOOLEAN DEFAULT false, + "keepOpen" BOOLEAN DEFAULT false, + "debounceTime" INTEGER, + "ignoreJids" JSONB, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP NOT NULL, + "botIdFallback" VARCHAR(100), + "instanceId" TEXT NOT NULL, + + CONSTRAINT "GenericSetting_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GenericSetting_instanceId_key" ON "GenericSetting"("instanceId"); + +-- AddForeignKey +ALTER TABLE "GenericBot" ADD CONSTRAINT "GenericBot_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GenericSetting" ADD CONSTRAINT "GenericSetting_botIdFallback_fkey" FOREIGN KEY ("botIdFallback") REFERENCES "GenericBot"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GenericSetting" ADD CONSTRAINT "GenericSetting_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/postgresql-schema.prisma b/prisma/postgresql-schema.prisma index 0e23f0cf..7407eed5 100644 --- a/prisma/postgresql-schema.prisma +++ b/prisma/postgresql-schema.prisma @@ -100,6 +100,8 @@ model Instance { Dify Dify[] DifySetting DifySetting? integrationSessions IntegrationSession[] + GenericBot GenericBot[] + GenericSetting GenericSetting? } model Session { @@ -481,3 +483,47 @@ model DifySetting { Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) instanceId String @unique } + +model GenericBot { + id String @id @default(cuid()) + enabled Boolean @default(true) @db.Boolean + description String? @db.VarChar(255) + apiUrl String? @db.VarChar(255) + apiKey String? @db.VarChar(255) + expire Int? @default(0) @db.Integer + keywordFinish String? @db.VarChar(100) + delayMessage Int? @db.Integer + unknownMessage String? @db.VarChar(100) + listeningFromMe Boolean? @default(false) @db.Boolean + stopBotFromMe Boolean? @default(false) @db.Boolean + keepOpen Boolean? @default(false) @db.Boolean + debounceTime Int? @db.Integer + ignoreJids Json? + triggerType TriggerType? + triggerOperator TriggerOperator? + triggerValue String? + createdAt DateTime? @default(now()) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String + GenericSetting GenericSetting[] +} + +model GenericSetting { + id String @id @default(cuid()) + expire Int? @default(0) @db.Integer + keywordFinish String? @db.VarChar(100) + delayMessage Int? @db.Integer + unknownMessage String? @db.VarChar(100) + listeningFromMe Boolean? @default(false) @db.Boolean + stopBotFromMe Boolean? @default(false) @db.Boolean + keepOpen Boolean? @default(false) @db.Boolean + debounceTime Int? @db.Integer + ignoreJids Json? + createdAt DateTime? @default(now()) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + Fallback GenericBot? @relation(fields: [botIdFallback], references: [id]) + botIdFallback String? @db.VarChar(100) + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String @unique +} diff --git a/src/api/integrations/chatbot/chatbot.controller.ts b/src/api/integrations/chatbot/chatbot.controller.ts index 75aa4962..7957e2b2 100644 --- a/src/api/integrations/chatbot/chatbot.controller.ts +++ b/src/api/integrations/chatbot/chatbot.controller.ts @@ -1,6 +1,12 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { PrismaRepository } from '@api/repository/repository.service'; -import { difyController, openaiController, typebotController, websocketController } from '@api/server.module'; +import { + difyController, + genericController, + openaiController, + typebotController, + websocketController, +} from '@api/server.module'; import { WAMonitoringService } from '@api/services/monitor.service'; import { Logger } from '@config/logger.config'; import { IntegrationSession } from '@prisma/client'; @@ -91,6 +97,9 @@ export class ChatbotController { // dify await difyController.emit(emitData); + + // generic + await genericController.emit(emitData); } public async setInstance(instanceName: string, data: any): Promise { diff --git a/src/api/integrations/chatbot/chatbot.router.ts b/src/api/integrations/chatbot/chatbot.router.ts index 2bada257..181a0dc3 100644 --- a/src/api/integrations/chatbot/chatbot.router.ts +++ b/src/api/integrations/chatbot/chatbot.router.ts @@ -4,6 +4,8 @@ import { OpenaiRouter } from '@api/integrations/chatbot/openai/routes/openai.rou import { TypebotRouter } from '@api/integrations/chatbot/typebot/routes/typebot.router'; import { Router } from 'express'; +import { GenericRouter } from './generic/routes/generic.router'; + export class ChatbotRouter { public readonly router: Router; @@ -14,5 +16,6 @@ export class ChatbotRouter { this.router.use('/typebot', new TypebotRouter(...guards).router); this.router.use('/openai', new OpenaiRouter(...guards).router); this.router.use('/dify', new DifyRouter(...guards).router); + this.router.use('/generic', new GenericRouter(...guards).router); } } diff --git a/src/api/integrations/chatbot/chatbot.schema.ts b/src/api/integrations/chatbot/chatbot.schema.ts index d2b5b7fe..cdac0afd 100644 --- a/src/api/integrations/chatbot/chatbot.schema.ts +++ b/src/api/integrations/chatbot/chatbot.schema.ts @@ -1,4 +1,5 @@ export * from '@api/integrations/chatbot/chatwoot/validate/chatwoot.schema'; export * from '@api/integrations/chatbot/dify/validate/dify.schema'; +export * from '@api/integrations/chatbot/generic/validate/generic.schema'; export * from '@api/integrations/chatbot/openai/validate/openai.schema'; export * from '@api/integrations/chatbot/typebot/validate/typebot.schema'; diff --git a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts index 5add7b65..41e6982a 100644 --- a/src/api/integrations/chatbot/dify/controllers/dify.controller.ts +++ b/src/api/integrations/chatbot/dify/controllers/dify.controller.ts @@ -711,7 +711,7 @@ export class DifyController extends ChatbotController implements ChatbotControll if (!this.integrationEnabled) return; try { - const settings = await this.prismaRepository.difySetting.findFirst({ + const settings = await this.settingsRepository.findFirst({ where: { instanceId: instance.instanceId, }, diff --git a/src/api/integrations/chatbot/dify/services/dify.service.ts b/src/api/integrations/chatbot/dify/services/dify.service.ts index 4a2aea07..f59fd673 100644 --- a/src/api/integrations/chatbot/dify/services/dify.service.ts +++ b/src/api/integrations/chatbot/dify/services/dify.service.ts @@ -25,7 +25,7 @@ export class DifyService { sessionId: data.remoteJid, status: 'opened', awaitUser: false, - botId: data.difyId, + botId: data.botId, instanceId: instance.instanceId, }, }); @@ -52,7 +52,7 @@ export class DifyService { ) { const data = await this.createNewSession(instance, { remoteJid, - difyId: dify.id, + botId: dify.id, }); if (data.session) { diff --git a/src/api/integrations/chatbot/generic/controllers/generic.controller.ts b/src/api/integrations/chatbot/generic/controllers/generic.controller.ts new file mode 100644 index 00000000..a6b27a7f --- /dev/null +++ b/src/api/integrations/chatbot/generic/controllers/generic.controller.ts @@ -0,0 +1,806 @@ +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 { getConversationMessage } from '@utils/getConversationMessage'; + +import { ChatbotController, ChatbotControllerInterface, EmitData } from '../../chatbot.controller'; +import { GenericBotDto } from '../dto/generic.dto'; +import { GenericService } from '../services/generic.service'; + +export class GenericController extends ChatbotController implements ChatbotControllerInterface { + constructor( + private readonly genericService: GenericService, + prismaRepository: PrismaRepository, + waMonitor: WAMonitoringService, + ) { + super(prismaRepository, waMonitor); + + this.botRepository = this.prismaRepository.genericBot; + this.settingsRepository = this.prismaRepository.genericSetting; + this.sessionRepository = this.prismaRepository.integrationSession; + } + + public readonly logger = new Logger(GenericController.name); + + integrationEnabled: boolean; + botRepository: any; + settingsRepository: any; + sessionRepository: any; + userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + + // Bots + public async createBot(instance: InstanceDto, data: GenericBotDto) { + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { + name: instance.instanceName, + }, + }) + .then((instance) => instance.id); + + if ( + !data.expire || + !data.keywordFinish || + !data.delayMessage || + !data.unknownMessage || + !data.listeningFromMe || + !data.stopBotFromMe || + !data.keepOpen || + !data.debounceTime || + !data.ignoreJids + ) { + const defaultSettingCheck = await this.settingsRepository.findFirst({ + where: { + instanceId: instanceId, + }, + }); + + if (!data.expire) data.expire = defaultSettingCheck?.expire || 0; + if (!data.keywordFinish) data.keywordFinish = defaultSettingCheck?.keywordFinish || ''; + if (!data.delayMessage) data.delayMessage = defaultSettingCheck?.delayMessage || 1000; + if (!data.unknownMessage) data.unknownMessage = defaultSettingCheck?.unknownMessage || ''; + if (!data.listeningFromMe) data.listeningFromMe = defaultSettingCheck?.listeningFromMe || false; + if (!data.stopBotFromMe) data.stopBotFromMe = defaultSettingCheck?.stopBotFromMe || false; + if (!data.keepOpen) data.keepOpen = defaultSettingCheck?.keepOpen || false; + if (!data.debounceTime) data.debounceTime = defaultSettingCheck?.debounceTime || 0; + if (!data.ignoreJids) data.ignoreJids = defaultSettingCheck?.ignoreJids || []; + + 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, + }); + } + } + + 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'); + } + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + instanceId: instanceId, + 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, + 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, + 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, + }, + }); + + return bot; + } catch (error) { + this.logger.error(error); + throw new Error('Error creating bot'); + } + } + + public async findBot(instance: InstanceDto) { + 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) { + 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('Bot not found'); + } + + if (bot.instanceId !== instanceId) { + throw new Error('Bot not found'); + } + + return bot; + } + + public async updateBot(instance: InstanceDto, botId: string, data: GenericBotDto) { + 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('Bot not found'); + } + + if (bot.instanceId !== instanceId) { + throw new Error('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 bot 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, + apiUrl: data.apiUrl, + apiKey: data.apiKey, + }, + }); + + if (checkDuplicate) { + throw new Error('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, + 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, + }, + }); + + return bot; + } catch (error) { + this.logger.error(error); + throw new Error('Error updating bot'); + } + } + + public async deleteBot(instance: InstanceDto, botId: string) { + 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('Bot not found'); + } + + if (bot.instanceId !== instanceId) { + throw new Error('Bot 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 bot'); + } + } + + // Settings + public async settings(instance: InstanceDto, data: any) { + 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, + botIdFallback: data.botIdFallback, + ignoreJids: data.ignoreJids, + }, + }); + + 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, + botIdFallback: updateSettings.botIdFallback, + ignoreJids: updateSettings.ignoreJids, + }; + } + + 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, + botIdFallback: data.botIdFallback, + ignoreJids: data.ignoreJids, + instanceId: instanceId, + }, + }); + + 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, + botIdFallback: newSetttings.botIdFallback, + ignoreJids: newSetttings.ignoreJids, + }; + } catch (error) { + this.logger.error(error); + throw new Error('Error setting default settings'); + } + } + + public async fetchSettings(instance: InstanceDto) { + 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: [], + botIdFallback: '', + 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, + botIdFallback: settings.botIdFallback, + 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 }, + }, + }); + } 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); + + const findBot = await this.findBotTrigger( + this.botRepository, + this.settingsRepository, + content, + instance, + session, + ); + + if (!findBot) 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; + + if ( + !expire || + !keywordFinish || + !delayMessage || + !unknownMessage || + !listeningFromMe || + !stopBotFromMe || + !keepOpen || + !debounceTime + ) { + if (!expire) expire = settings.expire; + + if (!keywordFinish) keywordFinish = settings.keywordFinish; + + if (!delayMessage) delayMessage = settings.delayMessage; + + if (!unknownMessage) unknownMessage = settings.unknownMessage; + + if (!listeningFromMe) listeningFromMe = settings.listeningFromMe; + + if (!stopBotFromMe) stopBotFromMe = settings.stopBotFromMe; + + if (!keepOpen) keepOpen = settings.keepOpen; + + if (!debounceTime) debounceTime = settings.debounceTime; + } + + 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 (debounceTime && debounceTime > 0) { + this.processDebounce(this.userMessageDebounce, content, remoteJid, debounceTime, async (debouncedContent) => { + await this.genericService.processBot( + this.waMonitor.waInstances[instance.instanceName], + remoteJid, + findBot, + session, + settings, + debouncedContent, + msg?.pushName, + ); + }); + } else { + await this.genericService.processBot( + this.waMonitor.waInstances[instance.instanceName], + remoteJid, + findBot, + session, + settings, + content, + msg?.pushName, + ); + } + + return; + } catch (error) { + this.logger.error(error); + return; + } + } +} diff --git a/src/api/integrations/chatbot/generic/dto/generic.dto.ts b/src/api/integrations/chatbot/generic/dto/generic.dto.ts new file mode 100644 index 00000000..7826344a --- /dev/null +++ b/src/api/integrations/chatbot/generic/dto/generic.dto.ts @@ -0,0 +1,33 @@ +import { TriggerOperator, TriggerType } from '@prisma/client'; + +export class GenericBotDto { + enabled?: boolean; + description?: string; + 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; +} + +export class GenericBotSettingDto { + expire?: number; + keywordFinish?: string; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + botIdFallback?: string; + ignoreJids?: any; +} diff --git a/src/api/integrations/chatbot/generic/routes/generic.router.ts b/src/api/integrations/chatbot/generic/routes/generic.router.ts new file mode 100644 index 00000000..3bf7ead8 --- /dev/null +++ b/src/api/integrations/chatbot/generic/routes/generic.router.ts @@ -0,0 +1,124 @@ +import { RouterBroker } from '@api/abstract/abstract.router'; +import { IgnoreJidDto } from '@api/dto/chatbot.dto'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { HttpStatus } from '@api/routes/index.router'; +import { genericController } from '@api/server.module'; +import { instanceSchema } from '@validate/instance.schema'; +import { RequestHandler, Router } from 'express'; + +import { GenericBotDto, GenericBotSettingDto } from '../dto/generic.dto'; +import { + genericIgnoreJidSchema, + genericSchema, + genericSettingSchema, + genericStatusSchema, +} from '../validate/generic.schema'; + +export class GenericRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .post(this.routerPath('create'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: genericSchema, + ClassRef: GenericBotDto, + execute: (instance, data) => genericController.createBot(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .get(this.routerPath('find'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => genericController.findBot(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('fetch/:genericId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => genericController.fetchBot(instance, req.params.genericId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .put(this.routerPath('update/:genericId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: genericSchema, + ClassRef: GenericBotDto, + execute: (instance, data) => genericController.updateBot(instance, req.params.genericId, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .delete(this.routerPath('delete/:genericId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => genericController.deleteBot(instance, req.params.genericId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('settings'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: genericSettingSchema, + ClassRef: GenericBotSettingDto, + execute: (instance, data) => genericController.settings(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('fetchSettings'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => genericController.fetchSettings(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('changeStatus'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: genericStatusSchema, + ClassRef: InstanceDto, + execute: (instance, data) => genericController.changeStatus(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('fetchSessions/:genericId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => genericController.fetchSessions(instance, req.params.genericId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('ignoreJid'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: genericIgnoreJidSchema, + ClassRef: IgnoreJidDto, + execute: (instance, data) => genericController.ignoreJid(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }); + } + + public readonly router: Router = Router(); +} diff --git a/src/api/integrations/chatbot/generic/services/generic.service.ts b/src/api/integrations/chatbot/generic/services/generic.service.ts new file mode 100644 index 00000000..06d26405 --- /dev/null +++ b/src/api/integrations/chatbot/generic/services/generic.service.ts @@ -0,0 +1,299 @@ +import { InstanceDto } from '@api/dto/instance.dto'; +import { PrismaRepository } from '@api/repository/repository.service'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { Auth, ConfigService, HttpServer } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { GenericBot, GenericSetting, IntegrationSession } from '@prisma/client'; +import { sendTelemetry } from '@utils/sendTelemetry'; +import axios from 'axios'; + +export class GenericService { + constructor( + private readonly waMonitor: WAMonitoringService, + private readonly configService: ConfigService, + private readonly prismaRepository: PrismaRepository, + ) {} + + private readonly logger = new Logger('GenericService'); + + public async createNewSession(instance: InstanceDto, data: any) { + try { + const session = await this.prismaRepository.integrationSession.create({ + data: { + remoteJid: data.remoteJid, + sessionId: data.remoteJid, + status: 'opened', + awaitUser: false, + botId: data.botId, + instanceId: instance.instanceId, + }, + }); + + return { session }; + } catch (error) { + this.logger.error(error); + return; + } + } + + private isImageMessage(content: string) { + return content.includes('imageMessage'); + } + + private async sendMessageToBot( + instance: any, + session: IntegrationSession, + bot: GenericBot, + remoteJid: string, + pushName: string, + content: string, + ) { + const payload: any = { + inputs: { + remoteJid: remoteJid, + pushName: pushName, + instanceName: instance.instanceName, + serverUrl: this.configService.get('SERVER').URL, + apiKey: this.configService.get('AUTHENTICATION').API_KEY.KEY, + }, + query: content, + conversation_id: session.sessionId === remoteJid ? undefined : session.sessionId, + user: remoteJid, + }; + + if (this.isImageMessage(content)) { + const contentSplit = content.split('|'); + + payload.files = [ + { + type: 'image', + url: contentSplit[1].split('?')[0], + }, + ]; + payload.query = contentSplit[2] || content; + } + + await instance.client.presenceSubscribe(remoteJid); + + await instance.client.sendPresenceUpdate('composing', remoteJid); + + let headers: any = { + 'Content-Type': 'application/json', + }; + + if (bot.apiKey) { + headers = { + ...headers, + Authorization: `Bearer ${bot.apiKey}`, + }; + } + + const response = await axios.post(bot.apiUrl, payload, { + headers, + }); + + await instance.client.sendPresenceUpdate('paused', remoteJid); + + const message = response?.data?.answer; + + return message; + } + + private async sendMessageWhatsApp( + instance: any, + remoteJid: string, + session: IntegrationSession, + settings: GenericSetting, + message: string, + ) { + const regex = /!?\[(.*?)\]\((.*?)\)/g; + + const result = []; + let lastIndex = 0; + + let match; + while ((match = regex.exec(message)) !== null) { + if (match.index > lastIndex) { + result.push({ text: message.slice(lastIndex, match.index).trim() }); + } + + result.push({ caption: match[1], url: match[2] }); + + lastIndex = regex.lastIndex; + } + + if (lastIndex < message.length) { + result.push({ text: message.slice(lastIndex).trim() }); + } + + for (const item of result) { + if (item.text) { + await instance.textMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + text: item.text, + }, + false, + ); + } + + if (item.url) { + await instance.mediaMessage( + { + number: remoteJid.split('@')[0], + delay: settings?.delayMessage || 1000, + mediatype: 'image', + media: item.url, + caption: item.caption, + }, + false, + ); + } + } + + await this.prismaRepository.integrationSession.update({ + where: { + id: session.id, + }, + data: { + status: 'opened', + awaitUser: true, + }, + }); + + sendTelemetry('/message/sendText'); + + return; + } + + private async initNewSession( + instance: any, + remoteJid: string, + bot: GenericBot, + settings: GenericSetting, + session: IntegrationSession, + content: string, + pushName?: string, + ) { + const data = await this.createNewSession(instance, { + remoteJid, + botId: bot.id, + }); + + if (data.session) { + session = data.session; + } + + const message = await this.sendMessageToBot(instance, session, bot, remoteJid, pushName, content); + + await this.sendMessageWhatsApp(instance, remoteJid, session, settings, message); + + return; + } + + public async processBot( + instance: any, + remoteJid: string, + bot: GenericBot, + session: IntegrationSession, + settings: GenericSetting, + content: string, + pushName?: 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: bot.id, + remoteJid: remoteJid, + }, + }); + } + + await this.initNewSession(instance, remoteJid, bot, settings, session, content, pushName); + return; + } + } + + if (!session) { + await this.initNewSession(instance, remoteJid, bot, 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, + ); + + 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: bot.id, + remoteJid: remoteJid, + }, + }); + } + return; + } + + const message = await this.sendMessageToBot(instance, session, bot, remoteJid, pushName, content); + + await this.sendMessageWhatsApp(instance, remoteJid, session, settings, message); + + return; + } +} diff --git a/src/api/integrations/chatbot/generic/validate/generic.schema.ts b/src/api/integrations/chatbot/generic/validate/generic.schema.ts new file mode 100644 index 00000000..40d8cabc --- /dev/null +++ b/src/api/integrations/chatbot/generic/validate/generic.schema.ts @@ -0,0 +1,107 @@ +import { JSONSchema7 } from 'json-schema'; +import { v4 } from 'uuid'; + +const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { + const properties = {}; + propertyNames.forEach( + (property) => + (properties[property] = { + minLength: 1, + description: `The "${property}" cannot be empty`, + }), + ); + return { + if: { + propertyNames: { + enum: [...propertyNames], + }, + }, + then: { properties }, + }; +}; + +export const genericSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + enabled: { type: 'boolean' }, + description: { type: 'string' }, + apiUrl: { type: 'string' }, + apiKey: { type: 'string' }, + triggerType: { type: 'string', enum: ['all', 'keyword', 'none', 'advanced'] }, + triggerOperator: { type: 'string', enum: ['equals', 'contains', 'startsWith', 'endsWith', 'regex'] }, + triggerValue: { type: 'string' }, + expire: { type: 'integer' }, + keywordFinish: { type: 'string' }, + delayMessage: { type: 'integer' }, + unknownMessage: { type: 'string' }, + listeningFromMe: { type: 'boolean' }, + stopBotFromMe: { type: 'boolean' }, + keepOpen: { type: 'boolean' }, + debounceTime: { type: 'integer' }, + ignoreJids: { type: 'array', items: { type: 'string' } }, + }, + required: ['enabled', 'apiUrl', 'triggerType'], + ...isNotEmpty('enabled', 'apiUrl', 'triggerType'), +}; + +export const genericStatusSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + remoteJid: { type: 'string' }, + status: { type: 'string', enum: ['opened', 'closed', 'paused', 'delete'] }, + }, + required: ['remoteJid', 'status'], + ...isNotEmpty('remoteJid', 'status'), +}; + +export const genericSettingSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + expire: { type: 'integer' }, + keywordFinish: { type: 'string' }, + delayMessage: { type: 'integer' }, + unknownMessage: { type: 'string' }, + listeningFromMe: { type: 'boolean' }, + stopBotFromMe: { type: 'boolean' }, + keepOpen: { type: 'boolean' }, + debounceTime: { type: 'integer' }, + ignoreJids: { type: 'array', items: { type: 'string' } }, + botIdFallback: { type: 'string' }, + }, + required: [ + 'expire', + 'keywordFinish', + 'delayMessage', + 'unknownMessage', + 'listeningFromMe', + 'stopBotFromMe', + 'keepOpen', + 'debounceTime', + 'ignoreJids', + ], + ...isNotEmpty( + 'expire', + 'keywordFinish', + 'delayMessage', + 'unknownMessage', + 'listeningFromMe', + 'stopBotFromMe', + 'keepOpen', + 'debounceTime', + 'ignoreJids', + ), +}; + +export const genericIgnoreJidSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + remoteJid: { type: 'string' }, + action: { type: 'string', enum: ['add', 'remove'] }, + }, + required: ['remoteJid', 'action'], + ...isNotEmpty('remoteJid', 'action'), +}; diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 6dc1fa17..f42c6364 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -17,6 +17,8 @@ import { ChatwootController } from './integrations/chatbot/chatwoot/controllers/ import { ChatwootService } from './integrations/chatbot/chatwoot/services/chatwoot.service'; import { DifyController } from './integrations/chatbot/dify/controllers/dify.controller'; import { DifyService } from './integrations/chatbot/dify/services/dify.service'; +import { GenericController } from './integrations/chatbot/generic/controllers/generic.controller'; +import { GenericService } from './integrations/chatbot/generic/services/generic.service'; import { OpenaiController } from './integrations/chatbot/openai/controllers/openai.controller'; import { OpenaiService } from './integrations/chatbot/openai/services/openai.service'; import { TypebotController } from './integrations/chatbot/typebot/controllers/typebot.controller'; @@ -116,4 +118,7 @@ export const openaiController = new OpenaiController(openaiService, prismaReposi const difyService = new DifyService(waMonitor, configService, prismaRepository); export const difyController = new DifyController(difyService, prismaRepository, waMonitor); +const genericService = new GenericService(waMonitor, configService, prismaRepository); +export const genericController = new GenericController(genericService, prismaRepository, waMonitor); + logger.info('Module - ON');