HEX
Server: LiteSpeed
System: Linux s3604.bom1.stableserver.net 4.18.0-513.11.1.lve.el8.x86_64 #1 SMP Thu Jan 18 16:21:02 UTC 2024 x86_64
User: dmstechonline (1480)
PHP: 7.4.33
Disabled: NONE
Upload Files
File: /home/dmstechonline/whatsapp.dmstech.online/modules/IntelliReply/Services/AIResponseService.php
<?php

namespace Modules\IntelliReply\Services;

use App\Helpers\CustomHelper;
use App\Models\Addon;
use App\Models\Chat;
use App\Models\ChatMedia;
use App\Models\ChatTicketLog;
use App\Models\Contact;
use App\Models\Organization;
use App\Models\Setting;
use App\Services\WhatsappService;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Audio\Mp3;
use Illuminate\Support\Facades\Log;
use Modules\IntelliReply\Models\Document;
use OpenAI;

class AIResponseService
{
    public function handleAIResponse($chat, $receivedMessage)
    {
        // Check organization metadata
        $aimodule = CustomHelper::isModuleEnabled('AI Assistant');
        $organizationId = $chat->organization_id;
        $organizationConfig = Organization::find($organizationId);
        $metadataArray = $organizationConfig->metadata ? json_decode($organizationConfig->metadata, true) : [];
        $is_ai_active = $metadataArray['ai']['active'] ?? false;
        $enable_ai_to_respond_automatically = $metadataArray['ai']['enable_automatic_responses'] ?? false;
        $chat_ticketing = $metadataArray['tickets']['active'] ?? false;
        $contact = Contact::find($chat->contact_id);
        $ai_assistance_enabled = $contact->ai_assistance_enabled;
        $start_words = $metadataArray['ai']['start_keywords'] ?? "";
        $stop_words = $metadataArray['ai']['stop_keywords'] ?? "";
        
        if($aimodule){
            $lastMessage = $this->extractLastMessage($organizationId, $chat->contact_id);
            $receivedMessage = $lastMessage['message'];
            $closestDocument = $this->findClosestDocumentByQuery($organizationId, $receivedMessage);
            
            /*if($closestDocument['success'] === false){
                return 'Sorry but I don\'t have any information about this.';
            } else {*/
                // Retrieve the last 5 messages for the contact
                $messages = Chat::where('contact_id', $chat->contact_id)
                    ->orderBy('created_at', 'desc')
                    ->take(10)
                    ->get();

                // Extract the message body from the metadata column and convert to an array
                $conversationHistory = $messages->map(function ($message) use ($organizationId, $metadataArray) {
                    // Decode the JSON in the metadata column
                    $metadata = json_decode($message->metadata, true);
                    
                    // Check if type is 'text' and return the body if it exists
                    if (isset($metadata['type']) && ($metadata['type'] === 'text' || $metadata['type'] === 'audio')) {
                        $role = ($message->type === 'outbound') ? 'assistant' : 'user'; //outbound => user, inbound => assistant

                        if($metadata['type'] === 'text'){
                            return [
                                "role" => $role,
                                "content" => $metadata['text']['body'] ?? null
                            ];
                        } else if($metadata['type'] === 'audio' && $metadataArray['ai']['allow_audio_response']){
                            $audio = ChatMedia::where('id', $message->media_id)->first();

                            if($role == 'user'){
                              	if($audio->location == 'local'){
                                    $filePath = storage_path("app/{$audio->path}");

                                    if(file_exists($filePath)){
                                        $audioFile = $this->convertToMp3($filePath);
    
                                        return [
                                            "role" => $role,
                                            "content" => [
                                                [
                                                    "type" => "input_audio",
                                                    "input_audio" => [
                                                        "data" => $audioFile['data'],
                                                        "format" => $audioFile['format'],
                                                    ]
                                                ]
                                            ]
                                        ];
                                    }
                                } else if($audio->location == 'amazon'){
                                    $fileUrl = $audio->path;
                                  	$parsedUrl = parse_url($audio->path);
    								$filePath = ltrim($parsedUrl['path'], '/');

                                    if (\Storage::disk('s3')->exists($filePath)) {
                                        $audioFile = $this->convertToMp3($fileUrl);
    
                                        return [
                                            "role" => $role,
                                            "content" => [
                                                [
                                                    "type" => "input_audio",
                                                    "input_audio" => [
                                                        "data" => $audioFile['data'],
                                                        "format" => $audioFile['format'],
                                                    ]
                                                ]
                                            ]
                                        ];
                                    }
                                }
                            } else {
                                //Check if audio name starts with audio_
                                if(isset($audio->name)){
                                    if (str_starts_with($audio->name, 'audio_')) {
                                        return [
                                            'role' => $role,
                                            'audio' => [
                                                'id' => $audio->name,
                                            ],
                                        ];
                                    }
                                }
                            }
                        }
                    }
                    
                    return null;
                })->filter()
                ->toArray();

                $conversationHistory = array_reverse($conversationHistory); // Inverte a ordem para manter a cronologia

                // Build the context by looping through the conversation history and concatenating the messages
                $context = [
                    [
                        "role" => "system", 
                        "content" => "You are a customer support service AI Chatbot. You only provide answers that can be strictly found in context or documentation. If the user asks a question that lacks sufficient information or if it is not covered in the documentation, reply with 'Sorry, I don't have information about this. Could you specify what you’d like more information about?'. Here is the documentation: " . $closestDocument['document']
                    ]
                ]; // Initialize context with a system message

                // Append the user and assistant messages to the context
                foreach ($conversationHistory as $message) {
                    if (!empty($message['content'])) {
                        $context[] = [
                            "role" => $message['role'],
                            "content" => $message['content']
                        ];
                    }
                }

                //\Log::info($context);

                // Check if the received message contains any stop words
                if ($stop_words) {
                    $stopWordsArray = array_map('trim', explode(',', $stop_words));
                    // Loop through each stop word and check if it exists in the received message
                    foreach ($stopWordsArray as $stopWord) {
                        if (stripos($receivedMessage, $stopWord) !== false) {
                            $ai_assistance_enabled = 0;
                            $contact->update(['ai_assistance_enabled' => $ai_assistance_enabled]);
                            return false; // Stop further AI processing
                        }
                    }
                }

                // Check if the received message contains any start words
                if ($start_words) {
                    $startWordsArray = array_map('trim', explode(',', $start_words));
                    // Loop through each stop word and check if it exists in the received message
                    foreach ($startWordsArray as $startWord) {
                        if (stripos($receivedMessage, $startWord) !== false) {
                            $ai_assistance_enabled = 1;
                            $contact->update(['ai_assistance_enabled' => $ai_assistance_enabled]);
                            //return true;
                        }
                    }
                }

                // Verify if the user has enabled automatic AI responses and 
                // ensure that the AI response feature is currently inactive for this user

                if($enable_ai_to_respond_automatically && !$ai_assistance_enabled){
                    if ($chat_ticketing) {
                        // Define the parameters with which the AI should respond if ticketing workflows have been enabled
                        // Retrieve the most recent chat ticket log for the contact
                        $chatTicketLog = ChatTicketLog::where('contact_id', $chat->contact_id)
                            ->where(function($query) {
                                $query->where('description', 'Conversation was opened')
                                    ->orWhere('description', 'Conversation was moved from closed to open');
                            })
                            ->orderBy('id', 'desc')
                            ->first();

                        // Count the number of outbound chats created after the last chat ticket log
                        if ($chatTicketLog) {
                            $outboundChatsCount = Chat::where('contact_id', $chat->contact_id)
                                ->where('created_at', '>', $chatTicketLog->created_at)
                                ->where('type', 'outbound')
                                ->count();
                        } else {
                            // Handle the case where no chat ticket log exists
                            $outboundChatsCount = 0;
                        }

                        // If no outbound chats are found, enable AI assistance for the contact
                        if ($outboundChatsCount == 0) {
                            $ai_assistance_enabled = 1;
                            Contact::where('id', $chat->contact_id)
                                ->update(['ai_assistance_enabled' => $ai_assistance_enabled]);
                        }
                    } else {
                        //Define the parameters with which the AI should respond if ticketing workflows have not been enabled
                        //Check if the user has other previous messages during the last 24 hours
                        $inboundChatsCount = Chat::where('contact_id', $chat->contact_id)
                            ->where('type', 'inbound')
                            ->where('created_at', '>', now()->subDay())
                            ->count();

                        // If no outbound chats are found, enable AI assistance for the contact
                        if ($inboundChatsCount <= 1) {
                            $ai_assistance_enabled = 1;
                            Contact::where('id', $chat->contact_id)
                                ->update(['ai_assistance_enabled' => $ai_assistance_enabled]);
                        }
                    }
                }

                // Check if AI is active and automatic responses are enabled
                if ($is_ai_active && $ai_assistance_enabled) {
                    // Pass the context and received message to the chat method
                    $res = $this->chat($organizationId, $lastMessage['type'], $context); 
            
                    if ($res) {
                        if($lastMessage['type'] == 'text'){
                            $this->initializeWhatsappService($organizationId)->sendMessage($contact->uuid, $res['text']);
                        } else if($lastMessage['type'] == 'audio' && $metadataArray['ai']['allow_audio_response']){
                            $file = $this->saveBase64Audio($res['audio']['data'], $organizationId, $res['audio']['id']);
                            $this->initializeWhatsappService($organizationId)->sendMedia($contact->uuid, 'audio', $res['audio']['id'], $file['filePath'], $file['mediaUrl'], $file['location']);
                        }
                        return true; // Indicates that an AI response was sent
                    }
                }
            //}
        }

        return false; // Indicates that no AI response was sent
    }

    public function chat($organizationId, $type, $context)
    {
        $organizationConfig = Organization::where('id', $organizationId)->first();
        $organizationConfig = $organizationConfig ? json_decode($organizationConfig->metadata, true) : [];

        if($organizationConfig['ai']['model'] != 'gpt-4o-audio-preview'){
            $response = \Http::withHeaders([
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $organizationConfig['ai']['api_key']
            ])->post('https://api.openai.com/v1/chat/completions', [
                'model' => $organizationConfig['ai']['model'],
                'messages' => $context
            ]);
        } else {
            $response = \Http::withHeaders([
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $organizationConfig['ai']['api_key']
            ])->post('https://api.openai.com/v1/chat/completions', [
                'model' => $organizationConfig['ai']['model'],
                "modalities" => ["text", "audio"],
                "audio" => [
                    "voice" => $organizationConfig['ai']['voice'],
                    "format" => "mp3"
                ],
                'messages' => $context
            ]);
        }
        

        $responseArray = $response->json();

        //\Log::info($responseArray);

        // Access the first choice in the response (choices array)
        if (isset($responseArray['choices']) && count($responseArray['choices']) > 0) {
            $audioId = NULL;
            $encodedAudioFile = NULL;

            if(isset($responseArray['choices'][0]['message']['content'])){
                $chatResponse = $responseArray['choices'][0]['message']['content'];
                $type = 'text';
            }

            if(isset($responseArray['choices'][0]['message']['audio']['transcript'])){
                $chatResponse = $responseArray['choices'][0]['message']['audio']['transcript'];
                $audioId = $responseArray['choices'][0]['message']['audio']['id'];
                $encodedAudioFile = $responseArray['choices'][0]['message']['audio']['data'];
                $type = 'audio';
            }

            return [
                'type' => $type,
                'text' => $chatResponse,
                'audio' => [
                    'id' => $audioId,
                    'data' => $encodedAudioFile,
                    'transcript' => $chatResponse
                ]
            ];
        }
    }

    protected function saveBase64Audio($base64Data, $organizationId, $fileName)
    {
        $storage = Setting::where('key', 'storage_system')->first()->value;
        $audioData = base64_decode($base64Data);

        // Create a temporary stream and write the audio data into it
        $stream = fopen('php://temp', 'rb+');
        fwrite($stream, $audioData);
        rewind($stream);

        if($storage === 'local'){
            // Get the public URL for accessing the audio file
            $filePath = 'public/audios/' . $fileName . '.mp3';
            $file = \Storage::disk('local')->put($filePath, $stream);
            $mediaUrl = rtrim(config('app.url'), '/') . '/media/public/audios/' . $fileName . '.mp3';
        } else if($storage === 'aws') {
            $filePath = 'uploads/media/sent/'  . $organizationId . '/' . $fileName . '.mp3';
            $file = \Storage::disk('s3')->put($filePath, $stream);
            $mediaUrl = \Storage::disk('s3')->url($filePath);
        }

        // Close the temporary stream
        fclose($stream);

        return [
            'filePath' => $storage == 'aws' ? $mediaUrl : $filePath,
            'mediaUrl' => $mediaUrl,
            'location' => $storage == 'aws' ? 'amazon' : 'local'
        ];
    }

    protected function convertToMp3($filePath)
    {
        // Get the file extension
        $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);

        // Check if the file is already in mp3, mpeg, or wav format
        if (in_array(strtolower($fileExtension), ['mp3', 'mpeg', 'wav'])) {
            $base64Data = base64_encode(file_get_contents($filePath));

            return [
                'data' => $base64Data,
                'format' => $fileExtension == 'mpeg' || $fileExtension == 'mp3' ? 'mp3' : 'wav',
            ];
        }

        // Initialize FFmpeg
        //$ffmpeg = FFMpeg::create();
        $ffmpeg = FFMpeg::create(array(
            'ffmpeg.binaries' => config('ffmpeg.ffmpeg.binaries'),
            'ffprobe.binaries' => config('ffmpeg.ffprobe.binaries'),
            'timeout' => config('ffmpeg.timeout'),
            'threads' => config('ffmpeg.threads'),
        ));
        $audio = $ffmpeg->open($filePath);
        $format = new Mp3();
        $tempFile = tempnam(sys_get_temp_dir(), 'audio') . '.mp3';
        $audio->save($format, $tempFile);
        $base64Data = base64_encode(file_get_contents($tempFile));
        unlink($tempFile);

        return [
            'data' => $base64Data,
            'format' => 'mp3',
        ];
    }

    protected function transcribeAudioToText($organizationId, $audioPath)
    {
        $organizationConfig = Organization::where('id', $organizationId)->first();
        $organizationConfig = $organizationConfig ? json_decode($organizationConfig->metadata, true) : [];

        // Use Whisper API to transcribe audio to text
        $response = \Http::withHeaders([
            'Authorization' => 'Bearer ' . $organizationConfig['ai']['api_key']
        ])->attach('file', file_get_contents($audioPath), 'audio.mp3')
        ->post('https://api.openai.com/v1/audio/transcriptions', [
            'model' => 'whisper-1',
            'language' => 'en'
        ]);

        $responseArray = $response->json();
        
        if (isset($responseArray['text'])) {
            return [
                'success' => true,
                'message' => 'Transcription successful',
                'text' => $responseArray['text']
            ];
        } else {
            return [
                'success' => false,
                'message' => 'Unable to transcribe audio',
                'text' => null
            ];
        }
    }

    protected function extractLastMessage($organizationId, $contactId){
        $chat = Chat::where('contact_id', $contactId)
                ->orderBy('created_at', 'desc') // Order by creation time
                ->first();
        $metadata = json_decode($chat->metadata, true);

        if($metadata['type'] === 'text'){
            return [
                'type' => 'text',
                'message' => $metadata['text']['body'] ?? null
            ];
        }

        if($metadata['type'] === 'button'){
            return [
                'type' => 'text',
                'message' => $metadata['button']['payload'] ?? null
            ];
        }

        if($metadata['type'] === 'audio'){
            $audio = ChatMedia::where('id', $chat->media_id)->first();

            if($audio->location === 'local'){
                $filePath = storage_path("app/{$audio->path}");
                $transcriptionResponse = $this->transcribeAudioToText($organizationId, $filePath);
            } else if($audio->location === 'amazon') {
                $filePath = $audio->path;
                $transcriptionResponse = $this->transcribeAudioToText($organizationId, $filePath);
            }

            if ($transcriptionResponse['success']) {
                return [
                    'type' => 'audio',
                    'message' => $transcriptionResponse['text']
                ];
            }
        }
    }

    protected function findClosestDocumentByQuery($organizationId, $query){
        $organizationConfig = Organization::where('id', $organizationId)->first();
        $organizationConfig = $organizationConfig ? json_decode($organizationConfig->metadata, true) : [];

        $api_key = $organizationConfig['ai']['api_key'];
        $client = OpenAI::client($api_key);

        try {
            // Generate embedding for query
            $response = $client->embeddings()->create([
                'input' => $query,
                'model' => 'text-embedding-ada-002'
            ]);
            $queryEmbedding = $response->embeddings[0]->embedding;

            // Find the closest document
            $documents = Document::where('organization_id', $organizationId)->get();
            $closestDocument = null;
            $closestDistance = PHP_FLOAT_MAX;

            foreach ($documents as $document) {
                $documentEmbeddings = json_decode($document->embeddings);

                foreach ($documentEmbeddings as $documentEmbedding) {
                    $distance = $this->cosineSimilarity($queryEmbedding, $documentEmbedding);

                    if ($distance < $closestDistance) {
                        $closestDistance = $distance;
                        $closestDocument = $document;
                    }
                }
            }

            if ($closestDocument) {
                return [
                    'success' => true,
                    'document' => $closestDocument->content
                ];
            }
        
            return [
                'success' => false,
                'document' => $closestDocument->content
            ];
        } catch (\Exception $e) {
            // Handle errors and exceptions
            return [
                'success' => false,
                'message' => 'Error: ' . $e->getMessage()
            ];
        }
    }

    protected function cosineSimilarity($vecA, $vecB)
    {
        $dotProduct = 0.0;
        $normA = 0.0;
        $normB = 0.0;

        for ($i = 0; $i < count($vecA); $i++) {
            $dotProduct += $vecA[$i] * $vecB[$i];
            $normA += $vecA[$i] ** 2;
            $normB += $vecB[$i] ** 2;
        }

        return 1 - ($dotProduct / (sqrt($normA) * sqrt($normB)));
    }

    protected function initializeWhatsappService($organizationId)
    {
        $config = Organization::where('id', $organizationId)->first()->metadata;
        $config = $config ? json_decode($config, true) : [];

        $accessToken = $config['whatsapp']['access_token'] ?? null;
        $apiVersion = 'v18.0';
        $appId = $config['whatsapp']['app_id'] ?? null;
        $phoneNumberId = $config['whatsapp']['phone_number_id'] ?? null;
        $wabaId = $config['whatsapp']['waba_id'] ?? null;

        return new WhatsappService($accessToken, $apiVersion, $appId, $phoneNumberId, $wabaId, $organizationId);
    }
}