restaurantId = $restaurantId; $this->provider = $provider ?? WhatsappProviderFactory::forRestaurant($restaurantId); } /** * Static factory method * * @param int $restaurantId * @return static */ public static function forRestaurant(int $restaurantId): static { return new static($restaurantId); } /** * Get the current provider * * @return WhatsappProviderInterface|null */ public function getProvider(): ?WhatsappProviderInterface { return $this->provider; } /** * Get provider name * * @return string|null */ public function getProviderName(): ?string { return $this->provider?->getProviderName(); } /** * Check if service is configured * * @return bool */ public function isConfigured(): bool { return $this->provider?->isConfigured() ?? false; } /** * Test connection to provider * * @return array */ public function testConnection(): array { if (!$this->provider) { return [ 'success' => false, 'message' => __eva('No WhatsApp provider configured'), 'data' => [], ]; } return $this->provider->testConnection(); } /** * Send a text message * * @param string $toPhone * @param string $message * @param int|null $bookingId * @param int|null $customerId * @return array */ public function sendMessage( string $toPhone, string $message, ?int $bookingId = null, ?int $customerId = null, $conversationId = null, array $senderInfo = [] ): array { if (!$this->provider || !$this->isConfigured()) { return [ 'success' => false, 'message' => __eva('WhatsApp service is not configured'), 'data' => [], ]; } // Check if customer has WhatsApp if ($customerId) { $customer = Customer::find($customerId); if ($customer && !$customer->has_whatsapp) { return [ 'success' => false, 'error_code' => 'customer_no_whatsapp', 'message' => __eva('This customer does not have WhatsApp'), 'data' => [], ]; } } // Convertir a new line $message = preg_replace('//i', "\n", $message); $toPhone = $this->formatPhoneNumber($toPhone); // Get or create conversation (customer-centric: one per phone/bsuid) $conversation = null; if ($conversationId) { $conversation = WhatsappConversation::find($conversationId); } if (!$conversation) { $conversation = $this->getOrCreateConversation($toPhone, $customerId, $bookingId); } // Use conversation's active_booking_id if no specific booking provided // Ensure we never pass 0 (FK constraint requires valid ID or NULL) $effectiveBookingId = ($bookingId && $bookingId > 0) ? $bookingId : $conversation->getActiveBookingId(); if (!$effectiveBookingId || $effectiveBookingId <= 0) { $effectiveBookingId = null; } // Create message record before sending $whatsappMessage = WhatsappMessage::create([ 'restaurant_id' => $this->restaurantId, 'conversation_id' => $conversation->id, 'booking_id' => $effectiveBookingId, 'customer_id' => $customerId, 'direction' => WhatsappMessage::DIRECTION_OUTBOUND, 'type' => WhatsappMessage::TYPE_TEXT, 'phone' => $toPhone, 'phone_normalized' => WhatsappConversation::normalizePhone($toPhone), 'body' => $message, 'status' => WhatsappMessage::STATUS_PENDING, 'provider' => $this->provider->getProviderName(), // Sender tracking 'sender_type' => $senderInfo['sender_type'] ?? 'bot', 'sender_user_id' => $senderInfo['sender_user_id'] ?? null, 'sender_user_name' => $senderInfo['sender_user_name'] ?? null, ]); // Create request $request = new SendMessageRequest($toPhone, $message); $request->bookingId = $bookingId; $request->customerId = $customerId; // Send via provider $result = $this->provider->sendMessage($request); if ($result['success']) { // Update message with provider ID and status $whatsappMessage->markAsSent( $result['data']['message_id'] ?? null, $result['data']['status'] ?? WhatsappMessage::STATUS_SENT ); // Update conversation $conversation->markOutbound(); // Set customer_status based on sender type $senderType = $senderInfo['sender_type'] ?? 'bot'; if ($senderType === 'agent') { // Agent sent a free text → set HUMAN_INTERACTION + AWAITING_REPLY $conversation->setCustomerStatus(WhatsappConversation::CUSTOMER_STATUS_HUMAN_INTERACTION); } // Fire action do_action('alexr_whatsapp_message_sent', [ 'restaurant_id' => $this->restaurantId, 'phone' => $toPhone, 'message_id' => $whatsappMessage->id, 'provider_message_id' => $result['data']['message_id'] ?? null, 'type' => 'text', 'provider' => $this->provider->getProviderName(), ]); $result['data']['message_id'] = $whatsappMessage->id; $result['data']['conversation_id'] = $conversation->id; } else { // Update message as failed $whatsappMessage->updateStatus( WhatsappMessage::STATUS_FAILED, $result['data']['error_code'] ?? null, $result['data']['error_message'] ?? $result['message'] ); } return $result; } /** * Send a template message * * @param string $toPhone * @param string $templateId Template ID (Content SID for Twilio, name for 360dialog) * @param array $variables Template variables * @param int|null $bookingId * @param int|null $customerId * @param string|null $templateName * @return array */ public function sendTemplate( string $toPhone, string $templateId, array $variables = [], ?int $bookingId = null, ?int $customerId = null, ?string $templateName = null, array $senderInfo = [], $conversationId = null ): array { //ray( $variables); if (!$this->provider || !$this->isConfigured()) { return [ 'success' => false, 'error_code' => 'whatsapp_not_configured', 'message' => __eva('WhatsApp service is not configured'), 'data' => [], ]; } // Check if customer has WhatsApp if ($customerId) { $customer = Customer::find($customerId); if ($customer && !$customer->has_whatsapp) { return [ 'success' => false, 'error_code' => 'customer_no_whatsapp', 'message' => __eva('This customer does not have WhatsApp'), 'data' => [], ]; } } $toPhone = $this->formatPhoneNumber($toPhone); // Get or create conversation (customer-centric: one per phone/bsuid) // If conversationId is provided, reuse the existing conversation $conversation = null; if ($conversationId) { $conversation = WhatsappConversation::find($conversationId); } if (!$conversation) { $conversation = $this->getOrCreateConversation($toPhone, $customerId, $bookingId); } // Build the resolved body from the cached template $resolvedBody = $this->resolveTemplateBody($templateId, $variables); // Ensure booking_id is valid or null (FK constraint requires valid ID or NULL) $effectiveBookingId = ($bookingId && $bookingId > 0) ? $bookingId : null; // Create message record before sending $whatsappMessage = WhatsappMessage::create([ 'restaurant_id' => $this->restaurantId, 'conversation_id' => $conversation->id, 'booking_id' => $effectiveBookingId, 'customer_id' => $customerId, 'direction' => WhatsappMessage::DIRECTION_OUTBOUND, 'type' => WhatsappMessage::TYPE_TEMPLATE, 'phone' => $toPhone, 'phone_normalized' => WhatsappConversation::normalizePhone($toPhone), 'content_sid' => $templateId, 'template_name' => $templateName, 'template_variables' => $variables, 'body' => $resolvedBody, 'status' => WhatsappMessage::STATUS_PENDING, 'provider' => $this->provider->getProviderName(), // Sender tracking 'sender_type' => $senderInfo['sender_type'] ?? 'bot', 'sender_user_id' => $senderInfo['sender_user_id'] ?? null, 'sender_user_name' => $senderInfo['sender_user_name'] ?? null, ]); // Create request $request = new SendTemplateRequest($toPhone, $templateId); $request->templateName = $templateName; $request->variables = $variables; $request->bookingId = $bookingId; $request->customerId = $customerId; //ray($request); // Send via provider $result = $this->provider->sendTemplate($request); if ($result['success']) { // Update message with provider ID and status $whatsappMessage->markAsSent( $result['data']['message_id'] ?? null, $result['data']['status'] ?? WhatsappMessage::STATUS_SENT ); // Update conversation $conversation->markOutbound(); // Set customer_status to AWAITING_REPLY only for system/agent-initiated templates // (reminders, reconfirmations, agent-sent templates). // Bot auto-replies (from KeywordHandler) should NOT change status. $senderType = $senderInfo['sender_type'] ?? 'bot'; if ($senderType !== 'bot') { $conversation->setCustomerStatus(WhatsappConversation::CUSTOMER_STATUS_AWAITING_REPLY); } // Fire action do_action('alexr_whatsapp_message_sent', [ 'restaurant_id' => $this->restaurantId, 'phone' => $toPhone, 'message_id' => $whatsappMessage->id, 'provider_message_id' => $result['data']['message_id'] ?? null, 'type' => 'template', 'template_id' => $templateId, 'provider' => $this->provider->getProviderName(), ]); $result['data']['message_id'] = $whatsappMessage->id; $result['data']['conversation_id'] = $conversation->id; } else { // Update message as failed $whatsappMessage->updateStatus( WhatsappMessage::STATUS_FAILED, $result['data']['error_code'] ?? null, $result['data']['error_message'] ?? $result['message'] ); } return $result; } /** * Resolve template body by replacing variables with their values * * @param string $templateId * @param array $variables * @return string|null */ protected function resolveTemplateBody(string $templateId, array $variables): ?string { // Get the cached template from settings $template = \Alexr\Settings\WhatsappTemplate::getTemplateBySid($this->restaurantId, $templateId); if (!$template || empty($template['body'])) { return null; } $body = $template['body']; // Replace {{1}}, {{2}}, etc. with actual variable values foreach ($variables as $key => $value) { $body = str_replace('{{' . $key . '}}', $value, $body); } return $body; } /** * Send a booking template with common variables * * @param string $toPhone * @param string $templateId * @param array $bookingData * @param int|null $bookingId * @param int|null $customerId * @param string|null $templateName * @return array */ public function sendBookingTemplate( string $toPhone, string $templateId, array $bookingData, ?int $bookingId = null, ?int $customerId = null, ?string $templateName = null, array $senderInfo = ['sender_type' => 'system'] ): array { // Merge restaurant info into booking data $config = $this->provider?->getConfig() ?? []; $variables = array_merge([ 'restaurant_name' => $config['restaurant_name'] ?? '', 'restaurant_phone' => $config['restaurant_phone'] ?? '', ], $bookingData); return $this->sendTemplate( $toPhone, $templateId, $variables, $bookingId, $customerId, $templateName, $senderInfo ); } /** * Process incoming webhook * * @param array $payload * @return array */ public function processWebhook(array $payload): array { if (!$this->provider) { return [ 'success' => false, 'type' => 'error', 'message' => __eva('No provider configured'), 'data' => $payload, ]; } // Determine webhook type - mejor incoming message primero if ($this->provider->isIncomingMessage($payload)) { return $this->processIncomingMessage($payload); } if ($this->provider->isStatusCallback($payload)) { return $this->processStatusCallback($payload); } return [ 'success' => false, 'type' => 'unknown', 'message' => __eva('Unknown webhook type'), 'data' => $payload, ]; } protected function processIncomingMessage(array $payload): array { // Parse via provider $normalized = $this->provider->parseIncomingWebhook($payload); // ** Idempotency check ** if ($normalized->messageId) { $existing = WhatsappMessage::findBySid($normalized->messageId); if ($existing) { return [ 'success' => true, 'type' => 'duplicate', 'message' => 'Message already processed', 'data' => ['message_id' => $existing->id], ]; } } // --------------------------------------------------------------- // Customer-centric conversation resolution. // One conversation per customer per restaurant. // Keyed by phone_normalized (or BSUID if phone is hidden). // --------------------------------------------------------------- $conversation = WhatsappConversation::findOrCreateByIdentifier( $this->restaurantId, $normalized->fromPhone, $normalized->externalUserId, [ 'profile_name' => $normalized->profileName, 'wa_id' => $normalized->waId, ] ); // Update conversation profile info if not set if ($normalized->profileName && !$conversation->profile_name) { $conversation->profile_name = $normalized->profileName; } if ($normalized->waId && !$conversation->wa_id) { $conversation->wa_id = $normalized->waId; } // Update BSUID if we now have one if ($normalized->externalUserId && !$conversation->bsuid) { $conversation->bsuid = $normalized->externalUserId; } $conversation->save(); // Try to link to customer by phone if (!$conversation->customer_id) { $customer = null; // Only search by phone if we have a real phone number (not BSUID) if ($normalized->fromPhone && !$normalized->isBsuid) { $customer = $this->findCustomerByPhone($normalized->fromPhone); } if (!$customer && $normalized->fromPhone && !$normalized->isBsuid) { $customer = $this->createCustomerFromWhatsapp( $normalized->fromPhone, $normalized->profileName ); } if ($customer) { $conversation->customer_id = $customer->id; $conversation->save(); } } // Mark as inbound (refreshes window, increments unread/message count) $conversation->markInbound(); // --------------------------------------------------------------- // Determine booking_id for this message. // 1. For button responses: inherit from the original outbound message // 2. For all messages: fall back to conversation's active_booking_id // --------------------------------------------------------------- $bookingIdForMessage = null; if ($normalized->isButtonResponse && $normalized->originalMessageId) { $originalMessage = WhatsappMessage::findBySid($normalized->originalMessageId); if ($originalMessage && $originalMessage->booking_id) { $bookingIdForMessage = $originalMessage->booking_id; } } // Fallback: use the conversation's active_booking_id if (!$bookingIdForMessage) { $bookingIdForMessage = $conversation->getActiveBookingId(); } // Create message record $whatsappMessage = WhatsappMessage::create([ 'restaurant_id' => $this->restaurantId, 'conversation_id' => $conversation->id, 'customer_id' => $conversation->customer_id, 'booking_id' => $bookingIdForMessage, 'direction' => WhatsappMessage::DIRECTION_INBOUND, 'type' => $normalized->type, 'phone' => $normalized->fromPhone, 'phone_normalized' => WhatsappConversation::normalizePhone($normalized->fromPhone), 'profile_name' => $normalized->profileName, 'message_sid' => $normalized->messageId, 'body' => $normalized->body, 'media_count' => $normalized->mediaCount, 'media_urls' => !empty($normalized->media) ? $normalized->media : null, 'button_payload' => $normalized->buttonPayload, 'button_text' => $normalized->buttonText, 'original_message_sid' => $normalized->originalMessageId, 'latitude' => $normalized->latitude, 'longitude' => $normalized->longitude, 'location_address' => $normalized->locationAddress, 'status' => WhatsappMessage::STATUS_DELIVERED, 'raw_payload' => $payload, 'provider' => $this->provider->getProviderName(), // BSUID fields 'bsuid' => $normalized->isBsuid ? $normalized->fromPhone : ($normalized->externalUserId ?? null), 'external_user_id' => $normalized->externalUserId, ]); // Atomic guard: if INSERT failed (UNIQUE constraint on message_sid), // another process already created this message → skip hooks to avoid duplicate reply if (!$whatsappMessage->id) { return [ 'success' => true, 'type' => 'duplicate', 'message' => 'Message already processed (concurrent)', 'data' => ['message_sid' => $normalized->messageId], ]; } // Link to original message if this is a button response if ($normalized->isButtonResponse) { $whatsappMessage->linkToOriginalMessage(); } // Prepare response data $responseData = $normalized->toArray(); $responseData['message_id'] = $whatsappMessage->id; $responseData['conversation_id'] = $conversation->id; $responseData['customer_id'] = $conversation->customer_id; $responseData['booking_id'] = $whatsappMessage->booking_id; // --------------------------------------------------------------- // Fire action hooks BEFORE updating customer_status. // This ensures that handlers (KeywordHandler, ButtonHandler) see // the current status (e.g. AWAITING_REPLY, HUMAN_INTERACTION) // and can decide whether to skip auto-replies accordingly. // --------------------------------------------------------------- if ($normalized->isButtonResponse) { do_action('alexr_whatsapp_button_response', $responseData, $this->restaurantId, $whatsappMessage, $conversation); // Fire action with specific button payload $sanitizedPayload = sanitize_key($normalized->buttonPayload ?? ''); if ($sanitizedPayload) { do_action('alexr_whatsapp_button_' . $sanitizedPayload, $responseData, $this->restaurantId, $whatsappMessage, $conversation); } } else { do_action('alexr_whatsapp_incoming_message', $responseData, $this->restaurantId, $whatsappMessage, $conversation); } // General action do_action('alexr_whatsapp_webhook_received', $responseData, $this->restaurantId, $whatsappMessage, $conversation); // --------------------------------------------------------------- // Update customer_status AFTER action hooks have fired. // - If AWAITING_REPLY → customer responded → IDLE // (system template got its answer, bot can resume auto-replies // on the NEXT incoming message, not this one) // - If HUMAN_INTERACTION → check timeout: // · Not expired → stays HUMAN_INTERACTION (agent still active) // · Expired → release to IDLE (agent abandoned conversation) // Timeout configured in WhatsappConversation::humanInteractionTimeoutSeconds() // --------------------------------------------------------------- if ($conversation->isAwaitingReply()) { $conversation->setCustomerStatus(WhatsappConversation::CUSTOMER_STATUS_IDLE); } elseif ($conversation->isHumanInteractionExpired()) { $conversation->setCustomerStatus(WhatsappConversation::CUSTOMER_STATUS_IDLE); } return [ 'success' => true, 'type' => $normalized->isButtonResponse ? 'button_response' : 'incoming_message', 'message' => __eva('Message processed successfully'), 'data' => $responseData, 'message_id' => $whatsappMessage->id, 'conversation_id' => $conversation->id, ]; } /** * Create a new customer from an incoming WhatsApp message. * * Used when a message arrives from an unknown phone number so * the conversation can be linked to a customer record. * * @param string $phone Full phone number (e.g. "+34617283910") * @param string|null $profileName WhatsApp profile name * @return Customer|null */ protected function createCustomerFromWhatsapp(string $phone, ?string $profileName = null): ?Customer { try { $cleanPhone = preg_replace('/[^0-9+]/', '', $phone); // Build a fake email from the phone: whatsapp_+34617283910@fake $email = 'whatsapp' . $cleanPhone . '@fake'; // Extract first_name / last_name from profile name $firstName = $profileName ?? 'WhatsApp'; $lastName = ''; if ($profileName) { $parts = explode(' ', trim($profileName), 2); $firstName = $parts[0]; $lastName = $parts[1] ?? ''; } // Decompose phone into dial code, local number and country $decomposed = \Alexr\Enums\Countries::decomposePhone($cleanPhone); $dialCode = $decomposed['dial_code'] ?? ''; $localPhone = $decomposed['phone'] ?? preg_replace('/[^0-9]/', '', $cleanPhone); $countryCode = $decomposed['country'] ?? ''; $customer = Customer::create([ 'restaurant_id' => $this->restaurantId, 'first_name' => $firstName, 'last_name' => $lastName, 'name' => trim($firstName . ' ' . $lastName), 'email' => $email, 'dial_code' => $dialCode, 'phone' => $localPhone, 'dial_code_country' => $countryCode, 'country_code' => $countryCode, // 'visits' => 0, // 'spend' => 0, // 'covers' => 0, // 'cancels' => 0, // 'no_shows' => 0, ]); return $customer; } catch (\Exception $e) { if (function_exists('ray')) { ray('Failed to create customer from WhatsApp', $e->getMessage())->red(); } error_log('[WhatsApp] Failed to create customer: ' . $e->getMessage()); return null; } } /** * Process status callback * * @param array $payload * @return array */ protected function processStatusCallback(array $payload): array { // Parse via provider $normalized = $this->provider->parseStatusCallback($payload); // ** Idempotency check ** if ($normalized->messageId) { $existingWebhook = WhatsappWebhook::where('message_sid', $normalized->messageId) ->where('status', $normalized->status) ->where('restaurant_id', $this->restaurantId) ->first(); if ($existingWebhook && $existingWebhook->processed) { return [ 'success' => true, 'type' => 'duplicate', 'message' => 'Status already processed', 'data' => ['webhook_id' => $existingWebhook->id], ]; } } // Create webhook record $webhook = WhatsappWebhook::createFromStatusCallback($payload, $this->restaurantId); // Find and update the message $message = null; $conversation = null; if ($normalized->messageId) { $message = WhatsappMessage::findBySid($normalized->messageId); if ($message) { // Guard: never downgrade to a lower-ranked status. // Webhooks can arrive out of order (e.g. read → sent), so we // only update when the incoming status is equal or higher rank. $statusRank = [ 'pending' => 0, 'sent' => 1, 'delivered' => 2, 'read' => 3, 'failed' => 4, 'undelivered' => 4, ]; $currentRank = $statusRank[$message->status] ?? -1; $incomingRank = $statusRank[$normalized->status] ?? -1; if ($incomingRank >= $currentRank) { $message->updateStatus( $normalized->status, $normalized->errorCode, $normalized->errorMessage ); } // Link webhook to message $webhook->message_id = $message->id; $webhook->conversation_id = $message->conversation_id; $webhook->save(); // Get conversation $conversation = $message->conversation; // If the error code indicates the phone has no WhatsApp, mark the customer if (in_array($normalized->status, ['failed', 'undelivered']) && $normalized->errorCode && in_array((string)$normalized->errorCode, self::NO_WHATSAPP_ERROR_CODES) ) { $customerId = $message->customer_id ?? ($conversation ? $conversation->customer_id : null); if ($customerId) { $customer = Customer::find($customerId); if ($customer && $customer->has_whatsapp) { $customer->has_whatsapp = false; $customer->save(); } } } } } // Mark webhook as processed $webhook->processed = true; $webhook->processed_at = current_time('mysql'); $webhook->save(); $responseData = $normalized->toArray(); $responseData['webhook_id'] = $webhook->id; $responseData['message_id'] = $message?->id; $responseData['conversation_id'] = $conversation?->id; // Fire status-specific actions do_action('alexr_whatsapp_status_callback', $responseData, $this->restaurantId, $webhook, $message); do_action('alexr_whatsapp_status_' . $normalized->status, $responseData, $this->restaurantId, $webhook, $message); return [ 'success' => true, 'type' => 'status_callback', 'message' => __eva('Status callback processed'), 'data' => $responseData, 'webhook_id' => $webhook->id, ]; } /** * Validate webhook signature * * @param string $signature * @param string $url * @param array $payload * @return bool */ public function validateWebhookSignature(string $signature, string $url, array $payload): bool { if (!$this->provider) { return false; } return $this->provider->validateWebhookSignature($signature, $url, $payload); } /** * Generate webhook response * * @param string|null $replyMessage * @return string */ public function generateWebhookResponse(?string $replyMessage = null): string { if (!$this->provider) { return ''; } return $this->provider->generateWebhookResponse($replyMessage); } /** * Get webhook response content type * * @return string */ public function getWebhookResponseContentType(): string { return $this->provider?->getWebhookResponseContentType() ?? 'text/plain'; } /** * Get templates * * @param int $pageSize * @param string|null $cursor * @return array */ public function getTemplates(int $pageSize = 50, ?string $cursor = null): array { if (!$this->provider || !$this->isConfigured()) { return [ 'success' => false, 'message' => __eva('WhatsApp service is not configured'), 'data' => [], ]; } return $this->provider->getTemplates($pageSize, $cursor); } /** * Get single template * * @param string $templateId * @return array */ public function getTemplate(string $templateId): array { //ray('WhatsappManager get template: ' . $templateId); if (!$this->provider || !$this->isConfigured()) { return [ 'success' => false, 'message' => __eva('WhatsApp service is not configured'), 'data' => [], ]; } return $this->provider->getTemplate($templateId); } /** * Get message status * * @param string $messageId Provider message ID * @return array */ public function getMessageStatus(string $messageId): array { if (!$this->provider || !$this->isConfigured()) { return [ 'success' => false, 'message' => __eva('WhatsApp service is not configured'), 'data' => [], ]; } $result = $this->provider->getMessageStatus($messageId); // Also update local message record if found if ($result['success']) { $localMessage = WhatsappMessage::findBySid($messageId); if ($localMessage) { $localMessage->updateStatus( $result['data']['status'] ?? null, $result['data']['error_code'] ?? null, $result['data']['error_message'] ?? null ); } } return $result; } /** * Get configuration * * @return array */ public function getConfig(): array { if (!$this->provider) { return [ 'enabled' => false, 'provider' => null, ]; } return $this->provider->getConfig(); } /** * Get or create conversation for a phone number (customer-centric). * One conversation per customer per restaurant, keyed by phone or BSUID. * * @param string $phone * @param int|null $customerId * @param int|null $bookingId If provided, sets as active_booking_id on the conversation * @param string|null $bsuid BSUID identifier if available * @return WhatsappConversation */ protected function getOrCreateConversation(string $phone, ?int $customerId = null, ?int $bookingId = null, ?string $bsuid = null): WhatsappConversation { $conversation = WhatsappConversation::findOrCreateByIdentifier( $this->restaurantId, $phone, $bsuid, ['customer_id' => $customerId] ); // Update customer if provided and not set if ($customerId && !$conversation->customer_id) { $conversation->customer_id = $customerId; $conversation->save(); } // Set active booking if sending a booking-related message if ($bookingId) { $conversation->setActiveBooking($bookingId); } return $conversation; } /** * Find customer by phone number * * @param string $phone * @return Customer|null */ protected function findCustomerByPhone(string $phone): ?Customer { return Customer::findByPhone($this->restaurantId, $phone); } /** * Format phone number to international format * * @param string $phone * @return string */ protected function formatPhoneNumber(string $phone): string { // Remove spaces, dashes, parentheses, whatsapp prefix $phone = preg_replace('/[\s\-\(\)]/', '', $phone); $phone = str_replace('whatsapp:', '', $phone); // Ensure it starts with + if (strpos($phone, '+') !== 0) { $phone = '+' . $phone; } return $phone; } /** * Get conversation by phone number * * @param string $phone * @return WhatsappConversation|null */ public function getConversation(string $phone): ?WhatsappConversation { $phoneNormalized = WhatsappConversation::normalizePhone($phone); return WhatsappConversation::where('restaurant_id', $this->restaurantId) ->where('phone_normalized', $phoneNormalized) ->first(); } /** * Get all conversations for this restaurant * * @param int $limit * @param int $offset * @return array */ public function getConversations(int $limit = 20, int $offset = 0): array { return WhatsappConversation::where('restaurant_id', $this->restaurantId) ->orderBy('last_message_at', 'DESC') ->limit($limit) ->offset($offset) ->get() ->toArray(); } /** * Get messages for a conversation * * @param int $conversationId * @param int $limit * @param int $offset * @return array */ public function getMessages(int $conversationId, int $limit = 50, int $offset = 0): array { return WhatsappMessage::getForConversation($conversationId, $limit, $offset) ->toArray(); } /** * Get messages for a booking * * @param int $bookingId * @return array */ public function getMessagesForBooking(int $bookingId): array { return WhatsappMessage::getForBooking($bookingId)->toArray(); } /** * Get webhook URLs * * @return array */ public function getWebhookUrls(): array { if (!$this->provider) { return [ 'incoming' => null, 'status' => null, ]; } return [ 'incoming' => $this->provider->getIncomingWebhookUrl(), 'status' => $this->provider->getStatusCallbackUrl(), ]; } }