Phase 4: Support LID identity scheme and fix incoming message parsing
This commit is contained in:
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
75
backend/app/Controllers/ChatbotController.php
Normal file
75
backend/app/Controllers/ChatbotController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\ChatbotRule;
|
||||
|
||||
class ChatbotController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Get chatbot rules for the company
|
||||
*/
|
||||
public function index(Request $request, Response $response)
|
||||
{
|
||||
$rules = ChatbotRule::findAllByCompany($request->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
backend/app/Models/ChatbotRule.php
Normal file
102
backend/app/Models/ChatbotRule.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Security;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* ChatbotRule Model
|
||||
* Handles the chatbot_rules table with encryption for Gemini API key.
|
||||
*/
|
||||
class ChatbotRule extends BaseModel
|
||||
{
|
||||
protected static string $table = 'chatbot_rules';
|
||||
|
||||
/**
|
||||
* Find chatbot rules for a company
|
||||
*/
|
||||
public static function findAllByCompany(int $companyId)
|
||||
{
|
||||
self::ensureColumnsExist();
|
||||
|
||||
$rules = Database::select(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? ORDER BY id DESC",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
if (!empty($rule['gemini_api_key'])) {
|
||||
$rule['gemini_api_key'] = Security::decrypt($rule['gemini_api_key']);
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active chatbot rule for a specific company and session
|
||||
*/
|
||||
public static function findActiveForRule(int $companyId, ?int $sessionId = null)
|
||||
{
|
||||
self::ensureColumnsExist();
|
||||
|
||||
if ($sessionId) {
|
||||
$rule = Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND (session_id = ? OR session_id IS NULL) AND is_active = 1 LIMIT 1",
|
||||
[$companyId, $sessionId]
|
||||
);
|
||||
} else {
|
||||
$rule = Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND is_active = 1 LIMIT 1",
|
||||
[$companyId]
|
||||
);
|
||||
}
|
||||
|
||||
if ($rule && !empty($rule['gemini_api_key'])) {
|
||||
$rule['gemini_api_key'] = Security::decrypt($rule['gemini_api_key']);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update chatbot rule securely
|
||||
*/
|
||||
public static function saveSecure(array $data)
|
||||
{
|
||||
self::ensureColumnsExist();
|
||||
|
||||
if (!empty($data['gemini_api_key'])) {
|
||||
$data['gemini_api_key'] = Security::encrypt($data['gemini_api_key']);
|
||||
}
|
||||
|
||||
if (isset($data['id'])) {
|
||||
$id = $data['id'];
|
||||
unset($data['id']);
|
||||
self::update($id, $data);
|
||||
return $id;
|
||||
} else {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run ALTER TABLE to make sure gemini_api_key exists in chatbot_rules
|
||||
*/
|
||||
private static function ensureColumnsExist()
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
try {
|
||||
// Check if column exists
|
||||
$columns = Database::select("SHOW COLUMNS FROM " . static::$table . " LIKE 'gemini_api_key'");
|
||||
if (empty($columns)) {
|
||||
Database::execute("ALTER TABLE " . static::$table . " ADD COLUMN gemini_api_key VARCHAR(512) DEFAULT NULL AFTER ai_prompt");
|
||||
}
|
||||
$checked = true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to ensure chatbot_rules column: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
51
backend/app/Services/GeminiService.php
Normal file
51
backend/app/Services/GeminiService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class GeminiService
|
||||
{
|
||||
/**
|
||||
* Call Gemini API to generate a response
|
||||
*/
|
||||
public static function generateResponse(string $apiKey, string $systemPrompt, string $userMessage): ?string
|
||||
{
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=' . $apiKey;
|
||||
|
||||
$payload = json_encode([
|
||||
'contents' => [
|
||||
[
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="app()" x-init="checkAuth()">
|
||||
@@ -651,6 +722,9 @@
|
||||
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'campaigns' }" @click="activeDashboardTab = 'campaigns'; fetchCampaigns()" id="nav-campaigns-btn">
|
||||
<span>📣</span> Marketing Campaigns
|
||||
</button>
|
||||
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'chatbot' }" @click="activeDashboardTab = 'chatbot'; fetchChatbotSettings()" id="nav-chatbot-btn">
|
||||
<span>🤖</span> AI Chatbot Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right Dashboard Panels -->
|
||||
@@ -806,7 +880,7 @@
|
||||
<div class="panel" x-show="activeDashboardTab === 'templates'" id="panel-templates">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h2 style="font-size: 1.4rem;">Templates</h2>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 0.5rem 1rem; font-size: 0.85rem;" id="btn-add-template">+ New Template</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 0.5rem 1rem; font-size: 0.85rem;" @click="openNewTemplateModal()" id="btn-add-template">+ New Template</button>
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
@@ -840,7 +914,7 @@
|
||||
<div class="panel" x-show="activeDashboardTab === 'campaigns'" id="panel-campaigns">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h2 style="font-size: 1.4rem;">Campaigns</h2>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 0.5rem 1rem; font-size: 0.85rem;" id="btn-add-campaign">+ Launch Campaign</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 0.5rem 1rem; font-size: 0.85rem;" @click="openLaunchCampaignModal()" id="btn-add-campaign">+ Launch Campaign</button>
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
@@ -872,8 +946,189 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel: AI Chatbot Settings -->
|
||||
<div class="panel" x-show="activeDashboardTab === 'chatbot'" id="panel-chatbot">
|
||||
<h2 style="font-size: 1.4rem; margin-bottom: 1.5rem;">AI Chatbot & Auto-Reply Settings</h2>
|
||||
|
||||
<form @submit.prevent="saveChatbotSettings()" id="chatbot-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Enable Chatbot</label>
|
||||
<select class="form-input" x-model="chatbotSettings.is_active" id="chatbot-active-select">
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Auto-Reply Type</label>
|
||||
<select class="form-input" x-model="chatbotSettings.trigger_type" id="chatbot-trigger-type-select">
|
||||
<option value="keyword">Keyword-Match (Static reply)</option>
|
||||
<option value="gemini_ai">Gemini AI (Dynamic conversational responder)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" x-show="chatbotSettings.trigger_type === 'keyword'" id="chatbot-keyword-group">
|
||||
<label class="form-label">Trigger Keywords (Comma separated)</label>
|
||||
<input type="text" class="form-input" x-model="chatbotSettings.keyword" placeholder="hello, price, discount, support" id="chatbot-keyword-input">
|
||||
</div>
|
||||
|
||||
<div class="form-group" x-show="chatbotSettings.trigger_type === 'gemini_ai'" id="chatbot-api-key-group">
|
||||
<label class="form-label">Google Gemini API Key</label>
|
||||
<input type="password" class="form-input" x-model="chatbotSettings.gemini_api_key" placeholder="••••••••" id="chatbot-api-key-input">
|
||||
<span style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-top: 0.25rem;">
|
||||
Leave empty to use the system default API key configured in .env.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="chatbot-prompt-group">
|
||||
<label class="form-label" x-text="chatbotSettings.trigger_type === 'gemini_ai' ? 'System Instruction Prompt' : 'Predefined Auto-Reply Message'"></label>
|
||||
<textarea class="form-input" x-model="chatbotSettings.ai_prompt" rows="5" required :placeholder="chatbotSettings.trigger_type === 'gemini_ai' ? 'You are a helpful customer support assistant... Respond concisely and politely in Arabic.' : 'Thank you for reaching out!'" id="chatbot-prompt-input"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" :disabled="actionLoading" id="chatbot-save-btn">
|
||||
<span x-show="!actionLoading">Save Settings</span>
|
||||
<span x-show="actionLoading" class="spinner"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Add Contact -->
|
||||
<div class="modal-overlay" x-show="showAddContactModal" id="modal-add-contact" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Add New Contact</h3>
|
||||
<button class="modal-close" @click="showAddContactModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitAddContact()" id="form-add-contact">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-input" x-model="contactName" required placeholder="John Doe" id="add-contact-name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Phone Number (with Country Code)</label>
|
||||
<input type="text" class="form-input" x-model="contactPhone" required placeholder="966500000000" id="add-contact-phone">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-input" x-model="contactEmail" placeholder="john@example.com" id="add-contact-email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-input" x-model="contactNotes" placeholder="Additional details..." id="add-contact-notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="width: auto;" @click="showAddContactModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" style="width: auto;" :disabled="actionLoading">
|
||||
<span x-show="!actionLoading">Create Contact</span>
|
||||
<span x-show="actionLoading" class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: New Template -->
|
||||
<div class="modal-overlay" x-show="showNewTemplateModal" id="modal-new-template" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Create Message Template</h3>
|
||||
<button class="modal-close" @click="showNewTemplateModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitNewTemplate()" id="form-new-template">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Template Name</label>
|
||||
<input type="text" class="form-input" x-model="templateName" required placeholder="welcome_message" id="new-template-name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Message Body</label>
|
||||
<textarea class="form-input" x-model="templateBody" rows="4" required placeholder="Hello {{name}}, welcome to Nabeh!" id="new-template-body"></textarea>
|
||||
<span style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-top: 0.25rem;">
|
||||
Use {{name}} to personalize with the contact's name.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Template Type</label>
|
||||
<select class="form-input" x-model="templateType" id="new-template-type">
|
||||
<option value="text">Text Only</option>
|
||||
<option value="image">Image Attachment</option>
|
||||
<option value="video">Video Attachment</option>
|
||||
<option value="document">Document Attachment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" x-show="templateType !== 'text'" id="new-template-media-group">
|
||||
<label class="form-label">Media URL</label>
|
||||
<input type="url" class="form-input" x-model="templateMediaUrl" placeholder="https://example.com/image.jpg" id="new-template-media-url">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="width: auto;" @click="showNewTemplateModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" style="width: auto;" :disabled="actionLoading">
|
||||
<span x-show="!actionLoading">Create Template</span>
|
||||
<span x-show="actionLoading" class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Launch Campaign -->
|
||||
<div class="modal-overlay" x-show="showLaunchCampaignModal" id="modal-launch-campaign" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Launch Marketing Campaign</h3>
|
||||
<button class="modal-close" @click="showLaunchCampaignModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitLaunchCampaign()" id="form-launch-campaign">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Campaign Name</label>
|
||||
<input type="text" class="form-input" x-model="campaignName" required placeholder="Ramadan Campaign 2026" id="launch-campaign-name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Target Group</label>
|
||||
<select class="form-input" x-model="campaignGroupId" required id="launch-campaign-group">
|
||||
<option value="">-- Select Contact Group --</option>
|
||||
<template x-for="grp in groups" :key="grp.id">
|
||||
<option :value="grp.id" x-text="grp.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">WhatsApp Session Sender</label>
|
||||
<select class="form-input" x-model="campaignSessionId" required id="launch-campaign-session">
|
||||
<option value="">-- Select Sender Number --</option>
|
||||
<template x-if="whatsappSession">
|
||||
<option :value="whatsappSession.id" x-text="whatsappSession.name + ' (' + (whatsappSession.phone || 'Connected') + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Message Template</label>
|
||||
<select class="form-input" x-model="campaignTemplateId" required id="launch-campaign-template">
|
||||
<option value="">-- Select Template --</option>
|
||||
<template x-for="tpl in templates" :key="tpl.id">
|
||||
<option :value="tpl.id" x-text="tpl.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="width: auto;" @click="showLaunchCampaignModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" style="width: auto;" :disabled="actionLoading">
|
||||
<span x-show="!actionLoading">Launch Broadcast</span>
|
||||
<span x-show="actionLoading" class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -63,5 +63,8 @@ $router->post('/api/templates', [\App\Controllers\TemplateController::class,
|
||||
$router->get('/api/campaigns', [\App\Controllers\CampaignController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/campaigns', [\App\Controllers\CampaignController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
$router->get('/api/chatbot/rules', [\App\Controllers\ChatbotController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/chatbot/rules',[\App\Controllers\ChatbotController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
// 4. Dispatch the request
|
||||
$router->dispatch($request, $response);
|
||||
|
||||
@@ -229,7 +229,34 @@ async function disconnectSession(session_key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message using an active session
|
||||
*/
|
||||
async function sendMessage(session_key, phone, message, mediaUrl = null) {
|
||||
const sock = sessions.get(session_key);
|
||||
if (!sock) {
|
||||
throw new Error(`Session ${session_key} is not active or connected`);
|
||||
}
|
||||
|
||||
let jid = phone.includes('@') ? phone : `${phone}@s.whatsapp.net`;
|
||||
|
||||
if (mediaUrl) {
|
||||
const ext = mediaUrl.split('.').pop().toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png', 'webp'].includes(ext)) {
|
||||
return await sock.sendMessage(jid, { image: { url: mediaUrl }, caption: message });
|
||||
} else if (['mp4', 'mkv', 'avi'].includes(ext)) {
|
||||
return await sock.sendMessage(jid, { video: { url: mediaUrl }, caption: message });
|
||||
} else {
|
||||
return await sock.sendMessage(jid, { document: { url: mediaUrl }, caption: message, fileName: mediaUrl.split('/').pop() });
|
||||
}
|
||||
}
|
||||
|
||||
return await sock.sendMessage(jid, { text: message });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startSession,
|
||||
disconnectSession
|
||||
disconnectSession,
|
||||
sendMessage
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ for (const p of envPaths) {
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { startSession, disconnectSession } = require('./baileys-client');
|
||||
const { startSession, disconnectSession, sendMessage } = require('./baileys-client');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -76,6 +76,23 @@ app.post('/api/sessions/disconnect', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Send outbound message
|
||||
app.post('/api/messages/send', async (req, res) => {
|
||||
const { session_key, phone, message, media_url } = req.body;
|
||||
|
||||
if (!session_key || !phone || !message) {
|
||||
return res.status(400).json({ error: 'Missing session_key, phone, or message' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendMessage(session_key, phone, message, media_url);
|
||||
res.json({ status: 'success', data: result });
|
||||
} catch (err) {
|
||||
console.error(`Error sending message via ${session_key} to ${phone}:`, err);
|
||||
res.status(500).json({ error: err.message || 'Failed to send message' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Nabeh WhatsApp Gateway running on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user