Deploy: 2026-05-22 03:37:23

This commit is contained in:
Hamza-Ayed
2026-05-22 03:37:23 +03:00
parent 479aedcbcf
commit 8448f13dfc
5 changed files with 761 additions and 2 deletions

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
use App\Models\SallaMerchant;
use App\Models\WhatsAppSession;
class SallaController extends BaseController
{
/**
* Redirect user to Salla OAuth login page.
* Accessible via GET /api/integrations/salla/auth?token=JWT_TOKEN
*/
public function auth(Request $request, Response $response)
{
$token = $_GET['token'] ?? '';
if (empty($token)) {
$response->status(400)->html("<h3>Error: Token is required</h3>");
return;
}
$payload = Security::verifyJWT($token);
if (!$payload) {
$response->status(401)->html("<h3>Error: Invalid token</h3>");
return;
}
$companyId = $payload['company_id'];
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
$authUrl = "https://accounts.salla.sa/oauth2/auth?" . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'read_orders read_customers',
'state' => $companyId
]);
header("Location: " . $authUrl);
exit;
}
/**
* Handle Salla OAuth callback.
* Accessible via GET /api/integrations/salla/callback?code=CODE&state=COMPANY_ID
*/
public function callback(Request $request, Response $response)
{
$code = $_GET['code'] ?? '';
$companyId = $_GET['state'] ?? '';
if (empty($code) || empty($companyId)) {
$response->status(400)->html("<h3>Error: Missing authorization code or state (company_id).</h3>");
return;
}
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
$clientSecret = getenv('SALLA_CLIENT_SECRET') ?: '';
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
// 1. Swap authorization code for token
$ch = curl_init('https://accounts.salla.sa/oauth2/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Salla Token Error: HTTP $httpCode - Response: $res");
$response->status(500)->html("<h3>Failed to exchange code for Salla token. Status code: $httpCode</h3>");
return;
}
$tokenData = json_decode($res, true);
if (empty($tokenData['access_token'])) {
$response->status(500)->html("<h3>Failed to parse Salla token response.</h3>");
return;
}
$accessToken = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'];
$expiresIn = $tokenData['expires_in'] ?? 2592000;
$expiresAt = date('Y-m-d H:i:s', time() + $expiresIn);
// 2. Fetch merchant/user info
$chInfo = curl_init('https://accounts.salla.sa/oauth2/user/info');
curl_setopt($chInfo, CURLOPT_RETURNTRANSFER, true);
curl_setopt($chInfo, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($chInfo, CURLOPT_TIMEOUT, 10);
$resInfo = curl_exec($chInfo);
$infoHttpCode = curl_getinfo($chInfo, CURLINFO_HTTP_CODE);
curl_close($chInfo);
$storeName = 'Salla Store';
$merchantId = '';
if ($infoHttpCode === 200) {
$infoData = json_decode($resInfo, true);
$merchantId = $infoData['merchant']['id'] ?? $infoData['id'] ?? '';
$storeName = $infoData['merchant']['name'] ?? 'Salla Store';
}
if (empty($merchantId)) {
error_log("Salla Info Fetch Error: HTTP $infoHttpCode - Response: $resInfo");
$response->status(500)->html("<h3>Failed to fetch merchant profile from Salla.</h3>");
return;
}
// 3. Save or update Salla merchant settings in DB
SallaMerchant::saveSecure([
'company_id' => (int)$companyId,
'merchant_id' => (string)$merchantId,
'store_name' => $storeName,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt
]);
// Redirect back to frontend
header("Location: " . getenv('APP_URL') . "/?salla_connect=success");
exit;
}
/**
* Get Salla connection status for current company.
* Protected by AuthMiddleware.
* Accessible via GET /api/integrations/salla/status
*/
public function status(Request $request, Response $response)
{
$merchant = SallaMerchant::findByCompany($request->company_id);
if ($merchant) {
$response->json([
'status' => 'success',
'connected' => true,
'store_name' => $merchant['store_name'],
'merchant_id' => $merchant['merchant_id'],
'expires_at' => $merchant['expires_at']
]);
} else {
$response->json([
'status' => 'success',
'connected' => false
]);
}
}
/**
* Disconnect Salla integration.
* Protected by AuthMiddleware.
* Accessible via POST /api/integrations/salla/disconnect
*/
public function disconnect(Request $request, Response $response)
{
SallaMerchant::deleteByCompany($request->company_id);
$response->json([
'status' => 'success',
'message' => 'Salla integration disconnected successfully'
]);
}
/**
* Handle incoming Webhook from Salla.
* Accessible via POST /api/webhooks/salla
*/
public function webhook(Request $request, Response $response)
{
// 1. Get raw input and signature
$rawPayload = file_get_contents('php://input');
$signature = $request->getHeader('x-salla-signature') ?: '';
// 2. Verify signature
$secret = getenv('SALLA_WEBHOOK_SECRET') ?: '406550c38b782a50e9a3e0687f564107f8a60135b072b1f819470dda5333a65d';
$calculated = hash_hmac('sha256', $rawPayload, $secret);
if (!hash_equals($calculated, $signature)) {
error_log("[Salla Webhook Error] Invalid signature. Received: $signature, Calculated: $calculated");
$response->status(401)->json(['error' => 'Invalid signature']);
return;
}
// 3. Parse JSON payload
$payload = json_decode($rawPayload, true);
if (!$payload) {
$response->status(400)->json(['error' => 'Invalid JSON']);
return;
}
$event = $payload['event'] ?? '';
$merchantId = $payload['merchant'] ?? '';
if (empty($event) || empty($merchantId)) {
$response->status(400)->json(['error' => 'Missing event or merchant information']);
return;
}
// 4. Find the merchant config to get company_id
$merchant = SallaMerchant::findByMerchantId((string)$merchantId);
if (!$merchant) {
error_log("[Salla Webhook Warning] Webhook received for unlinked merchant ID: " . $merchantId);
$response->status(404)->json(['error' => 'Store is not linked to any company in Nabeh']);
return;
}
$companyId = (int)$merchant['company_id'];
$orderData = $payload['data'] ?? [];
// 5. Process events
if ($event === 'order.created' || $event === 'order.status.updated') {
$this->handleOrderWebhook($companyId, $event, $orderData);
}
$response->json(['status' => 'success']);
}
/**
* Process order webhook events and send automated WhatsApp message
*/
private function handleOrderWebhook(int $companyId, string $event, array $orderData)
{
$orderId = $orderData['id'] ?? $orderData['reference_id'] ?? '';
$customerName = $orderData['customer']['name'] ?? 'عميلنا العزيز';
$customerPhone = $orderData['customer']['mobile'] ?? $orderData['customer']['phone'] ?? '';
$statusName = $orderData['status']['name'] ?? '';
if (empty($customerPhone) || empty($orderId)) {
return;
}
// Format phone: remove leading zero or +, ensure only digits
$customerPhone = preg_replace('/\D/', '', $customerPhone);
if (substr($customerPhone, 0, 2) === '00') {
$customerPhone = substr($customerPhone, 2);
}
// Normalize Saudi numbers starting with 05
if (strlen($customerPhone) === 9 && $customerPhone[0] === '5') {
$customerPhone = '966' . $customerPhone;
} elseif (strlen($customerPhone) === 10 && substr($customerPhone, 0, 2) === '05') {
$customerPhone = '966' . substr($customerPhone, 1);
}
// Construct message based on event
$message = '';
if ($event === 'order.created') {
$message = "مرحباً {$customerName}،\nتم استلام طلبك رقم ({$orderId}) بنجاح! شكرًا لتسوقك معنا. سنقوم بتحديثك فور تغيير حالة الطلب.";
} elseif ($event === 'order.status.updated') {
$message = "مرحباً {$customerName}،\nتم تحديث حالة طلبك رقم ({$orderId}) إلى: *{$statusName}*.";
// Check for shipping details
$courier = $orderData['shipment']['courier_name'] ?? '';
$tracking = $orderData['shipment']['tracking_link'] ?? '';
if (!empty($courier) && !empty($tracking)) {
$message .= "\n🚚 الشحن عبر: {$courier}\nرابط التتبع: {$tracking}";
}
}
if (empty($message)) {
return;
}
// Send WhatsApp Message via active session
$session = WhatsAppSession::findByCompany($companyId);
if (!$session || $session['status'] !== 'connected') {
error_log("[Salla Webhook Warning] Cannot send auto-notification: No active connected WhatsApp session for company: " . $companyId);
return;
}
$this->sendWhatsAppNotification($session['session_key'], $customerPhone, $message);
}
/**
* Send a WhatsApp message through the gateway
*/
private function sendWhatsAppNotification(string $sessionKey, string $phone, string $message)
{
$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' => $sessionKey,
'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);
if ($httpCode !== 200) {
error_log("[Salla Webhook Gateway Error] Failed to send WhatsApp notification. Gateway response: " . $response);
}
}
}

View File

@@ -343,12 +343,21 @@ class WhatsAppController extends BaseController
$infoContext = $this->fetchUserInfoFromEndpoint($infoEndpoint, $msgData['phone']);
}
// Dynamically fetch Salla order context if connected
$sallaContext = "";
if (!empty($msgData['phone'])) {
$sallaContext = $this->fetchSallaOrderContext($session['company_id'], $msgData['phone'], $incomingText);
}
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
// Append real-time info context to Gemini system prompt
if (!empty($infoContext)) {
$systemPrompt .= "\n\n" . $infoContext;
}
if (!empty($sallaContext)) {
$systemPrompt .= "\n\n" . $sallaContext;
}
// Enforce language matching rule dynamically
$systemPrompt .= "\n\nIMPORTANT LANGUAGE RULE: Detect the language of the incoming message. If the incoming message is in English, you MUST reply in English. If the incoming message is in Arabic, you MUST reply in Arabic. Override any default language instruction to match the user's language.";
@@ -578,4 +587,124 @@ class WhatsAppController extends BaseController
}
return "";
}
/**
* Fetch order info context from Salla e-commerce platform for the company
*/
private function fetchSallaOrderContext(int $companyId, string $phone, string $incomingText): string
{
try {
$accessToken = \App\Models\SallaMerchant::getOrRefreshAccessToken($companyId);
if (!$accessToken) {
return ""; // Salla is not integrated
}
// Standardize customer phone to compare last 9 digits
$cleanPhone = preg_replace('/\D/', '', $phone);
if (empty($cleanPhone)) {
return "";
}
// 1. Check if user is asking about a specific order ID (e.g. sequence of 5-12 digits)
if (preg_match('/\b(\d{5,12})\b/', $incomingText, $matches)) {
$orderId = $matches[1];
$ch = curl_init("https://api.salla.dev/admin/v2/orders/{$orderId}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$orderRes = json_decode($response, true);
$order = $orderRes['data'] ?? null;
if ($order) {
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
// Security check: match last 9 digits of WhatsApp phone to order customer phone
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
$status = $order['status']['name'] ?? 'غير معروف';
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
$courier = $order['shipment']['courier_name'] ?? '';
$tracking = $order['shipment']['tracking_link'] ?? '';
$itemsCount = count($order['items'] ?? []);
$context = "\n\n[تفاصيل طلب سلة المستعلم عنه للعميل:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$status}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
if (!empty($courier)) {
$context .= "- شركة الشحن: {$courier}\n";
}
if (!empty($tracking)) {
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
}
$context .= "الرجاء صياغة رد ودود ومختصر باللغة العربية لإخبار العميل بحالة هذا الطلب بالتحديد]";
return $context;
} else {
// Order ID exists but phone mismatch
return "\n\n[تنبيه أمني للذكاء الاصطناعي: العميل سأل عن الطلب رقم {$orderId} ولكن هذا الطلب مسجل برقم هاتف مختلف في سلة. لحماية الخصوصية والأمان، يمنع منعاً باتاً عرض تفاصيل هذا الطلب له. أخبر العميل بلطف أن رقم الهاتف الحالي لا يتطابق مع رقم الهاتف المسجل في تفاصيل هذا الطلب ولا يمكنك كشف تفاصيله]";
}
}
}
}
// 2. Fetch list of recent orders for the merchant and search by customer phone number
$ch = curl_init("https://api.salla.dev/admin/v2/orders?page=1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$ordersRes = json_decode($response, true);
$orders = $ordersRes['data'] ?? [];
if (is_array($orders)) {
foreach ($orders as $order) {
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
// Found the most recent order for this customer
$orderId = $order['id'] ?? $order['reference_id'] ?? '';
$status = $order['status']['name'] ?? 'غير معروف';
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
$courier = $order['shipment']['courier_name'] ?? '';
$tracking = $order['shipment']['tracking_link'] ?? '';
$itemsCount = count($order['items'] ?? []);
$context = "\n\n[آخر طلب للعميل في متجر سلة:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$status}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
if (!empty($courier)) {
$context .= "- شركة الشحن: {$courier}\n";
}
if (!empty($tracking)) {
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
}
$context .= "الرجاء استخدام هذه التفاصيل للإجابة على استفساره حول حالة طلبه الأخير بدقة وود باللغة العربية]";
return $context;
}
}
}
}
return "\n\n[سياق المتجر: العميل متصل بمتجر سلة ولكن لم يتم العثور على أي طلبات سابقة له برقم الهاتف هذا. أخبره بلطف أنه لا توجد طلبات سابقة مسجلة برقم هاتفه الحالي في المتجر، واطلب منه تزويدك برقم الطلب أو البريد الإلكتروني للبحث]";
} catch (\Exception $e) {
error_log("[Fetch Salla Order Exception] " . $e->getMessage());
}
return "";
}
}