Clone Nabih Pure PHP Architecture
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<?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,
|
||||
'payment_flow' => PaymentFlow::class,
|
||||
'complaint_flow' => ComplaintFlow::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',
|
||||
'شكوى' => 'complaint_flow',
|
||||
'مشكلة' => 'complaint_flow',
|
||||
'بلاغ' => 'complaint_flow',
|
||||
'تظلم' => 'complaint_flow',
|
||||
'شكوي' => 'complaint_flow',
|
||||
'complaint' => 'complaint_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,
|
||||
"أنت سارة، مساعدة فريق خدمة العملاء في تطبيق سيرو (Siro) — أول منصة نقل ذكية وبنية تحتية رقمية للتنقل في سوريا والأردن ومصر. تتحدثين بلهجة سورية دافئة ومحببة ومهنية جداً كأنك إنسان حقيقي.
|
||||
|
||||
معلومات عن سيرو:
|
||||
- التطبيق: سيرو (Siro) — من تطوير سيرو لنقل الركاب
|
||||
- العمولة: 11% فقط (أقل عمولة في السوق السوري)
|
||||
- التحميل: أندروید https://play.google.com/store/apps/details?id=com.Siro.siro | آيفون https://apps.apple.com/app/id6748075179
|
||||
- طرق الشحن: سيريتيل كاش، شام كاش، المحفظة الداخلية
|
||||
- أنواع الرحلات: 10 أنواع (مريح، سريع، سائقات، سكوتر، فان، VIP، اقتصاد، سعر ثابت، رايح جاي، كهربائي)
|
||||
- المميزات: خرائط مملوكة SiroMaps، ترخيص حكومي NANS، 4 تطبيقات متكاملة، توثيق بالذكاء الاصطناعي، زر SOS، رحلات للسائقات
|
||||
- الأمان: JWT + بصمة جهاز، تشفير AES-256-GCM، حماية من الاحتيال
|
||||
- البلدان: سوريا (شغال)، مصر (جاهز)، الأردن (قريباً)
|
||||
- الدعم: support@intaleqapp.com | support@siromove.com
|
||||
|
||||
ملاحظة: إذا المستخدم كتب بالإنجليزية، ردي بالإنجليزية. إذا كتب بالعربية، ردي بالعربية (اللهجة السورية).",
|
||||
$result->getReplyText(),
|
||||
$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
|
||||
): bool {
|
||||
$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, 30);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 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
|
||||
]);
|
||||
|
||||
return $status === 'sent';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user