Files
nabeh/backend/app/Core/Flows/ConversationFlowEngine.php
2026-05-22 21:52:51 +03:00

220 lines
7.6 KiB
PHP

<?php
namespace App\Core\Flows;
use App\Models\ConversationState;
use App\Models\MessageLog;
/**
* ConversationFlowEngine
* Orchestrates multi-stage interactive conversation flows.
*/
class ConversationFlowEngine
{
/**
* Map of registered flow names to their classes
*/
private static array $flows = [
'test_flow' => TestFlow::class,
'driver_registration_flow' => DriverRegistrationFlow::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',
];
/**
* 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
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
if ($isAudio) {
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
if (!empty($apiKey)) {
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
if ($transcription) {
$text = $transcription;
$msgData['body'] = $transcription;
}
}
}
// 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;
}
// 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
if ($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
): 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;
}
$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
]);
}
}