apiKey = $this->config['dialog360_api_key'] ?? ''; $this->phoneNumberId = $this->config['dialog360_phone_number_id'] ?? ''; $this->phoneNumber = $this->config['dialog360_phone_number'] ?? ''; $this->webhookVerifyToken = $this->config['dialog360_webhook_verify_token'] ?? ''; $this->appSecret = $this->config['dialog360_app_secret'] ?? ''; // Allow custom API base URL (for on-premise deployments) if (!empty($this->config['dialog360_api_base_url'])) { $this->apiBaseUrl = rtrim($this->config['dialog360_api_base_url'], '/') . '/'; } } /** * @inheritDoc */ protected function initClient(): void { if (empty($this->apiKey)) { return; } $this->client = new Client([ 'base_uri' => $this->apiBaseUrl, 'timeout' => $this->timeout, 'http_errors' => false, 'headers' => [ 'D360-API-KEY' => $this->apiKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ] ]); } /** * @inheritDoc */ public function isConfigured(): bool { if (!$this->configModel) { return false; } if (empty($this->config['enabled'])) { return false; } return !empty($this->apiKey) && !empty($this->phoneNumber); } /** * @inheritDoc */ public function testConnection(): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { // Test by fetching templates $response = $this->client->get('configs/templates', [ 'query' => ['limit' => 1] ]); $statusCode = $response->getStatusCode(); if ($statusCode === 200) { return $this->successResponse(__eva('Connection successful')); } if ($statusCode === 401) { return $this->errorResponse(__eva('Invalid 360dialog API Key')); } $body = json_decode($response->getBody()->getContents(), true); return $this->errorResponse( $body['error']['message'] ?? $body['meta']['developer_message'] ?? __eva('Unknown error'), $body ); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * @inheritDoc */ public function sendMessage(SendMessageRequest $request): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } $toPhone = $this->normalizePhone($request->toPhone); try { $messageData = [ 'messaging_product' => 'whatsapp', 'recipient_type' => 'individual', 'to' => $toPhone, 'type' => 'text', 'text' => [ 'preview_url' => $request->previewUrl, 'body' => $request->body, ] ]; // If media URL provided, send as media message if ($request->mediaUrl) { $messageData['type'] = 'image'; unset($messageData['text']); $messageData['image'] = [ 'link' => $request->mediaUrl, 'caption' => $request->body, ]; } $response = $this->client->post('messages', [ 'json' => $messageData ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { $messageId = $body['messages'][0]['id'] ?? null; return $this->successResponse(__eva('Message sent successfully'), [ 'message_id' => $messageId, 'status' => 'sent', 'date_created' => current_time('mysql'), ]); } $errorMessage = $body['error']['message'] ?? $body['meta']['developer_message'] ?? __eva('Failed to send message'); return $this->errorResponse($errorMessage, [ 'error_code' => $body['error']['code'] ?? null, 'error_message' => $errorMessage, ]); } catch (GuzzleException $e) { $this->logError('Send message failed', ['error' => $e->getMessage()]); return $this->errorResponse($e->getMessage()); } } /** * @inheritDoc */ public function sendTemplate(SendTemplateRequest $request): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } $toPhone = $this->normalizePhone($request->toPhone); try { $templateData = [ 'messaging_product' => 'whatsapp', 'recipient_type' => 'individual', 'to' => $toPhone, 'type' => 'template', 'template' => [ 'name' => $request->templateId, // 360dialog uses template name 'language' => [ 'code' => $this->normalizeLanguageCode($request->language), ], ] ]; // Format components $components = $this->formatTemplateComponents($request); if (!empty($components)) { $templateData['template']['components'] = $components; } $response = $this->client->post('messages', [ 'json' => $templateData ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { $messageId = $body['messages'][0]['id'] ?? null; return $this->successResponse(__eva('Template message sent successfully'), [ 'message_id' => $messageId, 'status' => 'sent', 'date_created' => current_time('mysql'), ]); } $errorMessage = $body['error']['message'] ?? $body['meta']['developer_message'] ?? __eva('Failed to send template message'); return $this->errorResponse($errorMessage, [ 'error_code' => $body['error']['code'] ?? null, 'error_message' => $errorMessage, ]); } catch (GuzzleException $e) { $this->logError('Send template failed', ['error' => $e->getMessage()]); return $this->errorResponse($e->getMessage()); } } /** * Format template components for 360dialog/Cloud API * * @param SendTemplateRequest $request * @return array */ protected function formatTemplateComponents(SendTemplateRequest $request): array { $components = []; // Header parameters if (!empty($request->headerParams)) { $headerComponent = [ 'type' => 'header', 'parameters' => [], ]; foreach ($request->headerParams as $param) { if (is_array($param)) { $headerComponent['parameters'][] = $param; } elseif (filter_var($param, FILTER_VALIDATE_URL)) { // Assume it's an image/document URL $headerComponent['parameters'][] = [ 'type' => 'image', 'image' => ['link' => $param], ]; } else { $headerComponent['parameters'][] = [ 'type' => 'text', 'text' => $param, ]; } } if (!empty($headerComponent['parameters'])) { $components[] = $headerComponent; } } // Body parameters $variables = $request->variables; // Convert booking data format to Cloud API format if (isset($variables['customer_name']) || isset($variables['date'])) { $bodyParams = []; $mappings = [ 'customer_name', 'restaurant_name' => $this->restaurantName, 'date' => isset($variables['date']) ? $this->formatDate($variables['date']) : '', 'time' => isset($variables['time']) ? $this->formatTime($variables['time']) : '', 'party', 'restaurant_phone' => $this->restaurantPhone, ]; foreach ($mappings as $key => $default) { if (is_int($key)) { $key = $default; $default = ''; } $value = $variables[$key] ?? $default; if (!empty($value)) { $bodyParams[] = [ 'type' => 'text', 'text' => (string)$value, ]; } } if (!empty($bodyParams)) { $components[] = [ 'type' => 'body', 'parameters' => $bodyParams, ]; } } elseif (!empty($variables)) { // Generic variables $bodyParams = []; foreach ($variables as $value) { if (is_array($value)) { $bodyParams[] = $value; } else { $bodyParams[] = [ 'type' => 'text', 'text' => (string)$value, ]; } } if (!empty($bodyParams)) { $components[] = [ 'type' => 'body', 'parameters' => $bodyParams, ]; } } // Button parameters if (!empty($request->buttonParams)) { foreach ($request->buttonParams as $index => $param) { $buttonComponent = [ 'type' => 'button', 'sub_type' => $param['sub_type'] ?? 'quick_reply', 'index' => $param['index'] ?? $index, 'parameters' => [], ]; if (isset($param['payload'])) { $buttonComponent['parameters'][] = [ 'type' => 'payload', 'payload' => $param['payload'], ]; } if (isset($param['text'])) { $buttonComponent['parameters'][] = [ 'type' => 'text', 'text' => $param['text'], ]; } $components[] = $buttonComponent; } } return $components; } /** * Normalize language code for Cloud API * * @param string $language * @return string */ protected function normalizeLanguageCode(string $language): string { // Cloud API uses codes like 'en_US', 'es_ES', etc. // Handle simple codes like 'en' -> 'en_US' $mappings = [ 'en' => 'en_US', 'es' => 'es_ES', 'fr' => 'fr_FR', 'de' => 'de_DE', 'it' => 'it_IT', 'pt' => 'pt_BR', ]; return $mappings[$language] ?? $language; } /** * @inheritDoc */ public function getTemplates(int $pageSize = 50, ?string $cursor = null): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $query = ['limit' => min($pageSize, 100)]; if ($cursor) { $query['offset'] = $cursor; } $response = $this->client->get('configs/templates', [ 'query' => $query ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['error']['message'] ?? __eva('Failed to fetch templates'), $body ); } $templates = []; $wabaTemplates = $body['waba_templates'] ?? $body['data'] ?? []; foreach ($wabaTemplates as $wabaTemplate) { $template = $this->normalizeTemplate($wabaTemplate); $templates[] = $template->toArray(); } // Calculate next cursor $nextCursor = null; $total = $body['total'] ?? $body['count'] ?? count($templates); $currentOffset = (int)($cursor ?? 0); if ($currentOffset + count($templates) < $total) { $nextCursor = (string)($currentOffset + count($templates)); } return $this->successResponse(__eva('Templates fetched successfully'), [ 'templates' => $templates, 'total' => $total, 'meta' => [ 'next_page_url' => $nextCursor, 'previous_page_url' => $currentOffset > 0 ? (string)max(0, $currentOffset - $pageSize) : null, ], ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * @inheritDoc */ public function getTemplate(string $templateId): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->client->get('configs/templates', [ 'query' => ['name' => $templateId] ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['error']['message'] ?? __eva('Failed to fetch template'), $body ); } $wabaTemplates = $body['waba_templates'] ?? $body['data'] ?? []; if (empty($wabaTemplates)) { return $this->errorResponse(__eva('Template not found')); } $template = $this->normalizeTemplate($wabaTemplates[0]); return $this->successResponse(__eva('Template fetched successfully'), [ 'template' => $template->toArray(), ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * Normalize 360dialog template to NormalizedTemplate * * @param array $wabaTemplate * @return NormalizedTemplate */ protected function normalizeTemplate(array $wabaTemplate): NormalizedTemplate { $template = new NormalizedTemplate( $wabaTemplate['name'], // 360dialog uses name as ID $wabaTemplate['name'], self::PROVIDER_NAME ); $template->language = $wabaTemplate['language'] ?? null; $template->category = $wabaTemplate['category'] ?? null; $template->rawData = $wabaTemplate; // Map status $status = strtolower($wabaTemplate['status'] ?? ''); $statusMap = [ 'approved' => NormalizedTemplate::STATUS_APPROVED, 'pending' => NormalizedTemplate::STATUS_PENDING, 'rejected' => NormalizedTemplate::STATUS_REJECTED, 'disabled' => NormalizedTemplate::STATUS_DISABLED, ]; $template->approvalStatus = $statusMap[$status] ?? NormalizedTemplate::STATUS_UNKNOWN; $template->rejectionReason = $wabaTemplate['rejected_reason'] ?? null; // Parse components $components = $wabaTemplate['components'] ?? []; $buttons = []; foreach ($components as $component) { $componentType = strtolower($component['type'] ?? ''); switch ($componentType) { case 'header': $format = strtolower($component['format'] ?? 'text'); $template->headerType = $format; if ($format === 'text') { $template->header = $component['text'] ?? null; } elseif (in_array($format, ['image', 'video', 'document'])) { $template->type = NormalizedTemplate::TYPE_MEDIA; $example = $component['example']['header_handle'][0] ?? null; $template->mediaUrl = $example; } break; case 'body': $template->body = $component['text'] ?? ''; // Extract variables from body if (preg_match_all('/\{\{(\d+)\}\}/', $template->body, $matches)) { $template->variables = array_map(fn($n) => "{{$n}}", array_unique($matches[1])); } break; case 'footer': $template->footer = $component['text'] ?? null; break; case 'buttons': $buttonList = $component['buttons'] ?? []; foreach ($buttonList as $btn) { $buttons[] = [ 'type' => $btn['type'] ?? 'unknown', 'text' => $btn['text'] ?? '', 'url' => $btn['url'] ?? null, 'payload' => $btn['payload'] ?? null, 'phone' => $btn['phone_number'] ?? null, ]; } break; } } $template->buttons = $buttons; // Determine type if (!empty($buttons)) { $buttonTypes = array_column($buttons, 'type'); if (in_array('QUICK_REPLY', $buttonTypes)) { $template->type = NormalizedTemplate::TYPE_QUICK_REPLY; } elseif (in_array('URL', $buttonTypes) || in_array('PHONE_NUMBER', $buttonTypes)) { $template->type = NormalizedTemplate::TYPE_CALL_TO_ACTION; } } return $template; } /** * @inheritDoc */ public function getMessageStatus(string $messageId): array { // 360dialog doesn't have a direct message status endpoint // Status is delivered via webhooks return $this->errorResponse( __eva('Message status must be tracked via webhooks for 360dialog'), ['message_id' => $messageId] ); } /** * @inheritDoc */ public function parseIncomingWebhook(array $payload): NormalizedMessage { $message = new NormalizedMessage(self::PROVIDER_NAME); // 360dialog/Cloud API webhook structure // payload -> entry[0] -> changes[0] -> value -> messages[0] $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $messageData = $value['messages'][0] ?? []; $contacts = $value['contacts'][0] ?? []; $metadata = $value['metadata'] ?? []; // Phone numbers $message->fromPhone = $messageData['from'] ?? ''; $message->toPhone = $metadata['display_phone_number'] ?? $this->phoneNumber; // Message identifiers $message->messageId = $messageData['id'] ?? null; $message->accountId = $entry['id'] ?? null; // Sender info $message->profileName = $contacts['profile']['name'] ?? null; $message->waId = $contacts['wa_id'] ?? $message->fromPhone; // Message type and content $messageType = $messageData['type'] ?? 'text'; switch ($messageType) { case 'text': $message->body = $messageData['text']['body'] ?? ''; $message->type = NormalizedMessage::TYPE_TEXT; break; case 'image': case 'video': case 'audio': case 'document': case 'sticker': $mediaData = $messageData[$messageType] ?? []; $message->type = NormalizedMessage::TYPE_MEDIA; $message->body = $mediaData['caption'] ?? ''; $message->mediaCount = 1; $message->media = [[ 'id' => $mediaData['id'] ?? null, 'url' => null, // Need to fetch via media endpoint 'content_type' => $mediaData['mime_type'] ?? null, 'filename' => $mediaData['filename'] ?? null, ]]; break; case 'location': $locationData = $messageData['location'] ?? []; $message->type = NormalizedMessage::TYPE_LOCATION; $message->latitude = floatval($locationData['latitude'] ?? 0); $message->longitude = floatval($locationData['longitude'] ?? 0); $message->locationName = $locationData['name'] ?? null; $message->locationAddress = $locationData['address'] ?? null; break; case 'interactive': $interactive = $messageData['interactive'] ?? []; $interactiveType = $interactive['type'] ?? ''; $message->type = NormalizedMessage::TYPE_BUTTON_RESPONSE; $message->isButtonResponse = true; $message->interactiveType = $interactiveType; if ($interactiveType === 'button_reply') { $buttonReply = $interactive['button_reply'] ?? []; $message->buttonPayload = $buttonReply['id'] ?? null; $message->buttonText = $buttonReply['title'] ?? null; $message->body = $message->buttonText; } elseif ($interactiveType === 'list_reply') { $listReply = $interactive['list_reply'] ?? []; $message->listReplyId = $listReply['id'] ?? null; $message->listReplyTitle = $listReply['title'] ?? null; $message->buttonPayload = $message->listReplyId; $message->buttonText = $message->listReplyTitle; $message->body = $listReply['description'] ?? $message->listReplyTitle; } break; case 'button': // Button response (different from interactive) $buttonData = $messageData['button'] ?? []; $message->type = NormalizedMessage::TYPE_BUTTON_RESPONSE; $message->isButtonResponse = true; $message->buttonPayload = $buttonData['payload'] ?? null; $message->buttonText = $buttonData['text'] ?? null; $message->body = $message->buttonText; break; default: $message->type = NormalizedMessage::TYPE_UNKNOWN; $message->body = ''; } // Reply context $context = $messageData['context'] ?? []; if (!empty($context['id'])) { $message->originalMessageId = $context['id']; $message->isReply = true; } // Timestamp $timestamp = $messageData['timestamp'] ?? null; if ($timestamp) { $message->timestamp = date('Y-m-d H:i:s', (int)$timestamp); } // Raw payload $message->rawPayload = $payload; return $message; } /** * @inheritDoc */ public function parseStatusCallback(array $payload): NormalizedStatusUpdate { $status = new NormalizedStatusUpdate(self::PROVIDER_NAME); // 360dialog/Cloud API status structure // payload -> entry[0] -> changes[0] -> value -> statuses[0] $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $statusData = $value['statuses'][0] ?? []; $status->messageId = $statusData['id'] ?? null; $status->accountId = $entry['id'] ?? null; $status->rawStatus = $statusData['status'] ?? null; $status->status = NormalizedStatusUpdate::normalizeStatus( $status->rawStatus ?? '', self::PROVIDER_NAME ); $status->toPhone = $statusData['recipient_id'] ?? ''; // Conversation info $conversation = $statusData['conversation'] ?? []; $status->conversationId = $conversation['id'] ?? null; $status->pricingCategory = $conversation['origin']['type'] ?? null; // Error handling $errors = $statusData['errors'] ?? []; if (!empty($errors)) { $error = $errors[0]; $status->errorCode = (string)($error['code'] ?? ''); $status->errorMessage = $error['title'] ?? $error['message'] ?? null; } // Timestamp $timestamp = $statusData['timestamp'] ?? null; if ($timestamp) { $status->timestamp = date('Y-m-d H:i:s', (int)$timestamp); } $status->rawPayload = $payload; return $status; } /** * @inheritDoc */ public function validateWebhookSignature(string $signature, string $url, array $payload): bool { // 360dialog webhook verification // For the verification request (GET), check the verify token // For actual webhooks, validate the X-Hub-Signature-256 header if (empty($signature)) { return false; } // If this is a signature validation (POST request) // The signature is: sha256= if (str_starts_with($signature, 'sha256=')) { $expectedHash = substr($signature, 7); // We need the raw body for signature validation $rawBody = file_get_contents('php://input'); if ($rawBody === false) { $rawBody = json_encode($payload); } // If no app secret configured, skip validation if (empty($this->appSecret)) { return true; } $calculatedHash = hash_hmac('sha256', $rawBody, $this->appSecret); return hash_equals($calculatedHash, $expectedHash); } // For verify token (GET request verification) return $signature === $this->webhookVerifyToken; } /** * Handle webhook verification request (GET) * * @param array $queryParams * @return string|null Verification response or null if invalid */ public function handleWebhookVerification(array $queryParams): ?string { $mode = $queryParams['hub_mode'] ?? ''; $token = $queryParams['hub_verify_token'] ?? ''; $challenge = $queryParams['hub_challenge'] ?? ''; if ($mode === 'subscribe' && $token === $this->webhookVerifyToken) { return $challenge; } return null; } /** * @inheritDoc */ public function generateWebhookResponse(?string $replyMessage = null): string { // 360dialog expects a simple 200 OK with no body for acknowledgment // Reply messages should be sent via the API, not in webhook response return ''; } /** * @inheritDoc */ public function getWebhookResponseContentType(): string { return 'application/json'; } /** * @inheritDoc */ public function getIncomingWebhookUrl(): string { return $this->getWebhookBaseUrl() . '/wp-admin/admin-ajax.php?action=alexr_whatsapp_webhook&restaurant_id=' . $this->restaurantId; } /** * @inheritDoc */ public function getStatusCallbackUrl(): string { // 360dialog uses the same webhook URL for messages and status updates return $this->getIncomingWebhookUrl(); } /** * @inheritDoc */ public function getPhoneNumber(): string { return $this->phoneNumber; } /** * @inheritDoc */ public function getConfig(): array { return [ 'provider' => self::PROVIDER_NAME, 'enabled' => $this->isConfigured(), 'restaurant_name' => $this->restaurantName, 'restaurant_phone' => $this->restaurantPhone, 'phone_number' => $this->phoneNumber, 'phone_number_id' => $this->phoneNumberId, 'date_format' => $this->dateFormat, 'time_format' => $this->timeFormat, ]; } /** * @inheritDoc */ public function isStatusCallback(array $payload): bool { // Check for status updates in Cloud API format $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; return isset($value['statuses']) && !empty($value['statuses']); } /** * @inheritDoc */ public function isIncomingMessage(array $payload): bool { // Check for messages in Cloud API format $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; return isset($value['messages']) && !empty($value['messages']); } /** * Download media from 360dialog * * @param string $mediaId * @return array ['success' => bool, 'url' => string|null, 'data' => array] */ public function getMediaUrl(string $mediaId): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->client->get("media/{$mediaId}"); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['error']['message'] ?? __eva('Failed to get media URL'), $body ); } return $this->successResponse(__eva('Media URL retrieved'), [ 'url' => $body['url'] ?? null, 'mime_type' => $body['mime_type'] ?? null, 'sha256' => $body['sha256'] ?? null, 'file_size' => $body['file_size'] ?? null, ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * Mark message as read * * @param string $messageId * @return array */ public function markAsRead(string $messageId): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->client->post('messages', [ 'json' => [ 'messaging_product' => 'whatsapp', 'status' => 'read', 'message_id' => $messageId, ] ]); $statusCode = $response->getStatusCode(); if ($statusCode >= 200 && $statusCode < 300) { return $this->successResponse(__eva('Message marked as read')); } $body = json_decode($response->getBody()->getContents(), true); return $this->errorResponse( $body['error']['message'] ?? __eva('Failed to mark message as read'), $body ); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * Check if a phone number is registered on WhatsApp using the contacts API * * @param string $phone Phone number in international format (e.g. +34612345678) * @return array ['success' => bool, 'has_whatsapp' => bool|null, 'wa_id' => string|null] */ public function checkContact(string $phone): array { if (!$this->isConfigured()) { return ['success' => false, 'has_whatsapp' => null, 'wa_id' => null]; } $cleanPhone = '+' . $this->normalizePhone($phone); try { $response = $this->client->post('contacts', [ 'json' => [ 'blocking' => 'wait', 'contacts' => [$cleanPhone], 'force_check' => true, ] ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { $contacts = $body['contacts'] ?? []; if (!empty($contacts)) { $contact = $contacts[0]; $status = $contact['status'] ?? 'invalid'; $waId = $contact['wa_id'] ?? null; return [ 'success' => true, 'has_whatsapp' => ($status === 'valid'), 'wa_id' => $waId, ]; } } return ['success' => false, 'has_whatsapp' => null, 'wa_id' => null]; } catch (GuzzleException $e) { $this->logError('Check contact failed', ['error' => $e->getMessage()]); return ['success' => false, 'has_whatsapp' => null, 'wa_id' => null]; } } }