Files

336 lines
15 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Flows\ConversationFlowEngine;
use App\Services\TTSService;
use App\Models\WhatsAppSession;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
class OTPController extends BaseController
{
/**
* Generate an image with the OTP code and return its public URL.
*/
private function generateOtpImage(string $code): string
{
if (function_exists('imagecreate')) {
// Base image
$img = imagecreatetruecolor(400, 200);
// Define high-contrast, beautiful color palettes [bg, text, line]
$palettes = [
['bg' => [240, 248, 255], 'text' => [15, 23, 42], 'line' => [148, 163, 184]], // Light Blue / Dark Navy
['bg' => [255, 250, 240], 'text' => [67, 20, 7], 'line' => [252, 211, 77]], // Floral White / Dark Brown
['bg' => [240, 253, 244], 'text' => [6, 78, 59], 'line' => [110, 231, 183]], // Mint Green / Dark Green
['bg' => [254, 242, 242], 'text' => [127, 29, 29], 'line' => [252, 165, 165]], // Light Red / Dark Red
['bg' => [250, 245, 255], 'text' => [88, 28, 135], 'line' => [216, 180, 254]], // Light Purple / Dark Purple
['bg' => [255, 247, 237], 'text' => [154, 52, 18], 'line' => [253, 186, 116]], // Light Orange / Dark Orange
];
$palette = $palettes[array_rand($palettes)];
$bg = imagecolorallocate($img, $palette['bg'][0], $palette['bg'][1], $palette['bg'][2]);
$textColor = imagecolorallocate($img, $palette['text'][0], $palette['text'][1], $palette['text'][2]);
$lineColor = imagecolorallocate($img, $palette['line'][0], $palette['line'][1], $palette['line'][2]);
imagefill($img, 0, 0, $bg);
// Add noise lines to make it look like a CAPTCHA and prevent automated scraping
for ($i = 0; $i < 15; $i++) {
imageline($img, rand(0, 400), rand(0, 200), rand(0, 400), rand(0, 200), $lineColor);
}
// Add random noise dots
for ($i = 0; $i < 200; $i++) {
imagesetpixel($img, rand(0, 400), rand(0, 200), $lineColor);
}
// Get or download a beautiful modern font (Roboto Bold)
$fontDir = __DIR__ . '/../../public/fonts';
if (!is_dir($fontDir)) {
mkdir($fontDir, 0755, true);
}
$fontPath = $fontDir . '/Roboto-Bold.ttf';
if (!file_exists($fontPath)) {
$options = [
'http' => [
'method' => 'GET',
'header' => 'User-Agent: PHP'
]
];
$context = stream_context_create($options);
$fontData = @file_get_contents('https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Bold.ttf', false, $context);
if ($fontData) {
file_put_contents($fontPath, $fontData);
}
}
$spacedCode = implode(' ', str_split($code));
if (file_exists($fontPath) && function_exists('imagettftext')) {
// Calculate exact text dimensions to center it perfectly
$fontSize = 48; // Large smooth font
$bbox = imagettfbbox($fontSize, 0, $fontPath, $spacedCode);
$textWidth = abs($bbox[2] - $bbox[0]);
$textHeight = abs($bbox[1] - $bbox[7]);
// If code is too long, shrink font
if ($textWidth > 360) {
$fontSize = 36;
$bbox = imagettfbbox($fontSize, 0, $fontPath, $spacedCode);
$textWidth = abs($bbox[2] - $bbox[0]);
$textHeight = abs($bbox[1] - $bbox[7]);
}
// Center calculations
$x = (400 - $textWidth) / 2;
$y = ((200 - $textHeight) / 2) + $textHeight; // Y is the baseline of the text
// Draw beautifully smooth true type text
imagettftext($img, $fontSize, 0, (int)$x, (int)$y, $textColor, $fontPath, $spacedCode);
} else {
// Fallback for missing TTF
$tmp = imagecreatetruecolor(200, 40);
$tmpBg = imagecolorallocate($tmp, $palette['bg'][0], $palette['bg'][1], $palette['bg'][2]);
$tmpText = imagecolorallocate($tmp, $palette['text'][0], $palette['text'][1], $palette['text'][2]);
imagefill($tmp, 0, 0, $tmpBg);
imagestring($tmp, 5, 5, 12, $spacedCode, $tmpText);
// Scale up dynamically
imagecopyresampled($img, $tmp, 0, 60, 0, 0, 400, 80, 200, 40);
imagedestroy($tmp);
}
ob_start();
imagepng($img);
$imageData = ob_get_clean();
imagedestroy($img);
return base64_encode($imageData);
} else {
// Fallback to placehold.co if PHP GD extension is missing
$url = "https://placehold.co/400x200/e0f2fe/0f172a/png?text=" . $code . "&font=oswald";
$content = file_get_contents($url);
return base64_encode($content);
}
}
/**
* Send OTP verification code via WhatsApp (Text, Voice Note, or Image)
* POST /api/otp/send
*/
public function send(Request $request, Response $response): void
{
$companyId = $request->company_id;
$body = $request->getBody();
$phone = $body['phone'] ?? '';
$type = $body['type'] ?? 'text'; // 'text', 'voice', or 'image'
$sessionId = $body['session_id'] ?? null;
$customCode = $body['code'] ?? null;
if (empty($phone)) {
$response->status(400)->json(['error' => 'Missing required parameter: phone']);
return;
}
// Clean phone number (remove non-digits including +)
$phone = preg_replace('/\D/', '', $phone);
// 1. Resolve WhatsApp Candidate Sessions with Round-Robin & Multi-Line Failover
$candidateSessions = [];
if ($sessionId) {
$singleSession = WhatsAppSession::findSecure((int)$sessionId);
if (!$singleSession || (int)$singleSession['company_id'] !== (int)$companyId) {
$response->status(404)->json(['error' => 'WhatsApp session not found']);
return;
}
$candidateSessions = [$singleSession];
} else {
// Get all active connected sessions ordered by Round-Robin strategy
$candidateSessions = WhatsAppSession::getRoundRobinSessionsList($companyId);
// Fallback: If no connected session found in cache/DB, check all company sessions
if (empty($candidateSessions)) {
$all = WhatsAppSession::findAllByCompany($companyId);
if (!empty($all)) {
$candidateSessions = $all;
}
}
}
if (empty($candidateSessions)) {
$response->status(400)->json(['error' => 'No active WhatsApp sessions configured for this company.']);
return;
}
// 2. Check SaaS subscription quotas
$useElevenLabs = false;
if ($companyId !== 1) {
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
$response->status(402)->json(['error' => 'Active subscription plan required.']);
return;
}
if (!CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
$response->status(403)->json(['error' => 'Monthly request quota exceeded. Please upgrade your plan.']);
return;
}
if ($type === 'voice') {
$features = json_decode($activeSub['features'] ?: '{}', true);
$voiceFeatureEnabled = isset($features['voice']) ? (bool)$features['voice'] : false;
$hasVoiceLimit = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice');
if ($voiceFeatureEnabled && $hasVoiceLimit) {
$useElevenLabs = true;
}
}
} else {
if ($type === 'voice') {
$useElevenLabs = true;
}
}
// 3. Generate verification code
$code = $customCode ? trim($customCode) : (string)rand(1000, 9999);
// 4. Prepare Payload
try {
$usedElevenLabsFinal = false;
$audioBase64 = null;
$mimeType = null;
$imageBase64 = null;
$textMsg = null;
if ($type === 'voice') {
// Spacing the digits to force slow Arabic pronunciation: e.g. "1 2 3 4"
$spacedCode = implode(' ', str_split($code));
$textToRead = "رمز التحقق الخاص بك هو: {$spacedCode}. أكرر، رمز التحقق هو: {$spacedCode}.";
$mimeType = 'audio/mp3';
if ($useElevenLabs) {
// Try getting ElevenLabs key from first candidate session
$firstSession = $candidateSessions[0];
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId, $firstSession['id']);
$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);
if (!empty($elApiKey)) {
$elVoiceId = !empty($elVoiceId) ? $elVoiceId : 'pNInz6obpgDQGcFmaJgB'; // Default to Adam
$audioData = \App\Services\GeminiService::generateAudioResponseWithElevenLabs($elApiKey, $textToRead, $elVoiceId);
if ($audioData) {
$audioBase64 = $audioData['audio'];
$mimeType = $audioData['mimeType'];
$usedElevenLabsFinal = true;
}
}
}
if (!$audioBase64) {
// Fallback to Google Translate TTS
$audioBase64 = TTSService::textToSpeechArabic($textToRead);
$mimeType = 'audio/mp3';
$usedElevenLabsFinal = false;
}
if (!$audioBase64) {
$response->status(500)->json(['error' => 'Failed to generate voice OTP audio.']);
return;
}
} else if ($type === 'image') {
$imageBase64 = $this->generateOtpImage($code);
} else {
$customText = $body['message'] ?? null;
if ($customText) {
$textMsg = str_replace('{code}', $code, $customText);
} else {
$textMsg = "رمز التحقق الخاص بك لمتجر نابه هو: *{$code}* \n الرجاء عدم مشاركته مع أي شخص.";
}
}
// 5. Send Message with Automatic Multi-Line Failover across Active Lines
$sentSuccessfully = false;
$usedSession = null;
$attemptErrors = [];
foreach ($candidateSessions as $index => $session) {
// If there are multiple sessions, skip non-connected ones
if ($session['status'] !== 'connected' && count($candidateSessions) > 1) {
continue;
}
$sessionLabel = ($session['name'] ?? 'Line') . " (" . ($session['session_key'] ?? 'unknown') . ")";
try {
$success = false;
if ($type === 'voice') {
$success = ConversationFlowEngine::sendReply($session, $phone, '', null, $audioBase64, $mimeType);
} else if ($type === 'image') {
$success = ConversationFlowEngine::sendReply($session, $phone, '', null, null, null, $imageBase64);
} else {
$success = ConversationFlowEngine::sendReply($session, $phone, $textMsg);
}
if ($success) {
$sentSuccessfully = true;
$usedSession = $session;
break; // Sent successfully! Break out of failover loop
} else {
$errorDetail = "Failed on {$sessionLabel}";
$attemptErrors[] = $errorDetail;
error_log("[OTP Failover] " . $errorDetail . " → Trying next active line...");
}
} catch (\Exception $sendEx) {
$errorDetail = "Exception on {$sessionLabel}: " . $sendEx->getMessage();
$attemptErrors[] = $errorDetail;
error_log("[OTP Failover Exception] " . $errorDetail);
}
}
if (!$sentSuccessfully) {
$errorSummary = !empty($attemptErrors) ? implode(' | ', $attemptErrors) : 'All candidate lines failed';
$response->status(500)->json([
'error' => "Failed to send {$type} OTP via gateway. Tried " . count($attemptErrors) . " active line(s): {$errorSummary}"
]);
return;
}
// Increment usage stats
if ($companyId !== 1) {
CompanySubscriptionUsage::incrementUsage($companyId, 'request');
if ($type === 'voice' && $usedElevenLabsFinal) {
CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
}
}
$response->json([
'status' => 'success',
'message' => 'OTP sent successfully',
'code' => $code,
'type' => $type,
'session_used' => [
'id' => $usedSession['id'] ?? null,
'name' => $usedSession['name'] ?? null,
'session_key' => $usedSession['session_key'] ?? null
]
]);
} catch (\Exception $e) {
error_log("[OTP Controller Error] " . $e->getMessage());
$response->status(500)->json(['error' => 'Failed to send OTP message: ' . $e->getMessage()]);
}
}
}