Commit 8ec37422 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Refactor WhatsApp: instances call manager hub instead of Meta directly

Instances no longer hold WhatsApp API credentials. They send messages
via the el-captain-manager messaging hub API using MESSAGING_HUB_URL
and MESSAGING_HUB_KEY env vars. The hub handles Meta Graph API calls,
rate limiting, and delivery tracking centrally.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent effca1ea
...@@ -10,17 +10,13 @@ ...@@ -10,17 +10,13 @@
class WhatsAppService class WhatsAppService
{ {
private string $apiVersion; private string $hubUrl;
private string $phoneNumberId; private string $hubKey;
private string $accessToken;
private string $baseUrl;
public function __construct() public function __construct()
{ {
$this->apiVersion = (string) (config('services.whatsapp.api_version') ?? 'v25.0'); $this->hubUrl = rtrim((string) (config('services.messaging_hub.url') ?? ''), '/');
$this->phoneNumberId = (string) (config('services.whatsapp.phone_number_id') ?? ''); $this->hubKey = (string) (config('services.messaging_hub.key') ?? '');
$this->accessToken = (string) (config('services.whatsapp.access_token') ?? '');
$this->baseUrl = "https://graph.facebook.com/{$this->apiVersion}/{$this->phoneNumberId}";
} }
public function sendTemplate( public function sendTemplate(
...@@ -31,30 +27,19 @@ public function sendTemplate( ...@@ -31,30 +27,19 @@ public function sendTemplate(
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
): WhatsAppMessage { ): WhatsAppMessage {
$to = $this->formatPhone($to); $response = $this->sendToHub([
'phone' => $to,
$payload = [ 'message_type' => 'template',
'messaging_product' => 'whatsapp', 'template_name' => $templateName,
'to' => $to, 'template_language' => $languageCode,
'type' => 'template', 'components' => $components,
'template' => [ ]);
'name' => $templateName,
'language' => ['code' => $languageCode],
],
];
if (!empty($components)) {
$payload['template']['components'] = $components;
}
$response = $this->sendRequest($payload);
return $this->logMessage( return $this->logMessage(
academyId: $academyId, academyId: $academyId,
to: $to, to: $to,
type: MessageType::Template, type: MessageType::Template,
content: $templateName, content: $templateName,
payload: $payload,
response: $response, response: $response,
senderId: $senderId, senderId: $senderId,
); );
...@@ -65,28 +50,18 @@ public function sendText( ...@@ -65,28 +50,18 @@ public function sendText(
string $body, string $body,
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
bool $previewUrl = false,
): WhatsAppMessage { ): WhatsAppMessage {
$to = $this->formatPhone($to); $response = $this->sendToHub([
'phone' => $to,
$payload = [ 'message_type' => 'text',
'messaging_product' => 'whatsapp', 'content' => $body,
'to' => $to, ]);
'type' => 'text',
'text' => [
'preview_url' => $previewUrl,
'body' => $body,
],
];
$response = $this->sendRequest($payload);
return $this->logMessage( return $this->logMessage(
academyId: $academyId, academyId: $academyId,
to: $to, to: $to,
type: MessageType::Text, type: MessageType::Text,
content: $body, content: $body,
payload: $payload,
response: $response, response: $response,
senderId: $senderId, senderId: $senderId,
); );
...@@ -99,29 +74,18 @@ public function sendImage( ...@@ -99,29 +74,18 @@ public function sendImage(
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
): WhatsAppMessage { ): WhatsAppMessage {
$to = $this->formatPhone($to); $response = $this->sendToHub([
'phone' => $to,
$payload = [ 'message_type' => 'image',
'messaging_product' => 'whatsapp', 'media_url' => $imageUrl,
'to' => $to, 'content' => $caption,
'type' => 'image', ]);
'image' => [
'link' => $imageUrl,
],
];
if ($caption) {
$payload['image']['caption'] = $caption;
}
$response = $this->sendRequest($payload);
return $this->logMessage( return $this->logMessage(
academyId: $academyId, academyId: $academyId,
to: $to, to: $to,
type: MessageType::Image, type: MessageType::Image,
content: $caption ?? $imageUrl, content: $caption ?? $imageUrl,
payload: $payload,
response: $response, response: $response,
senderId: $senderId, senderId: $senderId,
); );
...@@ -135,42 +99,26 @@ public function sendDocument( ...@@ -135,42 +99,26 @@ public function sendDocument(
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
): WhatsAppMessage { ): WhatsAppMessage {
$to = $this->formatPhone($to); $response = $this->sendToHub([
'phone' => $to,
$payload = [ 'message_type' => 'document',
'messaging_product' => 'whatsapp', 'media_url' => $documentUrl,
'to' => $to, 'filename' => $filename,
'type' => 'document', ]);
'document' => [
'link' => $documentUrl,
],
];
if ($filename) {
$payload['document']['filename'] = $filename;
}
if ($caption) {
$payload['document']['caption'] = $caption;
}
$response = $this->sendRequest($payload);
return $this->logMessage( return $this->logMessage(
academyId: $academyId, academyId: $academyId,
to: $to, to: $to,
type: MessageType::Document, type: MessageType::Document,
content: $caption ?? $filename ?? $documentUrl, content: $caption ?? $filename ?? $documentUrl,
payload: $payload,
response: $response, response: $response,
senderId: $senderId, senderId: $senderId,
); );
} }
public function sendBulkTemplate( public function sendBulkText(
array $recipients, array $recipients,
string $templateName, string $body,
string $languageCode = 'en_US',
array $components = [],
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
): array { ): array {
...@@ -178,7 +126,7 @@ public function sendBulkTemplate( ...@@ -178,7 +126,7 @@ public function sendBulkTemplate(
foreach ($recipients as $phone) { foreach ($recipients as $phone) {
try { try {
$msg = $this->sendTemplate($phone, $templateName, $languageCode, $components, $academyId, $senderId); $msg = $this->sendText($phone, $body, $academyId, $senderId);
if ($msg->status === MessageStatus::Failed) { if ($msg->status === MessageStatus::Failed) {
$results['failed']++; $results['failed']++;
} else { } else {
...@@ -187,16 +135,18 @@ public function sendBulkTemplate( ...@@ -187,16 +135,18 @@ public function sendBulkTemplate(
$results['messages'][] = $msg; $results['messages'][] = $msg;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$results['failed']++; $results['failed']++;
Log::warning("WhatsApp bulk send failed for {$phone}: {$e->getMessage()}"); Log::warning("Messaging hub bulk send failed for {$phone}: {$e->getMessage()}");
} }
} }
return $results; return $results;
} }
public function sendBulkText( public function sendBulkTemplate(
array $recipients, array $recipients,
string $body, string $templateName,
string $languageCode = 'en_US',
array $components = [],
?int $academyId = null, ?int $academyId = null,
?int $senderId = null, ?int $senderId = null,
): array { ): array {
...@@ -204,7 +154,7 @@ public function sendBulkText( ...@@ -204,7 +154,7 @@ public function sendBulkText(
foreach ($recipients as $phone) { foreach ($recipients as $phone) {
try { try {
$msg = $this->sendText($phone, $body, $academyId, $senderId); $msg = $this->sendTemplate($phone, $templateName, $languageCode, $components, $academyId, $senderId);
if ($msg->status === MessageStatus::Failed) { if ($msg->status === MessageStatus::Failed) {
$results['failed']++; $results['failed']++;
} else { } else {
...@@ -213,7 +163,7 @@ public function sendBulkText( ...@@ -213,7 +163,7 @@ public function sendBulkText(
$results['messages'][] = $msg; $results['messages'][] = $msg;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$results['failed']++; $results['failed']++;
Log::warning("WhatsApp bulk text failed for {$phone}: {$e->getMessage()}"); Log::warning("Messaging hub bulk template failed for {$phone}: {$e->getMessage()}");
} }
} }
...@@ -222,30 +172,25 @@ public function sendBulkText( ...@@ -222,30 +172,25 @@ public function sendBulkText(
public function isConfigured(): bool public function isConfigured(): bool
{ {
return !empty($this->phoneNumberId) && !empty($this->accessToken); return !empty($this->hubUrl) && !empty($this->hubKey);
} }
private function sendRequest(array $payload): array private function sendToHub(array $data): array
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
return ['error' => ['message' => 'WhatsApp API not configured']]; return ['success' => false, 'error' => 'Messaging hub not configured'];
} }
$response = Http::withToken($this->accessToken) try {
->timeout(30) $response = Http::timeout(30)
->post("{$this->baseUrl}/messages", $payload); ->withHeaders(['X-Messaging-Key' => $this->hubKey])
->post("{$this->hubUrl}/api/messaging/send", $data);
$data = $response->json();
if (!$response->successful()) { return $response->json() ?? ['success' => false, 'error' => 'Empty response'];
Log::error('WhatsApp API error', [ } catch (\Throwable $e) {
'status' => $response->status(), Log::error('Messaging hub request failed', ['error' => $e->getMessage()]);
'body' => $data, return ['success' => false, 'error' => $e->getMessage()];
'payload_to' => $payload['to'] ?? null,
]);
} }
return $data ?? [];
} }
private function logMessage( private function logMessage(
...@@ -253,44 +198,26 @@ private function logMessage( ...@@ -253,44 +198,26 @@ private function logMessage(
string $to, string $to,
MessageType $type, MessageType $type,
string $content, string $content,
array $payload,
array $response, array $response,
?int $senderId, ?int $senderId,
): WhatsAppMessage { ): WhatsAppMessage {
$waMessageId = $response['messages'][0]['id'] ?? null; $success = $response['success'] ?? false;
$status = $waMessageId ? MessageStatus::Sent : MessageStatus::Failed; $status = $success ? MessageStatus::Sent : MessageStatus::Failed;
$errorMessage = null; $externalId = $response['message_id'] ?? null;
$errorMessage = $success ? null : ($response['error'] ?? 'Unknown error');
if (!$waMessageId) {
$errorMessage = $response['error']['message']
?? $response['error']['error_data']['details'] ?? 'Unknown error';
}
return WhatsAppMessage::create([ return WhatsAppMessage::create([
'academy_id' => $academyId ?? (int) app('current_academy')?->id, 'academy_id' => $academyId ?? (int) app('current_academy')?->id,
'wa_message_id' => $waMessageId, 'wa_message_id' => $externalId,
'phone_number' => $to, 'phone_number' => $to,
'type' => $type->value, 'type' => $type->value,
'content' => mb_substr($content, 0, 2000), 'content' => mb_substr($content, 0, 2000),
'status' => $status->value, 'status' => $status->value,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
'payload' => $payload, 'payload' => $response,
'response' => $response, 'response' => $response,
'sent_by' => $senderId ?? auth()->id(), 'sent_by' => $senderId ?? auth()->id(),
'sent_at' => $waMessageId ? now() : null, 'sent_at' => $success ? now() : null,
]); ]);
} }
private function formatPhone(string $phone): string
{
$phone = preg_replace('/[\s\-\(\)\+]/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '20' . substr($phone, 1);
} elseif (strlen($phone) === 10) {
$phone = '20' . $phone;
}
return $phone;
}
} }
...@@ -42,4 +42,9 @@ ...@@ -42,4 +42,9 @@
'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN', ''), 'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN', ''),
], ],
'messaging_hub' => [
'url' => env('MESSAGING_HUB_URL', ''),
'key' => env('MESSAGING_HUB_KEY', ''),
],
]; ];
...@@ -17,7 +17,7 @@ chmod -R 775 storage bootstrap/cache ...@@ -17,7 +17,7 @@ chmod -R 775 storage bootstrap/cache
# Build .env from Docker env vars (CapRover passes them as container env) # Build .env from Docker env vars (CapRover passes them as container env)
: > .env : > .env
env | grep -E "^(APP_|DB_|LOG_|SESSION_|CACHE_|QUEUE_|FILESYSTEM_|BROADCAST_|MAIL_|TRUSTED_|BCRYPT_|PLATFORM_|ADMIN_|ACADEMY_|RUN_|WHATSAPP_)" | sort | sed 's/=\(.*\)/="\1"/' >> .env env | grep -E "^(APP_|DB_|LOG_|SESSION_|CACHE_|QUEUE_|FILESYSTEM_|BROADCAST_|MAIL_|TRUSTED_|BCRYPT_|PLATFORM_|ADMIN_|ACADEMY_|RUN_|WHATSAPP_|MESSAGING_)" | sort | sed 's/=\(.*\)/="\1"/' >> .env
# Ensure APP_KEY line exists (key:generate needs it to write to) # Ensure APP_KEY line exists (key:generate needs it to write to)
if ! grep -q '^APP_KEY=' .env 2>/dev/null; then if ! grep -q '^APP_KEY=' .env 2>/dev/null; then
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment