Phase 4: Support LID identity scheme and fix incoming message parsing

This commit is contained in:
Hamza-Ayed
2026-05-21 23:44:25 +03:00
parent 0a1752ce44
commit ec7c9ffc29
9 changed files with 790 additions and 20 deletions

View File

@@ -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]
);
}
}

View 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
]);
}
}

View File

@@ -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());
}
}
}

View 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());
}
}
}

View 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;
}
}