Feature: Implement multi-stage Conversation Flow Engine with TestFlow
This commit is contained in:
@@ -299,6 +299,11 @@ class WhatsAppController extends BaseController
|
|||||||
private function triggerAutoReply(array $session, array $msgData)
|
private function triggerAutoReply(array $session, array $msgData)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Hook the Conversation Flow Engine to intercept messages for active or new flows
|
||||||
|
if (\App\Core\Flows\ConversationFlowEngine::processMessage($session, $msgData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$rule = \App\Models\ChatbotRule::findActiveForRule($session['company_id'], $session['id']);
|
$rule = \App\Models\ChatbotRule::findActiveForRule($session['company_id'], $session['id']);
|
||||||
if (!$rule || !$rule['is_active']) {
|
if (!$rule || !$rule['is_active']) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
20
backend/app/Core/Flows/BaseFlow.php
Normal file
20
backend/app/Core/Flows/BaseFlow.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Flows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseFlow
|
||||||
|
* Abstract base class for all conversation flow states.
|
||||||
|
*/
|
||||||
|
abstract class BaseFlow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Process a step in the conversation flow.
|
||||||
|
*
|
||||||
|
* @param string $step The current step identifier ('start' for new flows)
|
||||||
|
* @param array $messageData The incoming WhatsApp message payload (body, phone, etc.)
|
||||||
|
* @param array $context Reference to the persistent JSON context array
|
||||||
|
* @return FlowResult
|
||||||
|
*/
|
||||||
|
abstract public function handleStep(string $step, array $messageData, array &$context): FlowResult;
|
||||||
|
}
|
||||||
177
backend/app/Core/Flows/ConversationFlowEngine.php
Normal file
177
backend/app/Core/Flows/ConversationFlowEngine.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of keyword triggers to start a specific flow
|
||||||
|
*/
|
||||||
|
private static array $startTriggers = [
|
||||||
|
'test' => 'test_flow',
|
||||||
|
'اختبار' => 'test_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']) : '';
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
$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() !== '') {
|
||||||
|
self::sendReply($session, $phone, $result->getReplyText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("[ConversationFlowEngine Exception] Flow '{$flowName}' failed: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send outbound message reply via the Baileys Gateway
|
||||||
|
*/
|
||||||
|
private static function sendReply(array $session, string $phone, string $message): 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'session_key' => $session['session_key'],
|
||||||
|
'phone' => $phone,
|
||||||
|
'message' => $message
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the outbound auto-reply message
|
||||||
|
MessageLog::logMessage([
|
||||||
|
'company_id' => $session['company_id'],
|
||||||
|
'session_id' => $session['id'],
|
||||||
|
'contact_phone' => $phone,
|
||||||
|
'direction' => 'outbound',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'message_body' => $message,
|
||||||
|
'whatsapp_message_id' => $waMsgId,
|
||||||
|
'status' => $status,
|
||||||
|
'error_message' => $errorMsg
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/app/Core/Flows/FlowResult.php
Normal file
55
backend/app/Core/Flows/FlowResult.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Flows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlowResult
|
||||||
|
* Holds response details after handling a single step in a conversation flow.
|
||||||
|
*/
|
||||||
|
class FlowResult
|
||||||
|
{
|
||||||
|
private string $replyText;
|
||||||
|
private string $nextStep;
|
||||||
|
private bool $finished;
|
||||||
|
private ?string $mediaUrl;
|
||||||
|
|
||||||
|
public function __construct(string $replyText, string $nextStep, bool $finished = false, ?string $mediaUrl = null)
|
||||||
|
{
|
||||||
|
$this->replyText = $replyText;
|
||||||
|
$this->nextStep = $nextStep;
|
||||||
|
$this->finished = $finished;
|
||||||
|
$this->mediaUrl = $mediaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reply message text to send to the contact
|
||||||
|
*/
|
||||||
|
public function getReplyText(): string
|
||||||
|
{
|
||||||
|
return $this->replyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next step key
|
||||||
|
*/
|
||||||
|
public function getNextStep(): string
|
||||||
|
{
|
||||||
|
return $this->nextStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the flow is finished (to be destroyed)
|
||||||
|
*/
|
||||||
|
public function isFinished(): bool
|
||||||
|
{
|
||||||
|
return $this->finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get target media URL (if any)
|
||||||
|
*/
|
||||||
|
public function getMediaUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->mediaUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/app/Core/Flows/TestFlow.php
Normal file
38
backend/app/Core/Flows/TestFlow.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Flows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestFlow
|
||||||
|
* A basic interactive flow for testing the multi-stage system.
|
||||||
|
*/
|
||||||
|
class TestFlow extends BaseFlow
|
||||||
|
{
|
||||||
|
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||||
|
{
|
||||||
|
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
|
||||||
|
|
||||||
|
switch ($step) {
|
||||||
|
case 'start':
|
||||||
|
// Initiate step
|
||||||
|
return new FlowResult("أهلاً بك في اختبار المسار التفاعلي! ما هو اسمك الكريم؟", "ask_name");
|
||||||
|
|
||||||
|
case 'ask_name':
|
||||||
|
if (empty($text)) {
|
||||||
|
return new FlowResult("يرجى إدخال اسمك للاستمرار:", "ask_name");
|
||||||
|
}
|
||||||
|
$context['name'] = $text;
|
||||||
|
return new FlowResult("تشرفنا بك يا {$text}! من فضلك قم بتقييم خدمتنا من 1 إلى 5:", "ask_feedback");
|
||||||
|
|
||||||
|
case 'ask_feedback':
|
||||||
|
if (!preg_match('/^[1-5]$/', $text)) {
|
||||||
|
return new FlowResult("الرجاء إدخال رقم من 1 إلى 5 فقط للتقييم:", "ask_feedback");
|
||||||
|
}
|
||||||
|
$context['rating'] = (int)$text;
|
||||||
|
return new FlowResult("شكراً لك يا {$context['name']}! لقد تم تسجيل تقييمك ({$text}/5) بنجاح. يومك سعيد!", "finished", true);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
backend/app/Models/ConversationState.php
Normal file
119
backend/app/Models/ConversationState.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConversationState Model
|
||||||
|
* Manages conversation states for multi-step bot flows.
|
||||||
|
*/
|
||||||
|
class ConversationState extends BaseModel
|
||||||
|
{
|
||||||
|
protected static string $table = 'conversation_states';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active conversation state for a company and contact phone
|
||||||
|
*/
|
||||||
|
public static function findActive(int $companyId, string $phone): ?array
|
||||||
|
{
|
||||||
|
self::ensureTableExists();
|
||||||
|
$hash = Security::blindIndex($phone);
|
||||||
|
$state = Database::selectOne(
|
||||||
|
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND contact_phone_hash = ? AND expires_at > NOW() LIMIT 1",
|
||||||
|
[$companyId, $hash]
|
||||||
|
);
|
||||||
|
return self::decryptState($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update conversation state securely
|
||||||
|
*/
|
||||||
|
public static function saveState(array $data): string
|
||||||
|
{
|
||||||
|
self::ensureTableExists();
|
||||||
|
|
||||||
|
if (!empty($data['contact_phone'])) {
|
||||||
|
$data['contact_phone_hash'] = Security::blindIndex($data['contact_phone']);
|
||||||
|
$data['contact_phone'] = Security::encrypt($data['contact_phone']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = Database::selectOne(
|
||||||
|
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND contact_phone_hash = ? LIMIT 1",
|
||||||
|
[$data['company_id'], $data['contact_phone_hash']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
self::update($existing['id'], $data);
|
||||||
|
return $existing['id'];
|
||||||
|
} else {
|
||||||
|
return self::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete conversation state by ID
|
||||||
|
*/
|
||||||
|
public static function deleteState(int $id): int
|
||||||
|
{
|
||||||
|
self::ensureTableExists();
|
||||||
|
return self::delete($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all expired states
|
||||||
|
*/
|
||||||
|
public static function cleanExpired(): void
|
||||||
|
{
|
||||||
|
self::ensureTableExists();
|
||||||
|
try {
|
||||||
|
Database::execute("DELETE FROM " . static::$table . " WHERE expires_at < NOW()");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Failed to clean expired conversation states: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to decrypt sensitive fields
|
||||||
|
*/
|
||||||
|
private static function decryptState(?array $state): ?array
|
||||||
|
{
|
||||||
|
if ($state) {
|
||||||
|
$state['contact_phone'] = !empty($state['contact_phone']) ? Security::decrypt($state['contact_phone']) : null;
|
||||||
|
}
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the conversation_states table exists in the database
|
||||||
|
*/
|
||||||
|
public static function ensureTableExists(): void
|
||||||
|
{
|
||||||
|
static $checked = false;
|
||||||
|
if ($checked) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::execute("
|
||||||
|
CREATE TABLE IF NOT EXISTS `conversation_states` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`company_id` INT NOT NULL,
|
||||||
|
`contact_phone` VARCHAR(512) NOT NULL,
|
||||||
|
`contact_phone_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`flow_name` VARCHAR(100) NOT NULL,
|
||||||
|
`current_step` VARCHAR(100) NOT NULL,
|
||||||
|
`context_data` JSON DEFAULT NULL,
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `unique_company_contact` (`company_id`, `contact_phone_hash`),
|
||||||
|
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
|
||||||
|
INDEX `idx_conv_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
");
|
||||||
|
$checked = true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Failed to ensure conversation_states table: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/create_conversation_states_table.sql
Normal file
20
backend/create_conversation_states_table.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- ==============================================================================
|
||||||
|
-- 🗄️ Conversation States Table Schema
|
||||||
|
-- Persists multi-stage flow sessions for WhatsApp contacts.
|
||||||
|
-- ==============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `conversation_states` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`company_id` INT NOT NULL,
|
||||||
|
`contact_phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM',
|
||||||
|
`contact_phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index',
|
||||||
|
`flow_name` VARCHAR(100) NOT NULL COMMENT 'e.g. test_flow, registration',
|
||||||
|
`current_step` VARCHAR(100) NOT NULL,
|
||||||
|
`context_data` JSON DEFAULT NULL COMMENT 'Stores transient state data',
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `unique_company_contact` (`company_id`, `contact_phone_hash`),
|
||||||
|
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
|
||||||
|
INDEX `idx_conv_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -162,3 +162,20 @@ CREATE TABLE IF NOT EXISTS `chatbot_rules` (
|
|||||||
INDEX `idx_bot_company` (`company_id`),
|
INDEX `idx_bot_company` (`company_id`),
|
||||||
INDEX `idx_bot_active` (`is_active`)
|
INDEX `idx_bot_active` (`is_active`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 11. Multi-Stage Conversation States Table
|
||||||
|
CREATE TABLE IF NOT EXISTS `conversation_states` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`company_id` INT NOT NULL,
|
||||||
|
`contact_phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM',
|
||||||
|
`contact_phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index',
|
||||||
|
`flow_name` VARCHAR(100) NOT NULL COMMENT 'e.g. test_flow, registration',
|
||||||
|
`current_step` VARCHAR(100) NOT NULL,
|
||||||
|
`context_data` JSON DEFAULT NULL COMMENT 'Stores transient state data',
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `unique_company_contact` (`company_id`, `contact_phone_hash`),
|
||||||
|
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
|
||||||
|
INDEX `idx_conv_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
101
backend/public/test_flow.php
Normal file
101
backend/public/test_flow.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Secure token check to prevent unauthorized execution on production
|
||||||
|
if (($_GET['token'] ?? '') !== 'nabeh_test_token_1298') {
|
||||||
|
http_response_code(403);
|
||||||
|
die('Unauthorized access');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Models\ConversationState;
|
||||||
|
use App\Core\Flows\ConversationFlowEngine;
|
||||||
|
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
echo "=== Starting Conversation Flow Engine Mock Test ===\n";
|
||||||
|
|
||||||
|
// 1. Ensure table exists
|
||||||
|
echo "Ensuring conversation_states table exists...\n";
|
||||||
|
ConversationState::ensureTableExists();
|
||||||
|
echo "Table ensured.\n";
|
||||||
|
|
||||||
|
// 2. Fetch a company and session to mock with
|
||||||
|
$company = Database::selectOne("SELECT * FROM companies LIMIT 1");
|
||||||
|
if (!$company) {
|
||||||
|
echo "No company found in database. Creating a mock company...\n";
|
||||||
|
$companyId = Database::insert("INSERT INTO companies (name) VALUES (?)", ["Mock Test Company"]);
|
||||||
|
$company = Database::selectOne("SELECT * FROM companies WHERE id = ?", [$companyId]);
|
||||||
|
}
|
||||||
|
echo "Using Company ID: {$company['id']} ({$company['name']})\n";
|
||||||
|
|
||||||
|
$session = Database::selectOne("SELECT * FROM whatsapp_sessions WHERE company_id = ? LIMIT 1", [$company['id']]);
|
||||||
|
if (!$session) {
|
||||||
|
echo "No WhatsApp session found for company. Creating a mock session...\n";
|
||||||
|
$sessionId = Database::insert(
|
||||||
|
"INSERT INTO whatsapp_sessions (company_id, name, session_key, status) VALUES (?, ?, ?, ?)",
|
||||||
|
[$company['id'], "Test Session", "mock_test_session_" . rand(100, 999), "connected"]
|
||||||
|
);
|
||||||
|
$session = Database::selectOne("SELECT * FROM whatsapp_sessions WHERE id = ?", [$sessionId]);
|
||||||
|
}
|
||||||
|
echo "Using WhatsApp Session Key: {$session['session_key']}\n";
|
||||||
|
|
||||||
|
// Clean existing states for test phone
|
||||||
|
$testPhone = "+962799999999";
|
||||||
|
$hash = \App\Core\Security::blindIndex($testPhone);
|
||||||
|
Database::execute("DELETE FROM conversation_states WHERE company_id = ? AND contact_phone_hash = ?", [$company['id'], $hash]);
|
||||||
|
echo "Cleaned any old state for test phone: {$testPhone}\n";
|
||||||
|
|
||||||
|
// Helper to simulate incoming message
|
||||||
|
function simulateIncoming(array $session, string $phone, string $body) {
|
||||||
|
echo "\n--- SIMULATING INCOMING: '$body' from $phone ---\n";
|
||||||
|
|
||||||
|
$msgData = [
|
||||||
|
'phone' => $phone,
|
||||||
|
'body' => $body,
|
||||||
|
'id' => 'msg_' . uniqid()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Temporarily point gateway to a local dummy or mock so curl doesn't fail
|
||||||
|
$oldGateway = getenv('WHATSAPP_GATEWAY_URL');
|
||||||
|
putenv('WHATSAPP_GATEWAY_URL=http://localhost:9999'); // Invalid/mock port to prevent actual gateway call or we can let it fail gracefully
|
||||||
|
|
||||||
|
$handled = ConversationFlowEngine::processMessage($session, $msgData);
|
||||||
|
echo "Handled by Engine: " . ($handled ? "YES" : "NO") . "\n";
|
||||||
|
|
||||||
|
// Restore gateway
|
||||||
|
if ($oldGateway) {
|
||||||
|
putenv("WHATSAPP_GATEWAY_URL={$oldGateway}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print current state in DB
|
||||||
|
$state = ConversationState::findActive($session['company_id'], $phone);
|
||||||
|
if ($state) {
|
||||||
|
echo "Current DB State: Step = '{$state['current_step']}', Context = '{$state['context_data']}'\n";
|
||||||
|
} else {
|
||||||
|
echo "Current DB State: [No Active Flow / Finished]\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run test cases
|
||||||
|
// Case A: Message not triggering anything
|
||||||
|
simulateIncoming($session, $testPhone, "مرحبا");
|
||||||
|
|
||||||
|
// Case B: Trigger keyword "اختبار"
|
||||||
|
simulateIncoming($session, $testPhone, "اختبار");
|
||||||
|
|
||||||
|
// Case C: Provide name "احمد"
|
||||||
|
simulateIncoming($session, $testPhone, "احمد");
|
||||||
|
|
||||||
|
// Case D: Provide invalid rating "10"
|
||||||
|
simulateIncoming($session, $testPhone, "10");
|
||||||
|
|
||||||
|
// Case E: Provide valid rating "5"
|
||||||
|
simulateIncoming($session, $testPhone, "5");
|
||||||
|
|
||||||
|
// Case F: Start again and test cancellation
|
||||||
|
simulateIncoming($session, $testPhone, "اختبار");
|
||||||
|
simulateIncoming($session, $testPhone, "إلغاء");
|
||||||
|
|
||||||
|
echo "\n=== Mock Test Complete ===\n";
|
||||||
88
backend/test_flow.php
Normal file
88
backend/test_flow.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Models\ConversationState;
|
||||||
|
use App\Core\Flows\ConversationFlowEngine;
|
||||||
|
|
||||||
|
echo "=== Starting Conversation Flow Engine Mock Test ===\n";
|
||||||
|
|
||||||
|
// 1. Ensure table exists
|
||||||
|
echo "Ensuring conversation_states table exists...\n";
|
||||||
|
ConversationState::ensureTableExists();
|
||||||
|
echo "Table ensured.\n";
|
||||||
|
|
||||||
|
// 2. Fetch a company and session to mock with
|
||||||
|
$company = Database::selectOne("SELECT * FROM companies LIMIT 1");
|
||||||
|
if (!$company) {
|
||||||
|
echo "No company found in database. Creating a mock company...\n";
|
||||||
|
$companyId = Database::insert("INSERT INTO companies (name) VALUES (?)", ["Mock Test Company"]);
|
||||||
|
$company = Database::selectOne("SELECT * FROM companies WHERE id = ?", [$companyId]);
|
||||||
|
}
|
||||||
|
echo "Using Company ID: {$company['id']} ({$company['name']})\n";
|
||||||
|
|
||||||
|
$session = Database::selectOne("SELECT * FROM whatsapp_sessions WHERE company_id = ? LIMIT 1", [$company['id']]);
|
||||||
|
if (!$session) {
|
||||||
|
echo "No WhatsApp session found for company. Creating a mock session...\n";
|
||||||
|
$sessionId = Database::insert(
|
||||||
|
"INSERT INTO whatsapp_sessions (company_id, name, session_key, status) VALUES (?, ?, ?, ?)",
|
||||||
|
[$company['id'], "Test Session", "mock_test_session_" . rand(100, 999), "connected"]
|
||||||
|
);
|
||||||
|
$session = Database::selectOne("SELECT * FROM whatsapp_sessions WHERE id = ?", [$sessionId]);
|
||||||
|
}
|
||||||
|
echo "Using WhatsApp Session Key: {$session['session_key']}\n";
|
||||||
|
|
||||||
|
// Clean existing states for test phone
|
||||||
|
$testPhone = "+962799999999";
|
||||||
|
$hash = \App\Core\Security::blindIndex($testPhone);
|
||||||
|
Database::execute("DELETE FROM conversation_states WHERE company_id = ? AND contact_phone_hash = ?", [$company['id'], $hash]);
|
||||||
|
echo "Cleaned any old state for test phone: {$testPhone}\n";
|
||||||
|
|
||||||
|
// Helper to simulate incoming message
|
||||||
|
function simulateIncoming(array $session, string $phone, string $body) {
|
||||||
|
echo "\n--- SIMULATING INCOMING: '$body' from $phone ---\n";
|
||||||
|
|
||||||
|
// We will bypass actual network calls by mocking the Curl response inside ConversationFlowEngine's sendReply or using a local test double.
|
||||||
|
// Wait, ConversationFlowEngine calls WHATSAPP_GATEWAY_URL to send messages. In our local test environment, that gateway might not be active,
|
||||||
|
// or it might return error. To prevent Curl from throwing or blocking, we can set WHATSAPP_GATEWAY_URL to a mock, or we can check the result.
|
||||||
|
// Let's see what happens when we process:
|
||||||
|
$msgData = [
|
||||||
|
'phone' => $phone,
|
||||||
|
'body' => $body,
|
||||||
|
'id' => 'msg_' . uniqid()
|
||||||
|
];
|
||||||
|
|
||||||
|
$handled = ConversationFlowEngine::processMessage($session, $msgData);
|
||||||
|
echo "Handled by Engine: " . ($handled ? "YES" : "NO") . "\n";
|
||||||
|
|
||||||
|
// Print current state in DB
|
||||||
|
$state = ConversationState::findActive($session['company_id'], $phone);
|
||||||
|
if ($state) {
|
||||||
|
echo "Current DB State: Step = '{$state['current_step']}', Context = '{$state['context_data']}'\n";
|
||||||
|
} else {
|
||||||
|
echo "Current DB State: [No Active Flow / Finished]\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run test cases
|
||||||
|
// Case A: Message not triggering anything
|
||||||
|
simulateIncoming($session, $testPhone, "مرحبا");
|
||||||
|
|
||||||
|
// Case B: Trigger keyword "اختبار"
|
||||||
|
simulateIncoming($session, $testPhone, "اختبار");
|
||||||
|
|
||||||
|
// Case C: Provide name "احمد"
|
||||||
|
simulateIncoming($session, $testPhone, "احمد");
|
||||||
|
|
||||||
|
// Case D: Provide invalid rating "10"
|
||||||
|
simulateIncoming($session, $testPhone, "10");
|
||||||
|
|
||||||
|
// Case E: Provide valid rating "5"
|
||||||
|
simulateIncoming($session, $testPhone, "5");
|
||||||
|
|
||||||
|
// Case F: Start again and test cancellation
|
||||||
|
simulateIncoming($session, $testPhone, "اختبار");
|
||||||
|
simulateIncoming($session, $testPhone, "إلغاء");
|
||||||
|
|
||||||
|
echo "\n=== Mock Test Complete ===\n";
|
||||||
97
docs/strategy_analysis.md
Normal file
97
docs/strategy_analysis.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<div dir="rtl" align="right">
|
||||||
|
|
||||||
|
# 🧠 تحليل استراتيجي شامل — منصة نبيه (Nabeh)
|
||||||
|
### رؤية المنتج، المجالات التسويقية، والأفكار الإبداعية
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 أولاً: ماذا نملك الآن؟ (تحليل القدرات الحالية)
|
||||||
|
|
||||||
|
| القدرة | الحالة | التفاصيل |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| بوابة واتساب (Baileys Gateway) | ✅ جاهز | إرسال واستقبال رسائل نصية، صوتية، وصور |
|
||||||
|
| ذكاء اصطناعي (Gemini AI) | ✅ جاهز | رد تلقائي نصي + فهم صوتي + تحليل صور |
|
||||||
|
| نظام Multi-Tenant | ✅ جاهز | دعم شركات متعددة على نفس المنصة |
|
||||||
|
| نظام CRM أساسي | ✅ جاهز | جهات اتصال + مجموعات + حملات بث |
|
||||||
|
| ربط APIs خارجية | ✅ جاهز | Endpoints قابلة للتخصيص لكل شركة |
|
||||||
|
| تكامل سلة | ⏸️ مؤجل | الكود جاهز، بس بحاجة توثيق سعودي |
|
||||||
|
| التحقق من وصولات الدفع | ✅ جاهز | تحليل صور الوصولات + تحقق تلقائي |
|
||||||
|
| نظام أمني متقدم | ✅ جاهز | تشفير AES-256، JWT، Rate Limiting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ثانياً: المجالات التسويقية والقطاعات المستهدفة
|
||||||
|
|
||||||
|
### القطاع 1: 🏪 التجارة الإلكترونية (E-Commerce) — أولوية عالية
|
||||||
|
|
||||||
|
#### المنصات المستهدفة بعد سلة:
|
||||||
|
|
||||||
|
| المنصة | حجم السوق | سهولة التكامل | الأولوية |
|
||||||
|
|--------|-----------|---------------|----------|
|
||||||
|
| **WooCommerce** | 🌍 عالمي — ملايين المتاجر | ⭐⭐⭐ سهل (REST API مفتوح) | 🔴 عالية جداً |
|
||||||
|
| **Shopify** | 🌍 عالمي — 4.8M+ متجر | ⭐⭐ متوسط (OAuth App) | 🔴 عالية |
|
||||||
|
| **Zid (زد)** | 🇸🇦 سعودي | ⭐⭐⭐ سهل (API مشابه لسلة) | 🟡 متوسطة |
|
||||||
|
| **OpenCart** | 🌍 عالمي | ⭐⭐ متوسط | 🟢 منخفضة |
|
||||||
|
|
||||||
|
**شو نقدر نوفر لمتاجر WooCommerce/Shopify:**
|
||||||
|
- الرد الآلي على استفسارات العملاء (حالة الطلب، الشحن، الأسعار)
|
||||||
|
- إشعارات تلقائية بتعنوان الطلبات عبر واتساب
|
||||||
|
- استرجاع سلة المشتريات المتروكة (Abandoned Cart Recovery)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### القطاع 2: 🚌 النقل والمواصلات (Entaleq / انطلق) — أولوية عالية جداً
|
||||||
|
|
||||||
|
#### 💡 فكرة 1: التسجيل الذكي عبر الواتساب (Smart Registration Bot)
|
||||||
|
يقوم العميل بإرسال المستندات وتأكيد البيانات خطوة بخطوة عبر محادثة ذكية على واتساب.
|
||||||
|
|
||||||
|
#### 💡 فكرة 2: الرسائل الصوتية بالذكاء الاصطناعي (Voice AI)
|
||||||
|
استقبال مقاطع صوتية وفهمها بالذكاء الاصطناعي، والرد الصوتي المولد بالذكاء الاصطناعي (Text-to-Speech).
|
||||||
|
|
||||||
|
#### 💡 فكرة 3: طلب رحلة عبر الواتساب (Trip Request Bot)
|
||||||
|
تقديم طلبات الرحلات ومطابقتها مع نظام انطلق وتأكيدها.
|
||||||
|
|
||||||
|
#### 💡 فكرة 4: تتبع الرحلة عبر الواتساب (Trip Tracking Bot)
|
||||||
|
تمكين أولياء الأمور من الاستعلام عن مسار وموقع الرحلة وإرسال تحديثات حالة الطلاب تلقائياً.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 ثالثاً: الأفكار الإبداعية المميزة (Killer Features)
|
||||||
|
|
||||||
|
### 1. 🧠 نظام المحادثة الذكية متعدد المراحل (Conversation Flow Engine)
|
||||||
|
القدرة على إدارة حالة العميل وحفظ سياق المحادثة وتوجيهه عبر خطوات محددة (مثل نماذج إدخال البيانات الذكية).
|
||||||
|
|
||||||
|
### 2. 🔌 نظام التكامل العالمي (Universal Integration Hub)
|
||||||
|
لوحة تحكم لربط الأنظمة الخارجية بسهولة عبر Webhooks و Custom APIs وتخصيص المدخلات والمخرجات.
|
||||||
|
|
||||||
|
### 3. 📊 لوحة تحليلات ذكية (AI Analytics Dashboard)
|
||||||
|
تحليلات شاملة للمحادثات، وتصنيف للأسئلة الشائعة وتحديد رضا العملاء تلقائياً.
|
||||||
|
|
||||||
|
### 4. 🎭 شخصيات البوت المخصصة (Bot Personas)
|
||||||
|
اختيار نبرة البوت ولهجته ولغته بما يتناسب مع طبيعة عمل الشركة وعملائها.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 رابعاً: المقارنة مع المنافسين
|
||||||
|
|
||||||
|
يتفوق نبيه بقدرته العالية على معالجة الصور والملفات الصوتية بالذكاء الاصطناعي، ودعم الربط والتكامل المباشر مع الأنظمة المحلية مثل "انطلق" ومتاجر WooCommerce دون قيود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 خامساً: النموذج التجاري المقترح (Business Model)
|
||||||
|
|
||||||
|
- باقة أساسية (محادثات نصية)
|
||||||
|
- باقة متقدمة (صوت وصور وتكاملات)
|
||||||
|
- باقة مؤسسات (تسجيل ذكي وتتبع وتحليلات متقدمة)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 سادساً: خارطة الطريق المعتمدة (Roadmap)
|
||||||
|
|
||||||
|
1. **المرحلة 1: المحرك الأساسي** (Conversation Flow Engine) - 🛠️ *نحن هنا*
|
||||||
|
2. **المرحلة 2: نظام التسجيل الذكي** لتطبيق انطلق (Smart Registration)
|
||||||
|
3. **المرحلة 3: التجارة الإلكترونية ولوحة الإدارة** (WooCommerce + Super Admin & Tenant Dashboards + Analytics)
|
||||||
|
4. **المرحلة 4: الاشتراكات ونظام الدفع**
|
||||||
|
5. **المرحلة 5: تطبيق الموبايل** (Flutter App)
|
||||||
|
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user