mirror of
https://github.com/EvolutionAPI/evolution-api.git
synced 2025-12-20 12:22:21 -06:00
feat(chatbot): implement base chatbot structure and enhance integration capabilities
- Introduced a base structure for chatbot integrations, including BaseChatbotController and BaseChatbotService. - Added common DTOs for chatbot settings and data to streamline integration processes. - Updated existing chatbot controllers (Dify, Evoai, N8n) to extend from the new base classes, improving code reusability and maintainability. - Enhanced media message handling across integrations, including audio transcription capabilities using OpenAI's Whisper API. - Refactored service methods to accommodate new message structures and improve error handling.
This commit is contained in:
443
src/api/integrations/chatbot/base-chatbot.service.ts
Normal file
443
src/api/integrations/chatbot/base-chatbot.service.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { InstanceDto } from '@api/dto/instance.dto';
|
||||
import { PrismaRepository } from '@api/repository/repository.service';
|
||||
import { WAMonitoringService } from '@api/services/monitor.service';
|
||||
import { ConfigService, Language } from '@config/env.config';
|
||||
import { Logger } from '@config/logger.config';
|
||||
import { IntegrationSession } from '@prisma/client';
|
||||
import { Integration } from '@api/types/wa.types';
|
||||
import axios from 'axios';
|
||||
import FormData from 'form-data';
|
||||
|
||||
/**
|
||||
* Base class for all chatbot service implementations
|
||||
* Contains common methods shared across different chatbot integrations
|
||||
*/
|
||||
export abstract class BaseChatbotService<BotType = any, SettingsType = any> {
|
||||
protected readonly logger: Logger;
|
||||
protected readonly waMonitor: WAMonitoringService;
|
||||
protected readonly prismaRepository: PrismaRepository;
|
||||
protected readonly configService?: ConfigService;
|
||||
|
||||
constructor(
|
||||
waMonitor: WAMonitoringService,
|
||||
prismaRepository: PrismaRepository,
|
||||
loggerName: string,
|
||||
configService?: ConfigService,
|
||||
) {
|
||||
this.waMonitor = waMonitor;
|
||||
this.prismaRepository = prismaRepository;
|
||||
this.logger = new Logger(loggerName);
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message contains an image
|
||||
*/
|
||||
protected isImageMessage(content: string): boolean {
|
||||
return content.includes('imageMessage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message contains audio
|
||||
*/
|
||||
protected isAudioMessage(content: string): boolean {
|
||||
return content.includes('audioMessage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is valid JSON
|
||||
*/
|
||||
protected isJSON(str: string): boolean {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the media type from a URL based on its extension
|
||||
*/
|
||||
protected getMediaType(url: string): string | null {
|
||||
const extension = url.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
const audioExtensions = ['mp3', 'wav', 'aac', 'ogg'];
|
||||
const videoExtensions = ['mp4', 'avi', 'mkv', 'mov'];
|
||||
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
|
||||
|
||||
if (imageExtensions.includes(extension || '')) return 'image';
|
||||
if (audioExtensions.includes(extension || '')) return 'audio';
|
||||
if (videoExtensions.includes(extension || '')) return 'video';
|
||||
if (documentExtensions.includes(extension || '')) return 'document';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribes audio to text using OpenAI's Whisper API
|
||||
*/
|
||||
protected async speechToText(audioBuffer: Buffer): Promise<string | null> {
|
||||
if (!this.configService) {
|
||||
this.logger.error('ConfigService not available for speech-to-text transcription');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the API key from process.env directly since ConfigService might not access it correctly
|
||||
const apiKey = this.configService.get<any>('OPENAI')?.API_KEY || process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
this.logger.error('No OpenAI API key set for Whisper transcription');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lang = this.configService.get<Language>('LANGUAGE').includes('pt')
|
||||
? 'pt'
|
||||
: this.configService.get<Language>('LANGUAGE');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBuffer, 'audio.ogg');
|
||||
formData.append('model', 'whisper-1');
|
||||
formData.append('language', lang);
|
||||
|
||||
const response = await axios.post('https://api.openai.com/v1/audio/transcriptions', formData, {
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response?.data?.text || null;
|
||||
} catch (err) {
|
||||
this.logger.error(`Whisper transcription failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chatbot session
|
||||
*/
|
||||
public async createNewSession(instance: InstanceDto | any, data: any, type: string) {
|
||||
try {
|
||||
// Extract pushName safely - if data.pushName is an object with a pushName property, use that
|
||||
const pushNameValue = typeof data.pushName === 'object' && data.pushName?.pushName
|
||||
? data.pushName.pushName
|
||||
: (typeof data.pushName === 'string' ? data.pushName : null);
|
||||
|
||||
// Extract remoteJid safely
|
||||
const remoteJidValue = typeof data.remoteJid === 'object' && data.remoteJid?.remoteJid
|
||||
? data.remoteJid.remoteJid
|
||||
: data.remoteJid;
|
||||
|
||||
const session = await this.prismaRepository.integrationSession.create({
|
||||
data: {
|
||||
remoteJid: remoteJidValue,
|
||||
pushName: pushNameValue,
|
||||
sessionId: remoteJidValue,
|
||||
status: 'opened',
|
||||
awaitUser: false,
|
||||
botId: data.botId,
|
||||
instanceId: instance.instanceId,
|
||||
type: type,
|
||||
},
|
||||
});
|
||||
|
||||
return { session };
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard implementation for processing incoming messages
|
||||
* This handles the common workflow across all chatbot types:
|
||||
* 1. Check for existing session or create new one
|
||||
* 2. Handle message based on session state
|
||||
*/
|
||||
public async process(
|
||||
instance: any,
|
||||
remoteJid: string,
|
||||
bot: BotType,
|
||||
session: IntegrationSession,
|
||||
settings: SettingsType,
|
||||
content: string,
|
||||
pushName?: string,
|
||||
msg?: any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// For new sessions or sessions awaiting initialization
|
||||
if (!session || session.status === 'paused') {
|
||||
await this.initNewSession(instance, remoteJid, bot, settings, session, content, pushName, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing sessions, keywords might indicate the conversation should end
|
||||
const keywordFinish = (settings as any)?.keywordFinish || [];
|
||||
const normalizedContent = content.toLowerCase().trim();
|
||||
if (
|
||||
keywordFinish.length > 0 &&
|
||||
keywordFinish.some((keyword: string) => normalizedContent === keyword.toLowerCase())
|
||||
) {
|
||||
// Update session to closed and return
|
||||
await this.prismaRepository.integrationSession.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
status: 'closed',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward the message to the chatbot API
|
||||
await this.sendMessageToBot(instance, session, settings, bot, remoteJid, pushName || '', content, msg);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in process: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard implementation for sending messages to WhatsApp
|
||||
* This handles common patterns like markdown links and formatting
|
||||
*/
|
||||
protected async sendMessageWhatsApp(
|
||||
instance: any,
|
||||
remoteJid: string,
|
||||
message: string,
|
||||
settings: SettingsType
|
||||
): Promise<void> {
|
||||
if (!message) return;
|
||||
|
||||
const linkRegex = /(!?)\[(.*?)\]\((.*?)\)/g;
|
||||
let textBuffer = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const splitMessages = (settings as any)?.splitMessages ?? false;
|
||||
const timePerChar = (settings as any)?.timePerChar ?? 0;
|
||||
const minDelay = 1000;
|
||||
const maxDelay = 20000;
|
||||
|
||||
while ((match = linkRegex.exec(message)) !== null) {
|
||||
const [fullMatch, exclamation, altText, url] = match;
|
||||
const mediaType = this.getMediaType(url);
|
||||
const beforeText = message.slice(lastIndex, match.index);
|
||||
|
||||
if (beforeText) {
|
||||
textBuffer += beforeText;
|
||||
}
|
||||
|
||||
if (mediaType) {
|
||||
// Send accumulated text before sending media
|
||||
if (textBuffer.trim()) {
|
||||
await this.sendFormattedText(instance, remoteJid, textBuffer.trim(), settings, splitMessages);
|
||||
textBuffer = '';
|
||||
}
|
||||
|
||||
// Handle sending the media
|
||||
try {
|
||||
switch (mediaType) {
|
||||
case 'image':
|
||||
await instance.mediaMessage({
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: (settings as any)?.delayMessage || 1000,
|
||||
caption: altText,
|
||||
media: url,
|
||||
});
|
||||
break;
|
||||
case 'video':
|
||||
await instance.mediaMessage({
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: (settings as any)?.delayMessage || 1000,
|
||||
caption: altText,
|
||||
media: url,
|
||||
});
|
||||
break;
|
||||
case 'document':
|
||||
await instance.documentMessage({
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: (settings as any)?.delayMessage || 1000,
|
||||
fileName: altText || 'document',
|
||||
media: url,
|
||||
});
|
||||
break;
|
||||
case 'audio':
|
||||
await instance.audioMessage({
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: (settings as any)?.delayMessage || 1000,
|
||||
media: url,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error sending media: ${error}`);
|
||||
// If media fails, at least send the alt text and URL
|
||||
textBuffer += `${altText}: ${url}`;
|
||||
}
|
||||
} else {
|
||||
// It's a regular link, keep it in the text
|
||||
textBuffer += `[${altText}](${url})`;
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last match
|
||||
if (lastIndex < message.length) {
|
||||
textBuffer += message.slice(lastIndex);
|
||||
}
|
||||
|
||||
// Send any remaining text
|
||||
if (textBuffer.trim()) {
|
||||
await this.sendFormattedText(instance, remoteJid, textBuffer.trim(), settings, splitMessages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to send formatted text with proper typing indicators and delays
|
||||
*/
|
||||
private async sendFormattedText(
|
||||
instance: any,
|
||||
remoteJid: string,
|
||||
text: string,
|
||||
settings: any,
|
||||
splitMessages: boolean
|
||||
): Promise<void> {
|
||||
const timePerChar = settings?.timePerChar ?? 0;
|
||||
const minDelay = 1000;
|
||||
const maxDelay = 20000;
|
||||
|
||||
if (splitMessages) {
|
||||
const multipleMessages = text.split('\n\n');
|
||||
for (let index = 0; index < multipleMessages.length; index++) {
|
||||
const message = multipleMessages[index];
|
||||
if (!message.trim()) continue;
|
||||
|
||||
const delay = Math.min(Math.max(message.length * timePerChar, minDelay), maxDelay);
|
||||
|
||||
if (instance.integration === Integration.WHATSAPP_BAILEYS) {
|
||||
await instance.client.presenceSubscribe(remoteJid);
|
||||
await instance.client.sendPresenceUpdate('composing', remoteJid);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
await instance.textMessage(
|
||||
{
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: settings?.delayMessage || 1000,
|
||||
text: message,
|
||||
},
|
||||
false,
|
||||
);
|
||||
resolve();
|
||||
}, delay);
|
||||
});
|
||||
|
||||
if (instance.integration === Integration.WHATSAPP_BAILEYS) {
|
||||
await instance.client.sendPresenceUpdate('paused', remoteJid);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const delay = Math.min(Math.max(text.length * timePerChar, minDelay), maxDelay);
|
||||
|
||||
if (instance.integration === Integration.WHATSAPP_BAILEYS) {
|
||||
await instance.client.presenceSubscribe(remoteJid);
|
||||
await instance.client.sendPresenceUpdate('composing', remoteJid);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
await instance.textMessage(
|
||||
{
|
||||
number: remoteJid.split('@')[0],
|
||||
delay: settings?.delayMessage || 1000,
|
||||
text: text,
|
||||
},
|
||||
false,
|
||||
);
|
||||
resolve();
|
||||
}, delay);
|
||||
});
|
||||
|
||||
if (instance.integration === Integration.WHATSAPP_BAILEYS) {
|
||||
await instance.client.sendPresenceUpdate('paused', remoteJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard implementation for initializing a new session
|
||||
* This method should be overridden if a subclass needs specific initialization
|
||||
*/
|
||||
protected async initNewSession(
|
||||
instance: any,
|
||||
remoteJid: string,
|
||||
bot: BotType,
|
||||
settings: SettingsType,
|
||||
session: IntegrationSession,
|
||||
content: string,
|
||||
pushName?: string | any,
|
||||
msg?: any,
|
||||
): Promise<void> {
|
||||
// Create a session if none exists
|
||||
if (!session) {
|
||||
// Extract pushName properly - if it's an object with pushName property, use that
|
||||
const pushNameValue = typeof pushName === 'object' && pushName?.pushName
|
||||
? pushName.pushName
|
||||
: (typeof pushName === 'string' ? pushName : null);
|
||||
|
||||
session = (
|
||||
await this.createNewSession(
|
||||
{
|
||||
instanceName: instance.instanceName,
|
||||
instanceId: instance.instanceId
|
||||
},
|
||||
{
|
||||
remoteJid,
|
||||
pushName: pushNameValue,
|
||||
botId: (bot as any).id,
|
||||
},
|
||||
this.getBotType(),
|
||||
)
|
||||
)?.session;
|
||||
}
|
||||
|
||||
// Update session status to opened
|
||||
await this.prismaRepository.integrationSession.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
status: 'opened',
|
||||
awaitUser: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Forward the message to the chatbot
|
||||
await this.sendMessageToBot(instance, session, settings, bot, remoteJid, pushName || '', content, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bot type identifier (e.g., 'dify', 'n8n', 'evoai')
|
||||
* This should match the type field used in the IntegrationSession
|
||||
*/
|
||||
protected abstract getBotType(): string;
|
||||
|
||||
/**
|
||||
* Send a message to the chatbot API
|
||||
* This is specific to each chatbot integration
|
||||
*/
|
||||
protected abstract sendMessageToBot(
|
||||
instance: any,
|
||||
session: IntegrationSession,
|
||||
settings: SettingsType,
|
||||
bot: BotType,
|
||||
remoteJid: string,
|
||||
pushName: string,
|
||||
content: string,
|
||||
msg?: any,
|
||||
): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user