TestFlow::class, 'driver_registration_flow' => DriverRegistrationFlow::class, 'payment_flow' => PaymentFlow::class, ]; /** * Map of keyword triggers to start a specific flow */ private static array $startTriggers = [ 'test' => 'test_flow', 'اختبار' => 'test_flow', 'سجل' => 'driver_registration_flow', 'تسجيل' => 'driver_registration_flow', 'register' => 'driver_registration_flow', 'دفع' => 'payment_flow', 'وصل' => 'payment_flow', 'تسديد' => 'payment_flow', 'رصيد' => 'payment_flow', ]; /** * Process incoming message. * Returns true if handled by the flow engine, false otherwise. */ public static function processMessage(array $session, array $msgData): bool { // 1. Housekeeping: remove expired sessions ConversationState::cleanExpired(); $phone = $msgData['phone']; $companyId = $session['company_id']; $text = isset($msgData['body']) ? trim($msgData['body']) : ''; // If incoming message is audio, transcribe it via Gemini (if limits permit) $isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']); if ($isAudio) { if ($companyId !== 1) { $activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId); if (!$activeSub) { error_log("[Flow Engine Warning] Company {$companyId} has no active subscription for audio transcription."); return false; } if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) { error_log("[Flow Engine Warning] Company {$companyId} has exceeded its request limit for audio transcription."); return false; } if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) { error_log("[Flow Engine Warning] Company {$companyId} has exceeded its voice limit for audio transcription."); return false; } } $rule = \App\Models\ChatbotRule::findActiveForRule($companyId); $configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null; $apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey); if (!empty($apiKey)) { $transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']); if ($transcription) { $text = $transcription; $msgData['body'] = $transcription; // Increment usage stats for successful transcription if ($companyId !== 1) { \App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice'); \App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request'); } } } } // 2. Lookup existing active flow $state = ConversationState::findActive($companyId, $phone); $flowName = ''; $currentStep = ''; $context = []; if ($state) { $flowName = $state['flow_name']; $currentStep = $state['current_step']; $context = json_decode($state['context_data'] ?: '{}', true) ?: []; } else { // Check if message is a starting trigger for a new flow $normalizedText = strtolower(trim($text)); if (isset(self::$startTriggers[$normalizedText])) { $flowName = self::$startTriggers[$normalizedText]; $currentStep = 'start'; $context = []; } } // If no active flow and no trigger matches, pass control back to normal bot rules if (empty($flowName) || !isset(self::$flows[$flowName])) { return false; } // Check subscription limits for active/starting flow if ($companyId !== 1) { $activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId); if (!$activeSub) { error_log("[Flow Engine Warning] Company {$companyId} has no active subscription."); self::sendReply($session, $phone, "⚠️ عذراً، لا يوجد اشتراك نشط لهذا المتجر حالياً."); return true; } if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) { error_log("[Flow Engine Warning] Company {$companyId} has exceeded its general request limit."); self::sendReply($session, $phone, "⚠️ عذراً، لقد استهلك هذا المتجر كامل الحد المسموح له من الرسائل والطلبات لهذا الشهر."); return true; } } // 3. User cancel flow option $normalizedCancel = strtolower(trim($text)); if (in_array($normalizedCancel, ['إلغاء', 'خروج', 'cancel', 'exit'])) { if ($state) { ConversationState::deleteState($state['id']); $cancelMsg = in_array($normalizedCancel, ['cancel', 'exit']) ? 'Interactive flow cancelled.' : 'تم إلغاء المحادثة التفاعلية.'; self::sendReply($session, $phone, $cancelMsg); return true; } } // 4. Instantiate and execute the flow $flowClass = self::$flows[$flowName]; /** @var BaseFlow $flowInstance */ $flowInstance = new $flowClass(); try { $context['company_id'] = $companyId; $result = $flowInstance->handleStep($currentStep, $msgData, $context); if ($result->isFinished()) { // Flow has reached terminal state: clean up DB record if ($state) { ConversationState::deleteState($state['id']); } } else { // Flow has next step: save or update state record (TTL: 1 hour) ConversationState::saveState([ 'company_id' => $companyId, 'contact_phone' => $phone, 'flow_name' => $flowName, 'current_step' => $result->getNextStep(), 'context_data' => json_encode($context, JSON_UNESCAPED_UNICODE), 'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour')) ]); } // 5. Send reply if one is provided $replySent = false; if ($companyId === 1 && $flowName === 'driver_registration_flow' && $result->getReplyText() !== '') { $rule = \App\Models\ChatbotRule::findActiveForRule($companyId); $configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null; $apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey); if (!empty($apiKey)) { $configuredElKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : null; $elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey); $configuredVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : null; $elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId); // Generate the audio voice note $audioData = \App\Services\GeminiService::generateAudioResponse( $apiKey, "أنت خدمة تسجيل كباتن تطبيق انطلق، تتحدث بلهجة سورية ودودة ومرحبة ومهنية جداً كأنك إنسان حقيقي.", $result->getReplyText(), 'Puck', $elApiKey, $elVoiceId ); if ($audioData && !empty($audioData['audio'])) { // Send the text message first self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl()); // Then send the voice note self::sendReply($session, $phone, '', null, $audioData['audio'], $audioData['mimeType']); $replySent = true; } } } if (!$replySent && ($result->getReplyText() !== '' || $result->getMediaUrl() !== null)) { self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl()); } return true; } catch (\Exception $e) { error_log("[ConversationFlowEngine Exception] Flow '{$flowName}' failed: " . $e->getMessage()); return false; } } /** * Send outbound message reply via the Baileys Gateway */ public static function sendReply( array $session, string $phone, string $message, ?string $mediaUrl = null, ?string $audioBase64 = null, ?string $mimetype = null, ?string $imageBase64 = null ): void { $gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/'); if (substr($gatewayUrl, -4) === '/api') { $sendUrl = $gatewayUrl . '/messages/send'; } else { $sendUrl = $gatewayUrl . '/api/messages/send'; } $payloadData = [ 'session_key' => $session['session_key'], 'phone' => $phone ]; if ($message !== '') { $payloadData['message'] = $message; } if ($mediaUrl !== null) { $payloadData['media_url'] = $mediaUrl; } if ($audioBase64 !== null) { $payloadData['audio'] = $audioBase64; } if ($mimetype !== null) { $payloadData['mimetype'] = $mimetype; } if ($imageBase64 !== null) { $payloadData['image'] = $imageBase64; } $payload = json_encode($payloadData); $ch = curl_init($sendUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET') ]); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $status = 'failed'; $errorMsg = null; $waMsgId = null; if ($httpCode === 200) { $status = 'sent'; $resData = json_decode($response, true); $waMsgId = $resData['data']['key']['id'] ?? null; } else { $resData = json_decode($response, true); $errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode; error_log("[Flow Engine Gateway Error] Failed to send: " . $errorMsg); } $msgType = ($audioBase64 !== null || $mediaUrl !== null) ? 'audio' : 'text'; // Log the outbound auto-reply message MessageLog::logMessage([ 'company_id' => $session['company_id'], 'session_id' => $session['id'], 'contact_phone' => $phone, 'direction' => 'outbound', 'message_type' => $msgType, 'message_body' => $message, 'media_url' => $mediaUrl, 'whatsapp_message_id' => $waMsgId, 'status' => $status, 'error_message' => $errorMsg ]); } }