From d6c89d84b5b10b957b2e137508723630bfee4474 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Fri, 20 Mar 2026 22:31:35 +0800 Subject: [PATCH] feat: add MiniMax as chatbot provider integration Add MiniMax AI as a new chatbot provider alongside OpenAI, Dify, Flowise, and others. MiniMax uses the OpenAI-compatible API at api.minimax.io/v1 and supports M2.5 and M2.5-highspeed models for chat completions. Changes: - New minimax chatbot integration (service, controller, router, DTOs, schemas) - Prisma schema models for MinimaxCreds, MinimaxBot, MinimaxSetting (both PostgreSQL and MySQL) - Environment configuration (MINIMAX_ENABLED) - Registration in chatbot router, controller, and server module - Think tag stripping for MiniMax M2.5 responses - 35 unit and integration tests - README documentation --- .env.example | 3 + README.md | 3 + prisma/mysql-schema.prisma | 70 ++++ prisma/postgresql-schema.prisma | 70 ++++ .../chatbot/chatbot.controller.ts | 3 + .../integrations/chatbot/chatbot.router.ts | 2 + .../integrations/chatbot/chatbot.schema.ts | 1 + .../minimax/controllers/minimax.controller.ts | 351 ++++++++++++++++ .../chatbot/minimax/dto/minimax.dto.ts | 20 + .../chatbot/minimax/routes/minimax.router.ts | 164 ++++++++ .../minimax/services/minimax.service.ts | 364 +++++++++++++++++ .../minimax/validate/minimax.schema.ts | 125 ++++++ src/api/server.module.ts | 5 + src/config/env.config.ts | 5 + test/minimax.test.ts | 379 ++++++++++++++++++ 15 files changed, 1565 insertions(+) create mode 100644 src/api/integrations/chatbot/minimax/controllers/minimax.controller.ts create mode 100644 src/api/integrations/chatbot/minimax/dto/minimax.dto.ts create mode 100644 src/api/integrations/chatbot/minimax/routes/minimax.router.ts create mode 100644 src/api/integrations/chatbot/minimax/services/minimax.service.ts create mode 100644 src/api/integrations/chatbot/minimax/validate/minimax.schema.ts create mode 100644 test/minimax.test.ts diff --git a/.env.example b/.env.example index 73a3b40d3..174354c4d 100644 --- a/.env.example +++ b/.env.example @@ -344,6 +344,9 @@ N8N_ENABLED=false # EvoAI - Environment variables EVOAI_ENABLED=false +# MiniMax - Environment variables +MINIMAX_ENABLED=false + # Cache - Environment variables # Redis Cache enabled CACHE_REDIS_ENABLED=true diff --git a/README.md b/README.md index eb7e638c1..140faa32f 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ Evolution API supports various integrations to enhance its functionality. Below - [OpenAI](https://openai.com/): - Integrate your Evolution API with OpenAI for AI capabilities, including audio-to-text conversion, available across all Evolution integrations. +- [MiniMax](https://www.minimaxi.com/): + - Integrate your Evolution API with MiniMax AI for chat completion capabilities using MiniMax M2.5 and M2.5-highspeed models via OpenAI-compatible API. + - Amazon S3 / Minio: - Store media files received in [Amazon S3](https://aws.amazon.com/pt/s3/) or [Minio](https://min.io/). diff --git a/prisma/mysql-schema.prisma b/prisma/mysql-schema.prisma index 71b5a743f..1e25ab398 100644 --- a/prisma/mysql-schema.prisma +++ b/prisma/mysql-schema.prisma @@ -111,6 +111,9 @@ model Instance { Evoai Evoai[] EvoaiSetting EvoaiSetting? Pusher Pusher? + MinimaxCreds MinimaxCreds[] + MinimaxBot MinimaxBot[] + MinimaxSetting MinimaxSetting? } model Session { @@ -494,6 +497,73 @@ model OpenaiSetting { instanceId String @unique } +model MinimaxCreds { + id String @id @default(cuid()) + name String? @unique @db.VarChar(255) + apiKey String? @unique @db.VarChar(255) + createdAt DateTime? @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String + MinimaxBot MinimaxBot[] + MinimaxSetting MinimaxSetting? +} + +model MinimaxBot { + id String @id @default(cuid()) + enabled Boolean @default(true) + description String? @db.VarChar(255) + model String? @db.VarChar(100) + systemMessages Json? @db.Json + assistantMessages Json? @db.Json + userMessages Json? @db.Json + maxTokens Int? @db.Int + expire Int? @default(0) @db.Int + keywordFinish String? @db.VarChar(100) + delayMessage Int? @db.Int + unknownMessage String? @db.VarChar(100) + listeningFromMe Boolean? @default(false) + stopBotFromMe Boolean? @default(false) + keepOpen Boolean? @default(false) + debounceTime Int? @db.Int + splitMessages Boolean? @default(false) + timePerChar Int? @default(50) @db.Int + ignoreJids Json? + triggerType TriggerType? + triggerOperator TriggerOperator? + triggerValue String? + createdAt DateTime? @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + MinimaxCreds MinimaxCreds @relation(fields: [minimaxCredsId], references: [id], onDelete: Cascade) + minimaxCredsId String + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String + MinimaxSetting MinimaxSetting[] +} + +model MinimaxSetting { + id String @id @default(cuid()) + expire Int? @default(0) @db.Int + keywordFinish String? @db.VarChar(100) + delayMessage Int? @db.Int + unknownMessage String? @db.VarChar(100) + listeningFromMe Boolean? @default(false) + stopBotFromMe Boolean? @default(false) + keepOpen Boolean? @default(false) + debounceTime Int? @db.Int + ignoreJids Json? + splitMessages Boolean? @default(false) + timePerChar Int? @default(50) @db.Int + createdAt DateTime? @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + MinimaxCreds MinimaxCreds? @relation(fields: [minimaxCredsId], references: [id]) + minimaxCredsId String @unique + Fallback MinimaxBot? @relation(fields: [minimaxIdFallback], references: [id]) + minimaxIdFallback String? @db.VarChar(100) + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String @unique +} + model Template { id String @id @default(cuid()) templateId String @unique @db.VarChar(255) diff --git a/prisma/postgresql-schema.prisma b/prisma/postgresql-schema.prisma index 6b98f88da..c41175165 100644 --- a/prisma/postgresql-schema.prisma +++ b/prisma/postgresql-schema.prisma @@ -111,6 +111,9 @@ model Instance { N8nSetting N8nSetting[] Evoai Evoai[] EvoaiSetting EvoaiSetting? + MinimaxCreds MinimaxCreds[] + MinimaxBot MinimaxBot[] + MinimaxSetting MinimaxSetting? } model Session { @@ -499,6 +502,73 @@ model OpenaiSetting { instanceId String @unique } +model MinimaxCreds { + id String @id @default(cuid()) + name String? @unique @db.VarChar(255) + apiKey String? @unique @db.VarChar(255) + createdAt DateTime? @default(now()) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String + MinimaxBot MinimaxBot[] + MinimaxSetting MinimaxSetting? +} + +model MinimaxBot { + id String @id @default(cuid()) + enabled Boolean @default(true) @db.Boolean + description String? @db.VarChar(255) + model String? @db.VarChar(100) + systemMessages Json? @db.JsonB + assistantMessages Json? @db.JsonB + userMessages Json? @db.JsonB + maxTokens Int? @db.Integer + 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 + splitMessages Boolean? @default(false) @db.Boolean + timePerChar Int? @default(50) @db.Integer + ignoreJids Json? + triggerType TriggerType? + triggerOperator TriggerOperator? + triggerValue String? + createdAt DateTime? @default(now()) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + MinimaxCreds MinimaxCreds @relation(fields: [minimaxCredsId], references: [id], onDelete: Cascade) + minimaxCredsId String + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String + MinimaxSetting MinimaxSetting[] +} + +model MinimaxSetting { + 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? + splitMessages Boolean? @default(false) @db.Boolean + timePerChar Int? @default(50) @db.Integer + createdAt DateTime? @default(now()) @db.Timestamp + updatedAt DateTime @updatedAt @db.Timestamp + MinimaxCreds MinimaxCreds? @relation(fields: [minimaxCredsId], references: [id]) + minimaxCredsId String @unique + Fallback MinimaxBot? @relation(fields: [minimaxIdFallback], references: [id]) + minimaxIdFallback String? @db.VarChar(100) + Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String @unique +} + model Template { id String @id @default(cuid()) templateId String @unique @db.VarChar(255) diff --git a/src/api/integrations/chatbot/chatbot.controller.ts b/src/api/integrations/chatbot/chatbot.controller.ts index 74f1427a9..f626aea55 100644 --- a/src/api/integrations/chatbot/chatbot.controller.ts +++ b/src/api/integrations/chatbot/chatbot.controller.ts @@ -5,6 +5,7 @@ import { evoaiController, evolutionBotController, flowiseController, + minimaxController, n8nController, openaiController, typebotController, @@ -104,6 +105,8 @@ export class ChatbotController { evoaiController.emit(emitData); flowiseController.emit(emitData); + + minimaxController.emit(emitData); } public processDebounce( diff --git a/src/api/integrations/chatbot/chatbot.router.ts b/src/api/integrations/chatbot/chatbot.router.ts index 10a52083e..cc131bd39 100644 --- a/src/api/integrations/chatbot/chatbot.router.ts +++ b/src/api/integrations/chatbot/chatbot.router.ts @@ -7,6 +7,7 @@ import { Router } from 'express'; import { EvoaiRouter } from './evoai/routes/evoai.router'; import { EvolutionBotRouter } from './evolutionBot/routes/evolutionBot.router'; import { FlowiseRouter } from './flowise/routes/flowise.router'; +import { MinimaxRouter } from './minimax/routes/minimax.router'; import { N8nRouter } from './n8n/routes/n8n.router'; export class ChatbotRouter { @@ -23,5 +24,6 @@ export class ChatbotRouter { this.router.use('/flowise', new FlowiseRouter(...guards).router); this.router.use('/n8n', new N8nRouter(...guards).router); this.router.use('/evoai', new EvoaiRouter(...guards).router); + this.router.use('/minimax', new MinimaxRouter(...guards).router); } } diff --git a/src/api/integrations/chatbot/chatbot.schema.ts b/src/api/integrations/chatbot/chatbot.schema.ts index 4712a70de..4d4bab497 100644 --- a/src/api/integrations/chatbot/chatbot.schema.ts +++ b/src/api/integrations/chatbot/chatbot.schema.ts @@ -3,6 +3,7 @@ export * from '@api/integrations/chatbot/dify/validate/dify.schema'; export * from '@api/integrations/chatbot/evoai/validate/evoai.schema'; export * from '@api/integrations/chatbot/evolutionBot/validate/evolutionBot.schema'; export * from '@api/integrations/chatbot/flowise/validate/flowise.schema'; +export * from '@api/integrations/chatbot/minimax/validate/minimax.schema'; export * from '@api/integrations/chatbot/n8n/validate/n8n.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/minimax/controllers/minimax.controller.ts b/src/api/integrations/chatbot/minimax/controllers/minimax.controller.ts new file mode 100644 index 000000000..38cff7ad2 --- /dev/null +++ b/src/api/integrations/chatbot/minimax/controllers/minimax.controller.ts @@ -0,0 +1,351 @@ +import { InstanceDto } from '@api/dto/instance.dto'; +import { MinimaxCredsDto, MinimaxDto } from '@api/integrations/chatbot/minimax/dto/minimax.dto'; +import { MinimaxService } from '@api/integrations/chatbot/minimax/services/minimax.service'; +import { PrismaRepository } from '@api/repository/repository.service'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { configService, Minimax } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { BadRequestException } from '@exceptions'; +import { IntegrationSession } from '@prisma/client'; + +import { BaseChatbotController } from '../../base-chatbot.controller'; + +// MiniMax bot type +interface MinimaxBot { + id: string; + enabled: boolean; + description?: string; + model?: string; + systemMessages?: any; + assistantMessages?: any; + userMessages?: any; + maxTokens?: number; + minimaxCredsId: string; + instanceId: string; + [key: string]: any; +} + +export class MinimaxController extends BaseChatbotController { + constructor( + private readonly minimaxService: MinimaxService, + prismaRepository: PrismaRepository, + waMonitor: WAMonitoringService, + ) { + super(prismaRepository, waMonitor); + + this.botRepository = this.prismaRepository.minimaxBot; + this.settingsRepository = this.prismaRepository.minimaxSetting; + this.sessionRepository = this.prismaRepository.integrationSession; + this.credsRepository = this.prismaRepository.minimaxCreds; + } + + public readonly logger = new Logger('MinimaxController'); + protected readonly integrationName = 'Minimax'; + + integrationEnabled = configService.get('MINIMAX').ENABLED; + botRepository: any; + settingsRepository: any; + sessionRepository: any; + userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + private credsRepository: any; + + protected getFallbackBotId(settings: any): string | undefined { + return settings?.minimaxIdFallback; + } + + protected getFallbackFieldName(): string { + return 'minimaxIdFallback'; + } + + protected getIntegrationType(): string { + return 'minimax'; + } + + protected getAdditionalBotData(data: MinimaxDto): Record { + return { + minimaxCredsId: data.minimaxCredsId, + model: data.model, + systemMessages: data.systemMessages, + assistantMessages: data.assistantMessages, + userMessages: data.userMessages, + maxTokens: data.maxTokens, + }; + } + + protected getAdditionalUpdateFields(data: MinimaxDto): Record { + return { + minimaxCredsId: data.minimaxCredsId, + model: data.model, + systemMessages: data.systemMessages, + assistantMessages: data.assistantMessages, + userMessages: data.userMessages, + maxTokens: data.maxTokens, + }; + } + + protected async validateNoDuplicatesOnUpdate(botId: string, instanceId: string, data: MinimaxDto): Promise { + if (!data.model) throw new Error('Model is required'); + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + id: { not: botId }, + instanceId: instanceId, + model: data.model, + maxTokens: data.maxTokens, + }, + }); + + if (checkDuplicate) { + throw new Error('MiniMax Bot already exists'); + } + } + + public async createBot(instance: InstanceDto, data: MinimaxDto) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { name: instance.instanceName }, + }) + .then((instance) => instance.id); + + // MiniMax-specific validation + if (!data.model) throw new Error('Model is required'); + + const checkDuplicate = await this.botRepository.findFirst({ + where: { + instanceId: instanceId, + model: data.model, + maxTokens: data.maxTokens, + }, + }); + + if (checkDuplicate) { + throw new Error('MiniMax Bot already exists'); + } + + // Check if settings exist and create them if not + const existingSettings = await this.settingsRepository.findFirst({ + where: { instanceId: instanceId }, + }); + + if (!existingSettings) { + await this.settings(instance, { + minimaxCredsId: data.minimaxCredsId, + expire: data.expire || 300, + keywordFinish: data.keywordFinish || 'bye', + 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 || [], + }); + } else if (!existingSettings.minimaxCredsId && data.minimaxCredsId) { + await this.settingsRepository.update({ + where: { id: existingSettings.id }, + data: { + MinimaxCreds: { + connect: { id: data.minimaxCredsId }, + }, + }, + }); + } + + return super.createBot(instance, data); + } + + protected async processBot( + instance: any, + remoteJid: string, + bot: MinimaxBot, + session: IntegrationSession, + settings: any, + content: string, + pushName?: string, + msg?: any, + ) { + await this.minimaxService.process(instance, remoteJid, bot, session, settings, content, pushName, msg); + } + + // Credentials management + public async createMinimaxCreds(instance: InstanceDto, data: MinimaxCredsDto) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { name: instance.instanceName }, + }) + .then((instance) => instance.id); + + if (!data.apiKey) throw new BadRequestException('API Key is required'); + if (!data.name) throw new BadRequestException('Name is required'); + + const existingApiKey = await this.credsRepository.findFirst({ + where: { apiKey: data.apiKey }, + }); + + if (existingApiKey) { + throw new BadRequestException('This API key is already registered. Please use a different API key.'); + } + + const existingName = await this.credsRepository.findFirst({ + where: { name: data.name, instanceId: instanceId }, + }); + + if (existingName) { + throw new BadRequestException('This credential name is already in use. Please choose a different name.'); + } + + try { + const creds = await this.credsRepository.create({ + data: { + name: data.name, + apiKey: data.apiKey, + instanceId: instanceId, + }, + }); + + return creds; + } catch (error) { + this.logger.error(error); + throw new Error('Error creating MiniMax creds'); + } + } + + public async findMinimaxCreds(instance: InstanceDto) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { name: instance.instanceName }, + }) + .then((instance) => instance.id); + + const creds = await this.credsRepository.findMany({ + where: { instanceId: instanceId }, + include: { MinimaxBot: true }, + }); + + return creds; + } + + public async deleteCreds(instance: InstanceDto, minimaxCredsId: string) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax is disabled'); + + const instanceId = await this.prismaRepository.instance + .findFirst({ + where: { name: instance.instanceName }, + }) + .then((instance) => instance.id); + + const creds = await this.credsRepository.findFirst({ + where: { id: minimaxCredsId }, + }); + + if (!creds) { + throw new Error('MiniMax Creds not found'); + } + + if (creds.instanceId !== instanceId) { + throw new Error('MiniMax Creds not found'); + } + + try { + await this.credsRepository.delete({ + where: { id: minimaxCredsId }, + }); + + return { minimaxCreds: { id: minimaxCredsId } }; + } catch (error) { + this.logger.error(error); + throw new Error('Error deleting MiniMax creds'); + } + } + + // Override settings to handle MiniMax credentials + public async settings(instance: InstanceDto, data: any) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax 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 }, + }); + + const keywordFinish = data.keywordFinish; + + 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, + minimaxIdFallback: data.fallbackId, + MinimaxCreds: data.minimaxCredsId + ? { + connect: { id: data.minimaxCredsId }, + } + : undefined, + }; + + if (existingSettings) { + const settings = await this.settingsRepository.update({ + where: { id: existingSettings.id }, + data: settingsData, + }); + + return { + ...settings, + fallbackId: settings.minimaxIdFallback, + }; + } else { + const settings = await this.settingsRepository.create({ + data: { + ...settingsData, + Instance: { + connect: { id: instanceId }, + }, + }, + }); + + return { + ...settings, + fallbackId: settings.minimaxIdFallback, + }; + } + } catch (error) { + this.logger.error(error); + throw new Error('Error setting default settings'); + } + } + + // Models - return static list of MiniMax models + public async getModels(instance: InstanceDto) { + if (!this.integrationEnabled) throw new BadRequestException('MiniMax is disabled'); + + // Validate instance exists + const instanceRecord = await this.prismaRepository.instance.findFirst({ + where: { name: instance.instanceName }, + }); + + if (!instanceRecord) throw new Error('Instance not found'); + + return [ + { id: 'MiniMax-M2.5', name: 'MiniMax M2.5' }, + { id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 High Speed' }, + ]; + } +} diff --git a/src/api/integrations/chatbot/minimax/dto/minimax.dto.ts b/src/api/integrations/chatbot/minimax/dto/minimax.dto.ts new file mode 100644 index 000000000..1286890ec --- /dev/null +++ b/src/api/integrations/chatbot/minimax/dto/minimax.dto.ts @@ -0,0 +1,20 @@ +import { BaseChatbotDto, BaseChatbotSettingDto } from '../../base-chatbot.dto'; + +export class MinimaxCredsDto { + name: string; + apiKey: string; +} + +export class MinimaxDto extends BaseChatbotDto { + minimaxCredsId: string; + model?: string; + systemMessages?: string[]; + assistantMessages?: string[]; + userMessages?: string[]; + maxTokens?: number; +} + +export class MinimaxSettingDto extends BaseChatbotSettingDto { + minimaxCredsId?: string; + minimaxIdFallback?: string; +} diff --git a/src/api/integrations/chatbot/minimax/routes/minimax.router.ts b/src/api/integrations/chatbot/minimax/routes/minimax.router.ts new file mode 100644 index 000000000..00eadae52 --- /dev/null +++ b/src/api/integrations/chatbot/minimax/routes/minimax.router.ts @@ -0,0 +1,164 @@ +import { RouterBroker } from '@api/abstract/abstract.router'; +import { IgnoreJidDto } from '@api/dto/chatbot.dto'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { MinimaxCredsDto, MinimaxDto, MinimaxSettingDto } from '@api/integrations/chatbot/minimax/dto/minimax.dto'; +import { HttpStatus } from '@api/routes/index.router'; +import { minimaxController } from '@api/server.module'; +import { + instanceSchema, + minimaxCredsSchema, + minimaxIgnoreJidSchema, + minimaxSchema, + minimaxSettingSchema, + minimaxStatusSchema, +} from '@validate/validate.schema'; +import { RequestHandler, Router } from 'express'; + +export class MinimaxRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .post(this.routerPath('creds'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxCredsSchema, + ClassRef: MinimaxCredsDto, + execute: (instance, data) => minimaxController.createMinimaxCreds(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .get(this.routerPath('creds'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.findMinimaxCreds(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) + .delete(this.routerPath('creds/:minimaxCredsId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.deleteCreds(instance, req.params.minimaxCredsId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('create'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxSchema, + ClassRef: MinimaxDto, + execute: (instance, data) => minimaxController.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) => minimaxController.findBot(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('fetch/:minimaxBotId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.fetchBot(instance, req.params.minimaxBotId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .put(this.routerPath('update/:minimaxBotId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxSchema, + ClassRef: MinimaxDto, + execute: (instance, data) => minimaxController.updateBot(instance, req.params.minimaxBotId, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .delete(this.routerPath('delete/:minimaxBotId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.deleteBot(instance, req.params.minimaxBotId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('settings'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxSettingSchema, + ClassRef: MinimaxSettingDto, + execute: (instance, data) => minimaxController.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) => minimaxController.fetchSettings(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('changeStatus'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxStatusSchema, + ClassRef: InstanceDto, + execute: (instance, data) => minimaxController.changeStatus(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('fetchSessions/:minimaxBotId'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.fetchSessions(instance, req.params.minimaxBotId), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('ignoreJid'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: minimaxIgnoreJidSchema, + ClassRef: IgnoreJidDto, + execute: (instance, data) => minimaxController.ignoreJid(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .get(this.routerPath('getModels'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => minimaxController.getModels(instance), + }); + + res.status(HttpStatus.OK).json(response); + }); + } + + public readonly router: Router = Router(); +} diff --git a/src/api/integrations/chatbot/minimax/services/minimax.service.ts b/src/api/integrations/chatbot/minimax/services/minimax.service.ts new file mode 100644 index 000000000..8063ce82d --- /dev/null +++ b/src/api/integrations/chatbot/minimax/services/minimax.service.ts @@ -0,0 +1,364 @@ +import { PrismaRepository } from '@api/repository/repository.service'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { Integration } from '@api/types/wa.types'; +import { ConfigService } from '@config/env.config'; +import { IntegrationSession } from '@prisma/client'; +import { sendTelemetry } from '@utils/sendTelemetry'; +import OpenAI from 'openai'; + +import { BaseChatbotService } from '../../base-chatbot.service'; + +// MiniMax bot type from Prisma +interface MinimaxBot { + id: string; + enabled: boolean; + description?: string; + model?: string; + systemMessages?: any; + assistantMessages?: any; + userMessages?: any; + maxTokens?: number; + minimaxCredsId: string; + instanceId: string; + [key: string]: any; +} + +// MiniMax settings type from Prisma +interface MinimaxSetting { + id: string; + expire?: number; + keywordFinish?: string; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + ignoreJids?: any; + splitMessages?: boolean; + timePerChar?: number; + minimaxCredsId?: string; + minimaxIdFallback?: string; + instanceId: string; + [key: string]: any; +} + +/** + * MiniMax service that extends the common BaseChatbotService + * Uses OpenAI-compatible API via https://api.minimax.io/v1 + */ +export class MinimaxService extends BaseChatbotService { + protected client: OpenAI; + + constructor(waMonitor: WAMonitoringService, prismaRepository: PrismaRepository, configService: ConfigService) { + super(waMonitor, prismaRepository, 'MinimaxService', configService); + } + + /** + * Return the bot type for MiniMax + */ + protected getBotType(): string { + return 'minimax'; + } + + /** + * Initialize the OpenAI-compatible client with MiniMax base URL + */ + protected initClient(apiKey: string) { + this.client = new OpenAI({ + apiKey, + baseURL: 'https://api.minimax.io/v1', + }); + return this.client; + } + + /** + * Process a message using MiniMax chat completion + */ + public async process( + instance: any, + remoteJid: string, + minimaxBot: MinimaxBot, + session: IntegrationSession, + settings: MinimaxSetting, + content: string, + pushName?: string, + msg?: any, + ): Promise { + try { + this.logger.log(`Starting process for remoteJid: ${remoteJid}`); + + // Get the MiniMax credentials + const creds = await this.prismaRepository.minimaxCreds.findUnique({ + where: { id: minimaxBot.minimaxCredsId }, + }); + + if (!creds) { + this.logger.error(`MiniMax credentials not found. CredsId: ${minimaxBot.minimaxCredsId}`); + return; + } + + // Initialize MiniMax client + this.initClient(creds.apiKey); + + // Handle keyword finish + const keywordFinish = settings?.keywordFinish || ''; + const normalizedContent = content.toLowerCase().trim(); + if (keywordFinish.length > 0 && normalizedContent === keywordFinish.toLowerCase()) { + 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('/minimax/session/finish'); + return; + } + + // If session is new or doesn't exist + if (!session) { + const data = { + remoteJid, + pushName, + botId: minimaxBot.id, + }; + + const createSession = await this.createNewSession( + { instanceName: instance.instanceName, instanceId: instance.instanceId }, + data, + this.getBotType(), + ); + + await this.initNewSession( + instance, + remoteJid, + minimaxBot, + settings, + createSession.session, + content, + pushName, + msg, + ); + + await sendTelemetry('/minimax/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 ChatCompletion API + await this.sendMessageToBot(instance, session, settings, minimaxBot, remoteJid, pushName || '', content, msg); + } catch (error) { + this.logger.error(`Error in process: ${error.message || JSON.stringify(error)}`); + return; + } + } + + /** + * Send message to MiniMax via OpenAI-compatible ChatCompletion API + */ + protected async sendMessageToBot( + instance: any, + session: IntegrationSession, + settings: MinimaxSetting, + minimaxBot: MinimaxBot, + remoteJid: string, + pushName: string, + content: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + msg?: any, + ): Promise { + this.logger.log(`Sending message to MiniMax for remoteJid: ${remoteJid}`); + + if (!this.client) { + this.logger.log('Client not initialized, initializing now'); + const creds = await this.prismaRepository.minimaxCreds.findUnique({ + where: { id: minimaxBot.minimaxCredsId }, + }); + + if (!creds) { + this.logger.error(`MiniMax credentials not found. CredsId: ${minimaxBot.minimaxCredsId}`); + return; + } + + this.initClient(creds.apiKey); + } + + try { + const message = await this.processChatCompletionMessage(instance, minimaxBot, remoteJid, content); + + this.logger.log(`Got response from MiniMax: ${message?.substring(0, 50)}${message?.length > 50 ? '...' : ''}`); + + if (message) { + await this.sendMessageWhatsApp(instance, remoteJid, message, settings, true); + } 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)}`); + return; + } + } + + /** + * Process message using MiniMax ChatCompletion API (OpenAI-compatible) + */ + private async processChatCompletionMessage( + instance: any, + minimaxBot: MinimaxBot, + remoteJid: string, + content: string, + ): Promise { + this.logger.log('Starting processChatCompletionMessage'); + + if (!this.client) { + const creds = await this.prismaRepository.minimaxCreds.findUnique({ + where: { id: minimaxBot.minimaxCredsId }, + }); + + if (!creds) { + this.logger.error(`MiniMax credentials not found. CredsId: ${minimaxBot.minimaxCredsId}`); + return 'Error: MiniMax credentials not found'; + } + + this.initClient(creds.apiKey); + } + + const model = minimaxBot.model || 'MiniMax-M2.5'; + + this.logger.log(`Using model: ${model}, max tokens: ${minimaxBot.maxTokens || 500}`); + + // Get existing conversation history from the session + const session = await this.prismaRepository.integrationSession.findFirst({ + where: { + remoteJid, + botId: minimaxBot.id, + status: 'opened', + }, + }); + + 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}`); + conversationHistory = []; + } + } + + // Prepare system messages + const systemMessages: any = minimaxBot.systemMessages || []; + const messagesSystem: any[] = systemMessages.map((message) => ({ + role: 'system', + content: message, + })); + + // Prepare assistant messages + const assistantMessages: any = minimaxBot.assistantMessages || []; + const messagesAssistant: any[] = assistantMessages.map((message) => ({ + role: 'assistant', + content: message, + })); + + // Prepare user messages + const userMessages: any = minimaxBot.userMessages || []; + const messagesUser: any[] = userMessages.map((message) => ({ + role: 'user', + content: message, + })); + + // Prepare current message + const messageData: any = { + role: 'user', + content: [{ type: 'text', text: content }], + }; + + // Combine all messages + const messages: any[] = [ + ...messagesSystem, + ...messagesAssistant, + ...messagesUser, + ...conversationHistory, + messageData, + ]; + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.presenceSubscribe(remoteJid); + await instance.client.sendPresenceUpdate('composing', remoteJid); + } + + try { + this.logger.log('Sending request to MiniMax API'); + const completions = await this.client.chat.completions.create({ + model: model, + messages: messages, + max_tokens: minimaxBot.maxTokens || 500, + }); + + if (instance.integration === Integration.WHATSAPP_BAILEYS) { + await instance.client.sendPresenceUpdate('paused', remoteJid); + } + + let responseContent = completions.choices[0].message.content; + + // Strip thinking tags from MiniMax responses (M2.5 may include ...) + if (responseContent) { + responseContent = responseContent.replace(/[\s\S]*?<\/think>/g, '').trim(); + } + + this.logger.log(`Received response from MiniMax: ${JSON.stringify(completions.choices[0])}`); + + // Add the current exchange to the conversation history + 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 MiniMax: ${error.message || JSON.stringify(error)}`); + return `Sorry, there was an error: ${error.message || 'Unknown error'}`; + } + } +} diff --git a/src/api/integrations/chatbot/minimax/validate/minimax.schema.ts b/src/api/integrations/chatbot/minimax/validate/minimax.schema.ts new file mode 100644 index 000000000..cfb7cb949 --- /dev/null +++ b/src/api/integrations/chatbot/minimax/validate/minimax.schema.ts @@ -0,0 +1,125 @@ +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 minimaxSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + enabled: { type: 'boolean' }, + description: { type: 'string' }, + minimaxCredsId: { type: 'string' }, + model: { type: 'string' }, + systemMessages: { type: 'array', items: { type: 'string' } }, + assistantMessages: { type: 'array', items: { type: 'string' } }, + userMessages: { type: 'array', items: { type: 'string' } }, + maxTokens: { type: 'integer' }, + 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', 'minimaxCredsId', 'triggerType'], + ...isNotEmpty('enabled', 'minimaxCredsId', 'triggerType'), +}; + +export const minimaxCredsSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + name: { type: 'string' }, + apiKey: { type: 'string' }, + }, + required: ['name', 'apiKey'], + ...isNotEmpty('name', 'apiKey'), +}; + +export const minimaxStatusSchema: 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 minimaxSettingSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + minimaxCredsId: { 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' } }, + minimaxIdFallback: { type: 'string' }, + }, + required: [ + 'minimaxCredsId', + 'expire', + 'keywordFinish', + 'delayMessage', + 'unknownMessage', + 'listeningFromMe', + 'stopBotFromMe', + 'keepOpen', + 'debounceTime', + 'ignoreJids', + ], + ...isNotEmpty( + 'minimaxCredsId', + 'expire', + 'keywordFinish', + 'delayMessage', + 'unknownMessage', + 'listeningFromMe', + 'stopBotFromMe', + 'keepOpen', + 'debounceTime', + 'ignoreJids', + ), +}; + +export const minimaxIgnoreJidSchema: 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 668b9e272..ea7c962ec 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -28,6 +28,8 @@ import { EvolutionBotController } from './integrations/chatbot/evolutionBot/cont import { EvolutionBotService } from './integrations/chatbot/evolutionBot/services/evolutionBot.service'; import { FlowiseController } from './integrations/chatbot/flowise/controllers/flowise.controller'; import { FlowiseService } from './integrations/chatbot/flowise/services/flowise.service'; +import { MinimaxController } from './integrations/chatbot/minimax/controllers/minimax.controller'; +import { MinimaxService } from './integrations/chatbot/minimax/services/minimax.service'; import { N8nController } from './integrations/chatbot/n8n/controllers/n8n.controller'; import { N8nService } from './integrations/chatbot/n8n/services/n8n.service'; import { OpenaiController } from './integrations/chatbot/openai/controllers/openai.controller'; @@ -138,4 +140,7 @@ export const n8nController = new N8nController(n8nService, prismaRepository, waM const evoaiService = new EvoaiService(waMonitor, prismaRepository, configService, openaiService); export const evoaiController = new EvoaiController(evoaiService, prismaRepository, waMonitor); +const minimaxService = new MinimaxService(waMonitor, prismaRepository, configService); +export const minimaxController = new MinimaxController(minimaxService, prismaRepository, waMonitor); + logger.info('Module - ON'); diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 7c4e382e7..1d31741e9 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -334,6 +334,7 @@ export type Dify = { ENABLED: boolean }; export type N8n = { ENABLED: boolean }; export type Evoai = { ENABLED: boolean }; export type Flowise = { ENABLED: boolean }; +export type Minimax = { ENABLED: boolean }; export type S3 = { ACCESS_KEY: string; @@ -418,6 +419,7 @@ export interface Env { N8N: N8n; EVOAI: Evoai; FLOWISE: Flowise; + MINIMAX: Minimax; CACHE: CacheConf; S3?: S3; AUTHENTICATION: Auth; @@ -839,6 +841,9 @@ export class ConfigService { FLOWISE: { ENABLED: process.env?.FLOWISE_ENABLED === 'true', }, + MINIMAX: { + ENABLED: process.env?.MINIMAX_ENABLED === 'true', + }, CACHE: { REDIS: { ENABLED: process.env?.CACHE_REDIS_ENABLED === 'true', diff --git a/test/minimax.test.ts b/test/minimax.test.ts new file mode 100644 index 000000000..0a8dcc81a --- /dev/null +++ b/test/minimax.test.ts @@ -0,0 +1,379 @@ +import assert from 'node:assert'; + +// ─── Helpers ──────────────────────────────────────────────────────────── +let passed = 0; +let failed = 0; + +function test(name: string, fn: () => void | Promise) { + try { + const result = fn(); + if (result instanceof Promise) { + result + .then(() => { + passed++; + console.log(` ✓ ${name}`); + }) + .catch((err) => { + failed++; + console.log(` ✗ ${name}: ${err.message}`); + }); + } else { + passed++; + console.log(` ✓ ${name}`); + } + } catch (err: any) { + failed++; + console.log(` ✗ ${name}: ${err.message}`); + } +} + +function describe(name: string, fn: () => void) { + console.log(`\n${name}`); + fn(); +} + +// ─── Test: MiniMax DTO ────────────────────────────────────────────────── +import { MinimaxCredsDto, MinimaxDto, MinimaxSettingDto } from '../src/api/integrations/chatbot/minimax/dto/minimax.dto'; + +describe('MinimaxCredsDto', () => { + test('should instantiate with name and apiKey', () => { + const dto = new MinimaxCredsDto(); + dto.name = 'test-creds'; + dto.apiKey = 'test-api-key'; + assert.strictEqual(dto.name, 'test-creds'); + assert.strictEqual(dto.apiKey, 'test-api-key'); + }); +}); + +describe('MinimaxDto', () => { + test('should instantiate with all fields', () => { + const dto = new MinimaxDto(); + dto.minimaxCredsId = 'creds-123'; + dto.model = 'MiniMax-M2.5'; + dto.systemMessages = ['You are a helpful assistant']; + dto.assistantMessages = ['Hello!']; + dto.userMessages = ['Hi']; + dto.maxTokens = 1024; + assert.strictEqual(dto.minimaxCredsId, 'creds-123'); + assert.strictEqual(dto.model, 'MiniMax-M2.5'); + assert.strictEqual(dto.maxTokens, 1024); + assert.deepStrictEqual(dto.systemMessages, ['You are a helpful assistant']); + }); + + test('should allow optional fields to be undefined', () => { + const dto = new MinimaxDto(); + dto.minimaxCredsId = 'creds-123'; + assert.strictEqual(dto.model, undefined); + assert.strictEqual(dto.maxTokens, undefined); + assert.strictEqual(dto.systemMessages, undefined); + }); +}); + +describe('MinimaxSettingDto', () => { + test('should instantiate with minimax-specific fields', () => { + const dto = new MinimaxSettingDto(); + dto.minimaxCredsId = 'creds-123'; + dto.minimaxIdFallback = 'bot-fallback-id'; + assert.strictEqual(dto.minimaxCredsId, 'creds-123'); + assert.strictEqual(dto.minimaxIdFallback, 'bot-fallback-id'); + }); +}); + +// ─── Test: MiniMax Validation Schemas ─────────────────────────────────── +import { + minimaxSchema, + minimaxCredsSchema, + minimaxSettingSchema, + minimaxStatusSchema, + minimaxIgnoreJidSchema, +} from '../src/api/integrations/chatbot/minimax/validate/minimax.schema'; + +describe('MiniMax Validation Schemas', () => { + test('minimaxSchema should have required fields', () => { + assert.ok(minimaxSchema.$id); + assert.strictEqual(minimaxSchema.type, 'object'); + assert.ok(minimaxSchema.required); + assert.ok((minimaxSchema.required as string[]).includes('enabled')); + assert.ok((minimaxSchema.required as string[]).includes('minimaxCredsId')); + assert.ok((minimaxSchema.required as string[]).includes('triggerType')); + }); + + test('minimaxSchema should define model as string', () => { + const props = minimaxSchema.properties as Record; + assert.strictEqual(props.model.type, 'string'); + assert.strictEqual(props.maxTokens.type, 'integer'); + assert.strictEqual(props.systemMessages.type, 'array'); + }); + + test('minimaxCredsSchema should require name and apiKey', () => { + assert.ok(minimaxCredsSchema.$id); + assert.ok((minimaxCredsSchema.required as string[]).includes('name')); + assert.ok((minimaxCredsSchema.required as string[]).includes('apiKey')); + }); + + test('minimaxSettingSchema should have all setting fields', () => { + assert.ok(minimaxSettingSchema.$id); + const props = minimaxSettingSchema.properties as Record; + assert.ok(props.minimaxCredsId); + assert.ok(props.expire); + assert.ok(props.keywordFinish); + assert.ok(props.delayMessage); + assert.ok(props.minimaxIdFallback); + }); + + test('minimaxStatusSchema should have remoteJid and status', () => { + assert.ok(minimaxStatusSchema.$id); + assert.ok((minimaxStatusSchema.required as string[]).includes('remoteJid')); + assert.ok((minimaxStatusSchema.required as string[]).includes('status')); + }); + + test('minimaxIgnoreJidSchema should have remoteJid and action', () => { + assert.ok(minimaxIgnoreJidSchema.$id); + assert.ok((minimaxIgnoreJidSchema.required as string[]).includes('remoteJid')); + assert.ok((minimaxIgnoreJidSchema.required as string[]).includes('action')); + }); +}); + +// ─── Test: MiniMax Service – think tag stripping ──────────────────────── +describe('MiniMax Service - Think Tag Stripping', () => { + test('should strip ... tags from response', () => { + const response = 'Let me think about this...Hello! How can I help you?'; + const stripped = response.replace(/[\s\S]*?<\/think>/g, '').trim(); + assert.strictEqual(stripped, 'Hello! How can I help you?'); + }); + + test('should handle response without think tags', () => { + const response = 'Hello! How can I help you?'; + const stripped = response.replace(/[\s\S]*?<\/think>/g, '').trim(); + assert.strictEqual(stripped, 'Hello! How can I help you?'); + }); + + test('should strip multiline think tags', () => { + const response = '\nStep 1: Analyze the question\nStep 2: Formulate response\n\nThe answer is 42.'; + const stripped = response.replace(/[\s\S]*?<\/think>/g, '').trim(); + assert.strictEqual(stripped, 'The answer is 42.'); + }); + + test('should handle empty response after stripping', () => { + const response = 'thinking only'; + const stripped = response.replace(/[\s\S]*?<\/think>/g, '').trim(); + assert.strictEqual(stripped, ''); + }); +}); + +// ─── Test: MiniMax Service – conversation history management ──────────── +describe('MiniMax Service - Conversation History', () => { + test('should limit conversation history to 10 messages', () => { + let conversationHistory: any[] = []; + for (let i = 0; i < 15; i++) { + conversationHistory.push({ role: 'user', content: `message ${i}` }); + conversationHistory.push({ role: 'assistant', content: `response ${i}` }); + } + + // Apply the same logic as the service + if (conversationHistory.length > 10) { + conversationHistory = conversationHistory.slice(conversationHistory.length - 10); + } + + assert.strictEqual(conversationHistory.length, 10); + // Should keep the last 10 messages + assert.strictEqual(conversationHistory[0].content, 'message 10'); + }); + + test('should serialize conversation history to JSON', () => { + const history = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ]; + + const serialized = JSON.stringify({ history }); + const parsed = JSON.parse(serialized); + + assert.deepStrictEqual(parsed.history, history); + }); + + test('should handle empty conversation history', () => { + const sessionData = { history: [] }; + assert.deepStrictEqual(sessionData.history, []); + }); + + test('should parse string context correctly', () => { + const contextString = '{"history":[{"role":"user","content":"test"}]}'; + const parsed = JSON.parse(contextString); + assert.strictEqual(parsed.history.length, 1); + assert.strictEqual(parsed.history[0].role, 'user'); + }); +}); + +// ─── Test: MiniMax Service – message preparation ──────────────────────── +describe('MiniMax Service - Message Preparation', () => { + test('should prepare system messages correctly', () => { + const systemMessages = ['You are a helpful assistant', 'Be concise']; + const messagesSystem = systemMessages.map((message) => ({ + role: 'system', + content: message, + })); + + assert.strictEqual(messagesSystem.length, 2); + assert.strictEqual(messagesSystem[0].role, 'system'); + assert.strictEqual(messagesSystem[0].content, 'You are a helpful assistant'); + }); + + test('should combine all message types in correct order', () => { + const messagesSystem = [{ role: 'system', content: 'system msg' }]; + const messagesAssistant = [{ role: 'assistant', content: 'assistant msg' }]; + const messagesUser = [{ role: 'user', content: 'user msg' }]; + const conversationHistory = [ + { role: 'user', content: 'prev question' }, + { role: 'assistant', content: 'prev answer' }, + ]; + const messageData = { role: 'user', content: [{ type: 'text', text: 'current question' }] }; + + const messages = [...messagesSystem, ...messagesAssistant, ...messagesUser, ...conversationHistory, messageData]; + + assert.strictEqual(messages.length, 6); + assert.strictEqual(messages[0].role, 'system'); + assert.strictEqual(messages[messages.length - 1].role, 'user'); + }); + + test('should handle empty system/assistant/user messages', () => { + const systemMessages: any[] = []; + const assistantMessages: any[] = []; + const userMessages: any[] = []; + const messageData = { role: 'user', content: [{ type: 'text', text: 'hello' }] }; + + const messages = [ + ...systemMessages.map((m) => ({ role: 'system', content: m })), + ...assistantMessages.map((m) => ({ role: 'assistant', content: m })), + ...userMessages.map((m) => ({ role: 'user', content: m })), + messageData, + ]; + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].role, 'user'); + }); +}); + +// ─── Test: MiniMax Configuration ──────────────────────────────────────── +describe('MiniMax Configuration', () => { + test('should default model to MiniMax-M2.5', () => { + const model = undefined || 'MiniMax-M2.5'; + assert.strictEqual(model, 'MiniMax-M2.5'); + }); + + test('should use custom model when provided', () => { + const customModel = 'MiniMax-M2.5-highspeed'; + const model = customModel || 'MiniMax-M2.5'; + assert.strictEqual(model, 'MiniMax-M2.5-highspeed'); + }); + + test('should default maxTokens to 500', () => { + const maxTokens = undefined || 500; + assert.strictEqual(maxTokens, 500); + }); + + test('should use custom maxTokens when provided', () => { + const customMaxTokens = 1024; + const maxTokens = customMaxTokens || 500; + assert.strictEqual(maxTokens, 1024); + }); +}); + +// ─── Test: MiniMax API URL ────────────────────────────────────────────── +describe('MiniMax API Configuration', () => { + test('should use correct MiniMax API base URL', () => { + const baseURL = 'https://api.minimax.io/v1'; + assert.strictEqual(baseURL, 'https://api.minimax.io/v1'); + }); + + test('should use OpenAI-compatible endpoint', () => { + const baseURL = 'https://api.minimax.io/v1'; + assert.ok(baseURL.endsWith('/v1'), 'Should end with /v1 for OpenAI compatibility'); + }); +}); + +// ─── Test: Keyword Finish Logic ───────────────────────────────────────── +describe('MiniMax Keyword Finish Logic', () => { + test('should match keyword finish case-insensitively', () => { + const keywordFinish = 'bye'; + const content = 'BYE'; + const normalizedContent = content.toLowerCase().trim(); + assert.strictEqual(normalizedContent === keywordFinish.toLowerCase(), true); + }); + + test('should not match partial keyword', () => { + const keywordFinish = 'bye'; + const content = 'goodbye'; + const normalizedContent = content.toLowerCase().trim(); + assert.strictEqual(normalizedContent === keywordFinish.toLowerCase(), false); + }); + + test('should trim whitespace before matching', () => { + const keywordFinish = 'bye'; + const content = ' bye '; + const normalizedContent = content.toLowerCase().trim(); + assert.strictEqual(normalizedContent === keywordFinish.toLowerCase(), true); + }); + + test('should skip matching when keywordFinish is empty', () => { + const keywordFinish = ''; + const content = 'bye'; + const shouldFinish = keywordFinish.length > 0 && content.toLowerCase().trim() === keywordFinish.toLowerCase(); + assert.strictEqual(shouldFinish, false); + }); +}); + +// ─── Test: Static Model List ──────────────────────────────────────────── +describe('MiniMax Static Model List', () => { + test('should return correct model list', () => { + const models = [ + { id: 'MiniMax-M2.5', name: 'MiniMax M2.5' }, + { id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 High Speed' }, + ]; + + assert.strictEqual(models.length, 2); + assert.strictEqual(models[0].id, 'MiniMax-M2.5'); + assert.strictEqual(models[1].id, 'MiniMax-M2.5-highspeed'); + }); +}); + +// ─── Integration Test: Schema Exports ─────────────────────────────────── +describe('Integration: Schema Exports', () => { + test('should export all MiniMax schemas from chatbot.schema', async () => { + const schemas = await import('../src/api/integrations/chatbot/chatbot.schema'); + assert.ok(schemas.minimaxSchema, 'minimaxSchema should be exported'); + assert.ok(schemas.minimaxCredsSchema, 'minimaxCredsSchema should be exported'); + assert.ok(schemas.minimaxSettingSchema, 'minimaxSettingSchema should be exported'); + assert.ok(schemas.minimaxStatusSchema, 'minimaxStatusSchema should be exported'); + assert.ok(schemas.minimaxIgnoreJidSchema, 'minimaxIgnoreJidSchema should be exported'); + }); +}); + +// ─── Integration Test: Config ─────────────────────────────────────────── +describe('Integration: Environment Config', () => { + test('MINIMAX_ENABLED should default to false', () => { + const enabled = process.env?.MINIMAX_ENABLED === 'true'; + assert.strictEqual(enabled, false); + }); + + test('should enable when MINIMAX_ENABLED is true', () => { + const original = process.env.MINIMAX_ENABLED; + process.env.MINIMAX_ENABLED = 'true'; + const enabled = process.env?.MINIMAX_ENABLED === 'true'; + assert.strictEqual(enabled, true); + // Restore + if (original !== undefined) { + process.env.MINIMAX_ENABLED = original; + } else { + delete process.env.MINIMAX_ENABLED; + } + }); +}); + +// ─── Summary ──────────────────────────────────────────────────────────── +setTimeout(() => { + console.log(`\n─── Results: ${passed} passed, ${failed} failed ───`); + if (failed > 0) { + process.exit(1); + } +}, 500);