diff --git a/backend/app/Controllers/CampaignController.php b/backend/app/Controllers/CampaignController.php index 2e98806..a85acf7 100644 --- a/backend/app/Controllers/CampaignController.php +++ b/backend/app/Controllers/CampaignController.php @@ -16,6 +16,15 @@ class CampaignController extends BaseController $campaignModel = new Campaign(); $campaigns = $campaignModel->findAllByCompany($request->company_id); + // Count sent messages per campaign from database + foreach ($campaigns as &$cmp) { + $counts = \App\Core\Database::selectOne( + "SELECT COUNT(*) as total FROM messages_log WHERE campaign_id = ? AND status = 'sent'", + [$cmp['id']] + ); + $cmp['sent_count'] = $counts['total'] ?? 0; + } + $response->json([ 'status' => 'success', 'data' => $campaigns @@ -23,7 +32,7 @@ class CampaignController extends BaseController } /** - * Create a new broadcast campaign + * Create a new broadcast campaign and launch it */ public function store(Request $request, Response $response) { @@ -42,10 +51,6 @@ class CampaignController extends BaseController $body = $request->getBody(); $campaignModel = new Campaign(); - // In a real dispatch scenario, we would enqueue jobs here - // to iterate over the contacts in the group, replace template variables, - // and add entries to messages_log with 'pending' status. - $id = $campaignModel->create([ 'company_id' => $request->company_id, 'name' => $body['name'], @@ -56,10 +61,155 @@ class CampaignController extends BaseController 'scheduled_at' => $body['scheduled_at'] ?? null ]); - $response->status(201)->json([ - 'status' => 'success', - 'message' => 'Campaign queued successfully', - 'id' => $id - ]); + // Launch campaign in background using PHP fastcgi_finish_request + if (function_exists('fastcgi_finish_request')) { + $response->status(201); + $response->setHeader('Content-Type', 'application/json; charset=utf-8'); + $allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*'; + $response->setHeader('Access-Control-Allow-Origin', $allowedOrigin); + $response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + $response->sendHeaders(); + http_response_code(201); + echo json_encode([ + 'status' => 'success', + 'message' => 'Campaign started in background', + 'id' => $id + ], JSON_UNESCAPED_UNICODE); + fastcgi_finish_request(); + + $this->dispatchCampaign($id, $request->company_id); + exit; + } else { + // Fallback for environment without PHP-FPM + $this->dispatchCampaign($id, $request->company_id); + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Campaign completed successfully (synchronous fallback)', + 'id' => $id + ]); + } + } + + /** + * Dispatch campaign messages in sequence with rate limiting + */ + private function dispatchCampaign(int $campaignId, int $companyId) + { + set_time_limit(0); + ignore_user_abort(true); + + $campaign = \App\Core\Database::selectOne( + "SELECT * FROM campaigns WHERE id = ? AND company_id = ? LIMIT 1", + [$campaignId, $companyId] + ); + + if (!$campaign) return; + + // Set status to running + \App\Core\Database::execute( + "UPDATE campaigns SET status = 'running' WHERE id = ?", + [$campaignId] + ); + + // Fetch template + $template = \App\Models\Template::findByIdAndCompany($campaign['template_id'], $companyId); + if (!$template) { + \App\Core\Database::execute( + "UPDATE campaigns SET status = 'failed' WHERE id = ?", + [$campaignId] + ); + return; + } + + // Fetch whatsapp session + $session = \App\Core\Database::selectOne( + "SELECT * FROM whatsapp_sessions WHERE id = ? AND company_id = ? LIMIT 1", + [$campaign['session_id'], $companyId] + ); + if (!$session || $session['status'] !== 'connected') { + \App\Core\Database::execute( + "UPDATE campaigns SET status = 'failed' WHERE id = ?", + [$campaignId] + ); + return; + } + + // Get contacts in group + $contacts = \App\Models\ContactGroup::getRawContacts($campaign['group_id']); + if (empty($contacts)) { + \App\Core\Database::execute( + "UPDATE campaigns SET status = 'completed' WHERE id = ?", + [$campaignId] + ); + return; + } + + $gatewayUrl = getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722'; + $sendUrl = $gatewayUrl . '/api/messages/send'; + + foreach ($contacts as $rawContact) { + // Decrypt contact data + $contact = \App\Models\Contact::findByPhone($companyId, \App\Core\Security::decrypt($rawContact['phone'])); + if (!$contact) continue; + + // Replace template variables + $messageBody = str_replace('{{name}}', $contact['name'], $template['body']); + + // Send via cURL to Node.js Gateway + $payload = json_encode([ + 'session_key' => $session['session_key'], + 'phone' => $contact['phone'], + 'message' => $messageBody, + 'media_url' => $template['media_url'] + ]); + + $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; + + if ($httpCode === 200) { + $status = 'sent'; + } else { + $resData = json_decode($response, true); + $errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode; + } + + // Log message securely + \App\Models\MessageLog::logMessage([ + 'company_id' => $companyId, + 'session_id' => $session['id'], + 'campaign_id' => $campaignId, + 'contact_phone' => $contact['phone'], + 'direction' => 'outbound', + 'message_type' => $template['type'], + 'message_body' => $messageBody, + 'media_url' => $template['media_url'], + 'status' => $status, + 'error_message' => $errorMsg + ]); + + // Wait 2 seconds between messages + sleep(2); + } + + // Set status to completed + \App\Core\Database::execute( + "UPDATE campaigns SET status = 'completed' WHERE id = ?", + [$campaignId] + ); } } diff --git a/backend/app/Controllers/ChatbotController.php b/backend/app/Controllers/ChatbotController.php new file mode 100644 index 0000000..6ad08e9 --- /dev/null +++ b/backend/app/Controllers/ChatbotController.php @@ -0,0 +1,75 @@ +company_id); + + $response->json([ + 'status' => 'success', + 'data' => $rules + ]); + } + + /** + * Store or update a chatbot rule + */ + public function store(Request $request, Response $response) + { + $errors = $this->validate($request, [ + 'trigger_type' => 'required', + 'is_active' => 'required' + ]); + + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $body = $request->getBody(); + + // Find existing rule or create one + $rules = ChatbotRule::findAllByCompany($request->company_id); + $ruleId = null; + if (!empty($rules)) { + $ruleId = $rules[0]['id']; + } + + $saveData = [ + 'company_id' => $request->company_id, + 'session_id' => !empty($body['session_id']) ? (int)$body['session_id'] : null, + 'trigger_type' => $body['trigger_type'], + 'keyword' => $body['keyword'] ?? null, + 'ai_prompt' => $body['ai_prompt'] ?? null, + 'is_active' => $body['is_active'] ? 1 : 0 + ]; + + if ($ruleId) { + $saveData['id'] = $ruleId; + } + + // If gemini_api_key is provided, update it. If not, and we have an existing rule, retain it. + // If it's a password placeholder like '••••••••' or similar, don't overwrite the existing one. + if (isset($body['gemini_api_key']) && $body['gemini_api_key'] !== '••••••••' && $body['gemini_api_key'] !== '') { + $saveData['gemini_api_key'] = $body['gemini_api_key']; + } + + $id = ChatbotRule::saveSecure($saveData); + + $response->json([ + 'status' => 'success', + 'message' => 'Chatbot rule saved successfully', + 'id' => $id + ]); + } +} diff --git a/backend/app/Controllers/WhatsAppController.php b/backend/app/Controllers/WhatsAppController.php index 9edb35d..6bcd3d1 100644 --- a/backend/app/Controllers/WhatsAppController.php +++ b/backend/app/Controllers/WhatsAppController.php @@ -202,10 +202,100 @@ class WhatsAppController extends BaseController } /** - * Placeholder to trigger Gemini AI Auto-Replies or Keyword rules (Phase 5) + * Trigger Gemini AI Auto-Replies or Keyword rules (Phase 5) */ private function triggerAutoReply(array $session, array $msgData) { - // To be implemented in Phase 5 + try { + $rule = \App\Models\ChatbotRule::findActiveForRule($session['company_id'], $session['id']); + if (!$rule || !$rule['is_active']) { + return; + } + + $incomingText = trim($msgData['body']); + if (empty($incomingText)) { + return; + } + + $replyText = null; + + if ($rule['trigger_type'] === 'keyword') { + $keywords = array_filter(array_map('trim', explode(',', $rule['keyword']))); + $matched = false; + foreach ($keywords as $kw) { + if (mb_stripos($incomingText, $kw) !== false) { + $matched = true; + break; + } + } + if ($matched) { + $replyText = $rule['ai_prompt']; // Under keyword rules, ai_prompt stores the predefined static reply + } + } elseif ($rule['trigger_type'] === 'gemini_ai') { + $apiKey = $rule['gemini_api_key'] ?: getenv('GEMINI_API_KEY'); + if (empty($apiKey)) { + error_log("[Chatbot Warning] Gemini API Key is not set globally or for company " . $session['company_id']); + return; + } + $systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.'; + + $replyText = \App\Services\GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText); + } + + if (!empty($replyText)) { + // Send reply back to the contact via Node.js Gateway + $gatewayUrl = getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722'; + $sendUrl = $gatewayUrl . '/api/messages/send'; + + $payload = json_encode([ + 'session_key' => $session['session_key'], + 'phone' => $msgData['phone'], + 'message' => $replyText + ]); + + $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("[Chatbot Gateway Error] failed to send auto-reply: " . $errorMsg); + } + + // Log the outbound auto-reply message + \App\Models\MessageLog::logMessage([ + 'company_id' => $session['company_id'], + 'session_id' => $session['id'], + 'contact_phone' => $msgData['phone'], + 'direction' => 'outbound', + 'message_type' => 'text', + 'message_body' => $replyText, + 'whatsapp_message_id' => $waMsgId, + 'status' => $status, + 'error_message' => $errorMsg + ]); + } + } catch (\Exception $e) { + error_log("[Chatbot Exception] Error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine()); + } } } diff --git a/backend/app/Models/ChatbotRule.php b/backend/app/Models/ChatbotRule.php new file mode 100644 index 0000000..feae6f0 --- /dev/null +++ b/backend/app/Models/ChatbotRule.php @@ -0,0 +1,102 @@ +getMessage()); + } + } +} diff --git a/backend/app/Services/GeminiService.php b/backend/app/Services/GeminiService.php new file mode 100644 index 0000000..b6bbe1a --- /dev/null +++ b/backend/app/Services/GeminiService.php @@ -0,0 +1,51 @@ + [ + [ + 'role' => 'user', + 'parts' => [ + ['text' => $userMessage] + ] + ] + ], + 'systemInstruction' => [ + 'parts' => [ + ['text' => $systemPrompt] + ] + ] + ]); + + $ch = curl_init($url); + 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' + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("[Gemini API Error] HTTP " . $httpCode . " | Response: " . $response); + return null; + } + + $data = json_decode($response, true); + return $data['candidates'][0]['content']['parts'][0]['text'] ?? null; + } +} diff --git a/backend/public/index.html b/backend/public/index.html index 0e8df37..6c9807e 100644 --- a/backend/public/index.html +++ b/backend/public/index.html @@ -540,13 +540,84 @@ } .gap-1 { gap: 0.25rem; } - .gap-2 { gap: 0.5rem; } - .gap-3 { gap: 0.75rem; } - .gap-4 { gap: 1rem; } - .text-center { text-align: center; } .font-semibold { font-weight: 600; } .text-muted { color: var(--text-secondary); } + + /* Modal Styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(10, 11, 20, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + animation: fadeIn 0.3s ease-out; + } + + .modal-card { + background: var(--card-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--card-border); + border-radius: 16px; + width: 100%; + max-width: 500px; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); + animation: slideDown 0.3s ease-out; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .modal-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--card-border); + display: flex; + align-items: center; + justify-content: space-between; + } + + .modal-title { + font-size: 1.2rem; + font-weight: 700; + } + + .modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + transition: color 0.2s ease; + } + + .modal-close:hover { + color: var(--text-primary); + } + + .modal-body { + padding: 1.5rem; + overflow-y: auto; + max-height: 70vh; + } + + .modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--card-border); + display: flex; + justify-content: flex-end; + gap: 1rem; + } + .font-semibold { font-weight: 600; } + .text-muted { color: var(--text-secondary); }
@@ -651,6 +722,9 @@ + @@ -806,7 +880,7 @@