accountSid = $this->config['twilio_sid'] ?? ''; $this->authToken = $this->config['twilio_token'] ?? ''; $this->phoneNumber = $this->config['twilio_phone'] ?? ''; } /** * @inheritDoc */ protected function initClient(): void { if (empty($this->accountSid) || empty($this->authToken)) { return; } $authHeader = 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken); // Client for Messages API $this->client = new Client([ 'base_uri' => 'https://api.twilio.com/', 'timeout' => $this->timeout, 'http_errors' => false, 'headers' => [ 'Authorization' => $authHeader, 'Accept' => 'application/json', ] ]); // Client for Content API (Templates) $this->contentClient = new Client([ 'base_uri' => 'https://content.twilio.com/', 'timeout' => $this->timeout, 'http_errors' => false, 'headers' => [ 'Authorization' => $authHeader, 'Accept' => 'application/json', ] ]); } /** * @inheritDoc */ public function isConfigured(): bool { if (!$this->configModel) { return false; } if (empty($this->config['enabled'])) { return false; } return !empty($this->accountSid) && !empty($this->authToken) && !empty($this->phoneNumber); } /** * @inheritDoc */ public function testConnection(): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->contentClient->get('v1/Content', [ 'query' => ['PageSize' => 1] ]); $statusCode = $response->getStatusCode(); if ($statusCode === 200) { return $this->successResponse(__eva('Connection successful')); } if ($statusCode === 401) { return $this->errorResponse(__eva('Invalid Twilio credentials (Account SID or Auth Token)')); } $body = json_decode($response->getBody()->getContents(), true); return $this->errorResponse($body['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->formatPhoneNumber($request->toPhone); try { $formParams = [ 'To' => 'whatsapp:' . $toPhone, 'From' => 'whatsapp:' . $this->phoneNumber, 'Body' => $request->body, ]; if ($request->mediaUrl) { $formParams['MediaUrl'] = $request->mediaUrl; } $response = $this->client->post("2010-04-01/Accounts/{$this->accountSid}/Messages.json", [ 'form_params' => $formParams ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { return $this->successResponse(__eva('Message sent successfully'), [ 'message_id' => $body['sid'] ?? null, 'status' => $body['status'] ?? null, 'date_created' => $body['date_created'] ?? null, ]); } return $this->errorResponse( $body['message'] ?? __eva('Failed to send message'), [ 'error_code' => $body['code'] ?? null, 'error_message' => $body['message'] ?? null, ] ); } 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->formatPhoneNumber($request->toPhone); try { $formParams = [ 'To' => 'whatsapp:' . $toPhone, 'From' => 'whatsapp:' . $this->phoneNumber, 'ContentSid' => $request->templateId, ]; // NO HACER ESTO - Convert variables to Twilio format (numeric keys) //$variables = $this->formatTemplateVariables($request); $variables = $request->variables; if (!empty($variables)) { $formParams['ContentVariables'] = json_encode($variables); } $response = $this->client->post("2010-04-01/Accounts/{$this->accountSid}/Messages.json", [ 'form_params' => $formParams ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { return $this->successResponse(__eva('Template message sent successfully'), [ 'message_id' => $body['sid'] ?? null, 'status' => $body['status'] ?? null, 'date_created' => $body['date_created'] ?? null, ]); } return $this->errorResponse( $body['message'] ?? __eva('Failed to send template message'), [ 'error_code' => $body['code'] ?? null, 'error_message' => $body['message'] ?? null, ] ); } catch (GuzzleException $e) { $this->logError('Send template failed', ['error' => $e->getMessage()]); return $this->errorResponse($e->getMessage()); } } /** * Format template variables for Twilio * Twilio uses numeric string keys: "1", "2", "3", etc. * * @param SendTemplateRequest $request * @return array */ protected function formatTemplateVariables(SendTemplateRequest $request): array { $variables = $request->variables; // If already in Twilio format (numeric keys), return as-is if (!empty($variables) && isset($variables['1'])) { return $variables; } // If booking data format, convert to Twilio format if (isset($variables['customer_name']) || isset($variables['date'])) { $formatted = []; $index = 1; $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)) { $formatted[(string)$index] = (string)$value; $index++; } } // Add any extra variables foreach ($variables as $key => $value) { if (!in_array($key, ['customer_name', 'restaurant_name', 'date', 'time', 'party', 'restaurant_phone'])) { if (!empty($value)) { $formatted[(string)$index] = (string)$value; $index++; } } } return $formatted; } // Generic array, convert to numeric keys $formatted = []; $index = 1; foreach ($variables as $value) { $formatted[(string)$index] = (string)$value; $index++; } return $formatted; } /** * @inheritDoc */ public function getTemplates(int $pageSize = 50, ?string $cursor = null): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { if ($cursor) { // Parse cursor URL for pagination $parsedUrl = parse_url($cursor); $path = $parsedUrl['path'] ?? 'v1/Content'; $query = []; if (isset($parsedUrl['query'])) { parse_str($parsedUrl['query'], $query); } $response = $this->contentClient->get(ltrim($path, '/'), [ 'query' => $query ]); } else { $response = $this->contentClient->get('v1/Content', [ 'query' => ['PageSize' => min($pageSize, 50)] ]); } $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['message'] ?? __eva('Failed to fetch templates'), $body ); } $templates = []; $contents = $body['contents'] ?? []; foreach ($contents as $content) { $template = $this->normalizeTemplate($content); $templates[] = $template->toArray(); } return $this->successResponse(__eva('Templates fetched successfully'), [ 'templates' => $templates, 'total' => count($templates), 'meta' => [ 'next_page_url' => $body['meta']['next_page_url'] ?? null, 'previous_page_url' => $body['meta']['previous_page_url'] ?? null, ], ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * @inheritDoc */ public function getTemplate(string $templateId): array { //ray('Get template: '.$templateId); if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->contentClient->get("v1/Content/{$templateId}"); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); //ray($statusCode, $body); if ($statusCode !== 200) { return $this->errorResponse( $body['message'] ?? __eva('Failed to fetch template'), $body ); } $template = $this->normalizeTemplate($body); // Get approval status $approvalStatus = $this->getTemplateApprovalStatus($templateId); if ($approvalStatus['success']) { $template->approvalStatus = $approvalStatus['data']['status'] ?? NormalizedTemplate::STATUS_UNKNOWN; $template->rejectionReason = $approvalStatus['data']['rejection_reason'] ?? null; $template->category = $approvalStatus['data']['category'] ?? null; } return $this->successResponse(__eva('Template fetched successfully'), [ 'template' => $template->toArray(), ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * Get template approval status from Twilio * * @param string $templateId * @return array */ protected function getTemplateApprovalStatus(string $templateId): array { try { $response = $this->contentClient->get("v1/Content/{$templateId}/ApprovalRequests"); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse($body['message'] ?? 'Failed to fetch approval status'); } $whatsappApproval = $body['whatsapp'] ?? []; return $this->successResponse('Approval status fetched', [ 'status' => $whatsappApproval['status'] ?? 'unknown', 'rejection_reason' => $whatsappApproval['rejection_reason'] ?? null, 'category' => $whatsappApproval['category'] ?? null, ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * Normalize Twilio template to NormalizedTemplate * * @param array $content * @return NormalizedTemplate */ protected function normalizeTemplate(array $content): NormalizedTemplate { $template = new NormalizedTemplate( $content['sid'], $content['friendly_name'] ?? '', self::PROVIDER_NAME ); $template->language = $content['language'] ?? null; $template->dateCreated = $content['date_created'] ?? null; $template->dateUpdated = $content['date_updated'] ?? null; $template->variables = $content['variables'] ?? []; $template->rawData = $content; $types = $content['types'] ?? []; // Determine template type and extract content if (isset($types['twilio/text'])) { $template->type = NormalizedTemplate::TYPE_TEXT; $template->body = $types['twilio/text']['body'] ?? ''; } elseif (isset($types['twilio/media'])) { $template->type = NormalizedTemplate::TYPE_MEDIA; $template->body = $types['twilio/media']['body'] ?? ''; $template->mediaUrl = $types['twilio/media']['media'][0] ?? null; } elseif (isset($types['twilio/call-to-action'])) { $template->type = NormalizedTemplate::TYPE_CALL_TO_ACTION; $template->body = $types['twilio/call-to-action']['body'] ?? ''; $template->buttons = $this->normalizeButtons($types['twilio/call-to-action']['actions'] ?? []); } elseif (isset($types['twilio/quick-reply'])) { $template->type = NormalizedTemplate::TYPE_QUICK_REPLY; $template->body = $types['twilio/quick-reply']['body'] ?? ''; $template->buttons = $this->normalizeButtons($types['twilio/quick-reply']['actions'] ?? []); } elseif (isset($types['twilio/card'])) { $template->type = NormalizedTemplate::TYPE_CARD; $template->body = $types['twilio/card']['body'] ?? ''; $template->header = $types['twilio/card']['title'] ?? null; $template->mediaUrl = $types['twilio/card']['media'][0] ?? null; $template->buttons = $this->normalizeButtons($types['twilio/card']['actions'] ?? []); } elseif (isset($types['twilio/list-picker'])) { $template->type = NormalizedTemplate::TYPE_LIST; $template->body = $types['twilio/list-picker']['body'] ?? ''; $template->buttons = $types['twilio/list-picker']['items'] ?? []; } return $template; } /** * Normalize button actions * * @param array $actions * @return array */ protected function normalizeButtons(array $actions): array { $buttons = []; foreach ($actions as $action) { $buttons[] = [ 'type' => $action['type'] ?? 'unknown', 'text' => $action['title'] ?? $action['text'] ?? '', 'url' => $action['url'] ?? null, 'payload' => $action['id'] ?? null, 'phone' => $action['phone'] ?? null, ]; } return $buttons; } /** * @inheritDoc */ public function getMessageStatus(string $messageId): array { if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $response = $this->client->get("2010-04-01/Accounts/{$this->accountSid}/Messages/{$messageId}.json"); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['message'] ?? __eva('Failed to fetch message status'), $body ); } return $this->successResponse(__eva('Status fetched successfully'), [ 'message_id' => $body['sid'], 'status' => $body['status'], 'error_code' => $body['error_code'] ?? null, 'error_message' => $body['error_message'] ?? null, 'date_created' => $body['date_created'], 'date_sent' => $body['date_sent'] ?? null, 'date_updated' => $body['date_updated'], ]); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } /** * @inheritDoc */ public function parseIncomingWebhook(array $payload): NormalizedMessage { $message = new NormalizedMessage(self::PROVIDER_NAME); // Extract phone numbers (remove 'whatsapp:' prefix) $from = $payload['From'] ?? ''; $to = $payload['To'] ?? ''; $message->fromPhone = str_replace('whatsapp:', '', $from); $message->toPhone = str_replace('whatsapp:', '', $to); // BSUID support: Extract ExternalUserId field $externalUserId = $payload['ExternalUserId'] ?? null; if ($externalUserId) { $message->externalUserId = str_replace('whatsapp:', '', $externalUserId); } // Detect if fromPhone is actually a BSUID (user hid their phone) if ($message->fromPhone && preg_match('/^[A-Z]{2}\.[A-Za-z0-9]+$/', $message->fromPhone)) { $message->isBsuid = true; if (!$message->externalUserId) { $message->externalUserId = $message->fromPhone; } } // Message identifiers $message->messageId = $payload['MessageSid'] ?? null; $message->accountId = $payload['AccountSid'] ?? null; // Sender info $message->profileName = $payload['ProfileName'] ?? null; $message->waId = $payload['WaId'] ?? null; // Message content $message->body = $payload['Body'] ?? ''; // Button response detection $buttonPayload = $payload['ButtonPayload'] ?? null; $buttonText = $payload['ButtonText'] ?? null; $message->isButtonResponse = !empty($buttonPayload) || !empty($buttonText); $message->buttonPayload = $buttonPayload; $message->buttonText = $buttonText ?? ($message->isButtonResponse ? $message->body : null); // Reply context $message->originalMessageId = $payload['OriginalRepliedMessageSid'] ?? null; $message->isReply = !empty($message->originalMessageId); // Media $message->mediaCount = (int)($payload['NumMedia'] ?? 0); $message->media = $this->extractMedia($payload); // Location if (isset($payload['Latitude'])) { $message->latitude = floatval($payload['Latitude']); $message->longitude = floatval($payload['Longitude'] ?? 0); $message->locationAddress = $payload['Address'] ?? null; } // Determine type $message->type = $message->getMessageType(); // Raw payload $message->rawPayload = $payload; return $message; } /** * Extract media from Twilio payload * * @param array $payload * @return array */ protected function extractMedia(array $payload): array { $media = []; $numMedia = (int)($payload['NumMedia'] ?? 0); for ($i = 0; $i < $numMedia; $i++) { $mediaUrl = $payload["MediaUrl{$i}"] ?? null; $mediaType = $payload["MediaContentType{$i}"] ?? null; if ($mediaUrl) { $media[] = [ 'url' => $mediaUrl, 'content_type' => $mediaType, 'id' => null, ]; } } return $media; } /** * @inheritDoc */ public function parseStatusCallback(array $payload): NormalizedStatusUpdate { $status = new NormalizedStatusUpdate(self::PROVIDER_NAME); $status->messageId = $payload['MessageSid'] ?? null; $status->accountId = $payload['AccountSid'] ?? null; $status->rawStatus = $payload['MessageStatus'] ?? null; $status->status = NormalizedStatusUpdate::normalizeStatus( $status->rawStatus ?? '', self::PROVIDER_NAME ); $status->toPhone = str_replace('whatsapp:', '', $payload['To'] ?? ''); $status->fromPhone = str_replace('whatsapp:', '', $payload['From'] ?? ''); $status->errorCode = $payload['ErrorCode'] ?? null; $status->errorMessage = $payload['ErrorMessage'] ?? null; $status->rawPayload = $payload; return $status; } /** * @inheritDoc */ public function validateWebhookSignature(string $signature, string $url, array $payload): bool { if (empty($this->authToken)) { return false; } // Sort params alphabetically by key ksort($payload); // Concatenate URL and params $data = $url; foreach ($payload as $key => $value) { $data .= $key . $value; } // Generate signature $expectedSignature = base64_encode(hash_hmac('sha1', $data, $this->authToken, true)); return hash_equals($expectedSignature, $signature); } /** * @inheritDoc */ public function generateWebhookResponse(?string $replyMessage = null): string { if ($replyMessage) { return '' . '' . htmlspecialchars($replyMessage) . ''; } return ''; } /** * @inheritDoc */ public function getWebhookResponseContentType(): string { return 'text/xml'; } /** * @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 { return $this->getWebhookBaseUrl() . '/wp-admin/admin-ajax.php?action=alexr_whatsapp_status_callback&restaurant_id=' . $this->restaurantId; } /** * @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, 'date_format' => $this->dateFormat, 'time_format' => $this->timeFormat, ]; } /** * @inheritDoc */ public function isStatusCallback(array $payload): bool { return isset($payload['MessageStatus']); } /** * @inheritDoc */ public function isIncomingMessage(array $payload): bool { return isset($payload['Body']) || isset($payload['ButtonPayload']) || isset($payload['NumMedia']); } /** * Check if a phone number is registered on WhatsApp * Twilio does not provide a direct contact check API. * * @param string $phone * @return array */ public function checkContact(string $phone): array { // Twilio does not support direct contact checking return ['success' => false, 'has_whatsapp' => null, 'wa_id' => null]; } /** * Configure webhook URLs for this WhatsApp sender via Twilio Senders API v2. * * @param string $incomingUrl Webhook URL for incoming messages * @param string $statusCallbackUrl URL for status callbacks * @return array */ public function configureWebhooks(string $incomingUrl, string $statusCallbackUrl): array { //ray('CONFIGURING WEBHOOKS FOR: ' . $this->phoneNumber); //ray('INCOMING URL: ' . $incomingUrl); //ray('STATUS CALLBACK URL: ' . $statusCallbackUrl); if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $sendersClient = new Client([ 'base_uri' => 'https://messaging.twilio.com/', 'timeout' => $this->timeout, 'http_errors' => false, 'headers' => [ 'Authorization' => 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken), 'Accept' => 'application/json', 'Content-Type' => 'application/json', ] ]); // Step 1: List senders to find the WhatsApp sender SID $response = $sendersClient->get('v2/Channels/Senders', [ 'query' => ['Channel' => 'whatsapp'] ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode !== 200) { return $this->errorResponse( $body['message'] ?? __eva('Failed to list Twilio senders'), $body ); } $senderSid = null; $whatsappSenderId = 'whatsapp:' . $this->phoneNumber; $senders = $body['senders'] ?? []; foreach ($senders as $sender) { if (($sender['sender_id'] ?? '') === $whatsappSenderId) { $senderSid = $sender['sid'] ?? null; break; } } if (!$senderSid) { return $this->errorResponse( __eva('WhatsApp sender not found for phone number') . ': ' . $this->phoneNumber ); } // Step 2: Update webhook URLs via Senders API $response = $sendersClient->post("v2/Channels/Senders/{$senderSid}", [ 'json' => [ 'sender_id' => $whatsappSenderId, 'webhook' => [ 'callback_url' => $incomingUrl, 'callback_method' => 'POST', 'status_callback_url' => $statusCallbackUrl, 'status_callback_method' => 'POST', ] ] ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody()->getContents(), true); if ($statusCode >= 200 && $statusCode < 300) { return $this->successResponse(__eva('Webhooks configured successfully'), [ 'sender_sid' => $senderSid, 'callback_url' => $incomingUrl, 'status_callback_url' => $statusCallbackUrl, ]); } return $this->errorResponse( $body['message'] ?? __eva('Failed to configure webhooks'), $body ); } catch (GuzzleException $e) { $this->logError('Configure webhooks failed', ['error' => $e->getMessage()]); return $this->errorResponse($e->getMessage()); } } /** * Mark an inbound message as read via Twilio's Typing Indicators API. * Sending a typing indicator automatically marks the referenced message as read * on the customer's WhatsApp client. * * @param string $messageId Twilio Message SID (e.g. SMxxxxx) * @return array */ public function markAsRead(string $messageId): array { // En whatsapp se muestran los 3 puntos demasiado tiempo, mejor no usarlo return $this->successResponse(__eva('NO using this funcion')); if (!$this->isConfigured()) { return $this->errorResponse(__eva('WhatsApp service is not configured')); } try { $authHeader = 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken); $messagingClient = new Client([ 'base_uri' => 'https://messaging.twilio.com/', 'timeout' => $this->timeout, 'http_errors' => false, 'headers' => [ 'Authorization' => $authHeader, 'Accept' => 'application/json', ] ]); $response = $messagingClient->post('v2/Indicators/Typing.json', [ 'form_params' => [ 'messageId' => $messageId, 'channel' => 'whatsapp', ] ]); $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['message'] ?? __eva('Failed to mark message as read'), $body ); } catch (GuzzleException $e) { return $this->errorResponse($e->getMessage()); } } }