'int', 'restaurant_id' => 'int', 'conversation_id' => 'int', 'booking_id' => 'int', 'customer_id' => 'int', 'reply_to_message_id' => 'int', 'media_count' => 'int', 'media_urls' => 'array', 'template_variables' => 'array', 'raw_payload' => 'array', 'latitude' => 'float', 'longitude' => 'float', ]; protected $guarded = []; protected $appends = [ 'is_inbound', 'is_outbound', 'is_button_response', 'has_media', ]; // Provider constants const PROVIDER_TWILIO = 'twilio'; const PROVIDER_360DIALOG = '360dialog'; // Direction constants const DIRECTION_INBOUND = 'inbound'; const DIRECTION_OUTBOUND = 'outbound'; // Type constants const TYPE_TEMPLATE = 'template'; const TYPE_TEXT = 'text'; const TYPE_MEDIA = 'media'; const TYPE_BUTTON_RESPONSE = 'button_response'; const TYPE_LOCATION = 'location'; const TYPE_INTERACTIVE = 'interactive'; const TYPE_UNKNOWN = 'unknown'; // Status constants const STATUS_PENDING = 'pending'; const STATUS_QUEUED = 'queued'; const STATUS_SENDING = 'sending'; const STATUS_SENT = 'sent'; const STATUS_DELIVERED = 'delivered'; const STATUS_READ = 'read'; const STATUS_FAILED = 'failed'; const STATUS_UNDELIVERED = 'undelivered'; // ------------------------------------------------------------------------- // Relationships // ------------------------------------------------------------------------- public function restaurant() { return $this->belongsTo(Restaurant::class); } public function conversation() { return $this->belongsTo(WhatsappConversation::class, 'conversation_id'); } public function booking() { return $this->belongsTo(Booking::class); } public function customer() { return $this->belongsTo(Customer::class); } public function replyToMessage() { return $this->belongsTo(WhatsappMessage::class, 'reply_to_message_id'); } public function replies() { return $this->hasMany(WhatsappMessage::class, 'reply_to_message_id'); } public function webhooks() { return $this->hasMany(WhatsappWebhook::class, 'message_id'); } // ------------------------------------------------------------------------- // Attributes // ------------------------------------------------------------------------- public function getIsInboundAttribute(): bool { return $this->direction === self::DIRECTION_INBOUND; } public function getIsOutboundAttribute(): bool { return $this->direction === self::DIRECTION_OUTBOUND; } public function getIsButtonResponseAttribute(): bool { return $this->type === self::TYPE_BUTTON_RESPONSE || !empty($this->button_payload); } public function getHasMediaAttribute(): bool { return $this->media_count > 0; } public function getIsDeliveredAttribute(): bool { return in_array($this->status, [self::STATUS_DELIVERED, self::STATUS_READ]); } public function getIsReadAttribute(): bool { return $this->status === self::STATUS_READ; } public function getIsFailedAttribute(): bool { return in_array($this->status, [self::STATUS_FAILED, self::STATUS_UNDELIVERED]); } public function getIsTwilioAttribute(): bool { return $this->provider === self::PROVIDER_TWILIO; } public function getIs360DialogAttribute(): bool { return $this->provider === self::PROVIDER_360DIALOG; } // ------------------------------------------------------------------------- // Static Methods - Create Messages // ------------------------------------------------------------------------- /** * Create an outbound template message * * @param int $restaurantId * @param string $phone * @param string $contentSid Template ID (Content SID for Twilio, name for 360dialog) * @param string $templateName * @param array $variables * @param int|null $bookingId * @param int|null $customerId * @param int|null $conversationId * @param string|null $provider * @return self */ public static function createOutboundTemplate( int $restaurantId, string $phone, string $contentSid, string $templateName, array $variables = [], ?int $bookingId = null, ?int $customerId = null, ?int $conversationId = null, ?string $provider = null ): self { $phoneNormalized = WhatsappConversation::normalizePhone($phone); return self::create([ 'restaurant_id' => $restaurantId, 'provider' => $provider, 'conversation_id' => $conversationId, 'booking_id' => $bookingId, 'customer_id' => $customerId, 'direction' => self::DIRECTION_OUTBOUND, 'type' => self::TYPE_TEMPLATE, 'phone' => $phone, 'phone_normalized' => $phoneNormalized, 'content_sid' => $contentSid, 'template_name' => $templateName, 'template_variables' => $variables, 'status' => self::STATUS_PENDING, ]); } /** * Create an outbound text message * * @param int $restaurantId * @param string $phone * @param string $body * @param int|null $bookingId * @param int|null $customerId * @param int|null $conversationId * @param string|null $provider * @return self */ public static function createOutboundText( int $restaurantId, string $phone, string $body, ?int $bookingId = null, ?int $customerId = null, ?int $conversationId = null, ?string $provider = null ): self { $phoneNormalized = WhatsappConversation::normalizePhone($phone); return self::create([ 'restaurant_id' => $restaurantId, 'provider' => $provider, 'conversation_id' => $conversationId, 'booking_id' => $bookingId, 'customer_id' => $customerId, 'direction' => self::DIRECTION_OUTBOUND, 'type' => self::TYPE_TEXT, 'phone' => $phone, 'phone_normalized' => $phoneNormalized, 'body' => $body, 'status' => self::STATUS_PENDING, ]); } /** * Create an inbound message from webhook payload (multi-provider) * * @param int $restaurantId * @param array $payload Raw webhook payload * @param int|null $conversationId * @param string|null $provider Provider name (auto-detected if null) * @return self */ public static function createFromInboundWebhook( int $restaurantId, array $payload, ?int $conversationId = null, ?string $provider = null ): self { // Auto-detect provider if not specified if ($provider === null) { $provider = self::detectProviderFromPayload($payload); } // Extract data based on provider return match ($provider) { self::PROVIDER_360DIALOG => self::createFrom360DialogPayload($restaurantId, $payload, $conversationId), default => self::createFromTwilioPayload($restaurantId, $payload, $conversationId), }; } /** * Create message from Twilio payload * * @param int $restaurantId * @param array $payload * @param int|null $conversationId * @return self */ protected static function createFromTwilioPayload( int $restaurantId, array $payload, ?int $conversationId = null ): self { $phone = $payload['From'] ?? ''; $phoneNormalized = WhatsappConversation::normalizePhone($phone); // Determine message type $type = self::TYPE_TEXT; if (!empty($payload['ButtonPayload']) || !empty($payload['ButtonText'])) { $type = self::TYPE_BUTTON_RESPONSE; } elseif (!empty($payload['NumMedia']) && intval($payload['NumMedia']) > 0) { $type = self::TYPE_MEDIA; } elseif (!empty($payload['Latitude'])) { $type = self::TYPE_LOCATION; } // Extract media URLs $mediaUrls = []; $numMedia = intval($payload['NumMedia'] ?? 0); for ($i = 0; $i < $numMedia; $i++) { if (!empty($payload["MediaUrl{$i}"])) { $mediaUrls[] = [ 'url' => $payload["MediaUrl{$i}"], 'content_type' => $payload["MediaContentType{$i}"] ?? null, ]; } } return self::create([ 'restaurant_id' => $restaurantId, 'provider' => self::PROVIDER_TWILIO, 'conversation_id' => $conversationId, 'direction' => self::DIRECTION_INBOUND, 'type' => $type, 'phone' => $phone, 'phone_normalized' => $phoneNormalized, 'profile_name' => $payload['ProfileName'] ?? null, 'message_sid' => $payload['MessageSid'] ?? null, 'account_sid' => $payload['AccountSid'] ?? null, 'body' => $payload['Body'] ?? null, 'media_count' => $numMedia, 'media_urls' => !empty($mediaUrls) ? $mediaUrls : null, 'button_payload' => $payload['ButtonPayload'] ?? null, 'button_text' => $payload['ButtonText'] ?? null, 'original_message_sid' => $payload['OriginalRepliedMessageSid'] ?? null, 'latitude' => !empty($payload['Latitude']) ? floatval($payload['Latitude']) : null, 'longitude' => !empty($payload['Longitude']) ? floatval($payload['Longitude']) : null, 'location_address' => $payload['Address'] ?? null, 'status' => self::STATUS_DELIVERED, 'raw_payload' => $payload, // BSUID support 'bsuid' => static::extractBsuidFromPayload($payload), 'external_user_id' => $payload['ExternalUserId'] ?? null, ]); } /** * Extract BSUID from Twilio payload (from ExternalUserId or From field). * * @param array $payload * @return string|null */ protected static function extractBsuidFromPayload(array $payload): ?string { // ExternalUserId field (without whatsapp: prefix) $externalUserId = $payload['ExternalUserId'] ?? null; if ($externalUserId) { $clean = str_replace('whatsapp:', '', $externalUserId); if (WhatsappConversation::isBsuid($clean)) { return $clean; } } // From field may contain BSUID when user hides phone $from = $payload['From'] ?? ''; $cleanFrom = str_replace('whatsapp:', '', $from); if (WhatsappConversation::isBsuid($cleanFrom)) { return $cleanFrom; } return null; } /** * Create message from 360dialog/Cloud API payload * * @param int $restaurantId * @param array $payload * @param int|null $conversationId * @return self */ protected static function createFrom360DialogPayload( int $restaurantId, array $payload, ?int $conversationId = null ): self { // Extract data from Cloud API structure $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $messageData = $value['messages'][0] ?? []; $contacts = $value['contacts'][0] ?? []; $metadata = $value['metadata'] ?? []; $phone = $messageData['from'] ?? ''; $phoneNormalized = WhatsappConversation::normalizePhone($phone); // Determine message type and extract content $messageType = $messageData['type'] ?? 'text'; $type = self::TYPE_TEXT; $body = null; $mediaUrls = []; $numMedia = 0; $latitude = null; $longitude = null; $locationAddress = null; $buttonPayload = null; $buttonText = null; switch ($messageType) { case 'text': $body = $messageData['text']['body'] ?? ''; break; case 'image': case 'video': case 'audio': case 'document': case 'sticker': $type = self::TYPE_MEDIA; $mediaData = $messageData[$messageType] ?? []; $body = $mediaData['caption'] ?? ''; $numMedia = 1; $mediaUrls = [[ 'id' => $mediaData['id'] ?? null, 'url' => null, // Must be fetched via media endpoint 'content_type' => $mediaData['mime_type'] ?? null, 'filename' => $mediaData['filename'] ?? null, ]]; break; case 'location': $type = self::TYPE_LOCATION; $locationData = $messageData['location'] ?? []; $latitude = floatval($locationData['latitude'] ?? 0); $longitude = floatval($locationData['longitude'] ?? 0); $locationAddress = $locationData['address'] ?? $locationData['name'] ?? null; break; case 'interactive': $type = self::TYPE_BUTTON_RESPONSE; $interactive = $messageData['interactive'] ?? []; $interactiveType = $interactive['type'] ?? ''; if ($interactiveType === 'button_reply') { $buttonReply = $interactive['button_reply'] ?? []; $buttonPayload = $buttonReply['id'] ?? null; $buttonText = $buttonReply['title'] ?? null; $body = $buttonText; } elseif ($interactiveType === 'list_reply') { $listReply = $interactive['list_reply'] ?? []; $buttonPayload = $listReply['id'] ?? null; $buttonText = $listReply['title'] ?? null; $body = $listReply['description'] ?? $buttonText; } break; case 'button': $type = self::TYPE_BUTTON_RESPONSE; $buttonData = $messageData['button'] ?? []; $buttonPayload = $buttonData['payload'] ?? null; $buttonText = $buttonData['text'] ?? null; $body = $buttonText; break; default: $type = self::TYPE_UNKNOWN; } // Get reply context $originalMessageSid = null; $context = $messageData['context'] ?? []; if (!empty($context['id'])) { $originalMessageSid = $context['id']; } return self::create([ 'restaurant_id' => $restaurantId, 'provider' => self::PROVIDER_360DIALOG, 'conversation_id' => $conversationId, 'direction' => self::DIRECTION_INBOUND, 'type' => $type, 'phone' => $phone, 'phone_normalized' => $phoneNormalized, 'profile_name' => $contacts['profile']['name'] ?? null, 'message_sid' => $messageData['id'] ?? null, 'account_sid' => $entry['id'] ?? null, 'body' => $body, 'media_count' => $numMedia, 'media_urls' => !empty($mediaUrls) ? $mediaUrls : null, 'button_payload' => $buttonPayload, 'button_text' => $buttonText, 'original_message_sid' => $originalMessageSid, 'latitude' => $latitude, 'longitude' => $longitude, 'location_address' => $locationAddress, 'status' => self::STATUS_DELIVERED, 'raw_payload' => $payload, ]); } /** * Detect provider from webhook payload structure * * @param array $payload * @return string */ protected static function detectProviderFromPayload(array $payload): string { // Twilio format: has AccountSid, MessageSid at root level if (isset($payload['AccountSid']) || isset($payload['MessageSid']) || isset($payload['From'])) { // Check if From has whatsapp: prefix (Twilio format) $from = $payload['From'] ?? ''; if (str_starts_with($from, 'whatsapp:')) { return self::PROVIDER_TWILIO; } } // 360dialog/Cloud API format: has entry[].changes[].value structure if (isset($payload['entry'][0]['changes'][0]['value'])) { return self::PROVIDER_360DIALOG; } // Check for 'object' field which is present in Cloud API if (isset($payload['object']) && $payload['object'] === 'whatsapp_business_account') { return self::PROVIDER_360DIALOG; } // Default to twilio for backwards compatibility return self::PROVIDER_TWILIO; } // ------------------------------------------------------------------------- // Instance Methods // ------------------------------------------------------------------------- /** * Update message after successful send * * @param string|null $messageSid Provider message ID * @param string|null $status * @return self */ public function markAsSent(?string $messageSid, ?string $status = null): self { if ($messageSid) { $this->message_sid = $messageSid; } $this->status = $status ?? self::STATUS_SENT; $this->sent_at = evavel_now(); $this->save(); return $this; } /** * Update message status * * @param string $status * @param string|null $errorCode * @param string|null $errorMessage * @return self */ public function updateStatus(string $status, ?string $errorCode = null, ?string $errorMessage = null): self { $this->status = $status; switch ($status) { case self::STATUS_DELIVERED: $this->delivered_at = evavel_now(); break; case self::STATUS_READ: $this->read_at = evavel_now(); if (!$this->delivered_at) { $this->delivered_at = evavel_now(); } break; case self::STATUS_FAILED: case self::STATUS_UNDELIVERED: $this->failed_at = evavel_now(); $this->error_code = $errorCode; $this->error_message = $errorMessage; break; } $this->save(); return $this; } /** * Link to reply-to message by SID * * @return self */ public function linkToOriginalMessage(): self { if (!$this->original_message_sid) { return $this; } $originalMessage = self::where('message_sid', $this->original_message_sid)->first(); if ($originalMessage) { $this->reply_to_message_id = $originalMessage->id; // Also link to booking if original had one if (!$this->booking_id && $originalMessage->booking_id) { $this->booking_id = $originalMessage->booking_id; } $this->save(); } return $this; } /** * Find message by provider SID/ID * * @param string $messageSid * @return self|null */ public static function findBySid(string $messageSid): ?self { return self::where('message_sid', $messageSid)->first(); } /** * Find message by provider SID/ID and provider * * @param string $messageSid * @param string $provider * @return self|null */ public static function findBySidAndProvider(string $messageSid, string $provider): ?self { return self::where('message_sid', $messageSid) ->where('provider', $provider) ->first(); } /** * Get messages for a conversation * * @param int $conversationId * @param int $limit * @param int $offset * @return mixed */ public static function getForConversation(int $conversationId, int $limit = 50, int $offset = 0) { return self::where('conversation_id', $conversationId) //->orderBy('date_created', 'DESC') ->orderBy('id', 'DESC') ->limit($limit) ->offset($offset) ->get(); } /** * Get messages for a booking * * @param int $bookingId * @return mixed */ public static function getForBooking(int $bookingId) { return self::where('booking_id', $bookingId) ->orderBy('id', 'DESC') ->get(); } /** * Get messages by provider * * @param int $restaurantId * @param string $provider * @param int $limit * @return mixed */ public static function getByProvider(int $restaurantId, string $provider, int $limit = 50) { return self::where('restaurant_id', $restaurantId) ->where('provider', $provider) ->orderBy('id', 'DESC') ->limit($limit) ->get(); } /** * Get button responses for an outbound message * * @return mixed */ public function getButtonResponses() { return self::where('reply_to_message_id', $this->id) ->where('type', self::TYPE_BUTTON_RESPONSE) ->orderBy('id', 'ASC') ->get(); } /** * Convert to array * * @return array */ public function toArray(): array { return [ 'id' => $this->id, 'restaurant_id' => $this->restaurant_id, 'sender_type' => $this->sender_type, 'sender_user_id' => $this->sender_user_id, 'sender_user_name' => $this->sender_user_name, 'provider' => $this->provider, 'conversation_id' => $this->conversation_id, 'booking_id' => $this->booking_id, 'customer_id' => $this->customer_id, 'reply_to_message_id' => $this->reply_to_message_id, 'direction' => $this->direction, 'type' => $this->type, 'phone' => $this->phone, 'phone_normalized' => $this->phone_normalized, 'profile_name' => $this->profile_name, 'message_sid' => $this->message_sid, 'content_sid' => $this->content_sid, 'template_name' => $this->template_name, 'template_variables' => $this->template_variables, 'body' => $this->body, 'media_count' => $this->media_count, 'media_urls' => $this->media_urls, 'button_payload' => $this->button_payload, 'button_text' => $this->button_text, 'is_button_response' => $this->isButtonResponse, 'has_media' => $this->hasMedia, 'latitude' => $this->latitude, 'longitude' => $this->longitude, 'location_address' => $this->location_address, 'status' => $this->status, 'error_code' => $this->error_code, 'error_message' => $this->error_message, 'sent_at' => $this->sent_at, 'delivered_at' => $this->delivered_at, 'read_at' => $this->read_at, 'failed_at' => $this->failed_at, 'bsuid' => $this->bsuid, 'external_user_id' => $this->external_user_id, 'date_created' => $this->date_created, 'date_modified' => $this->date_modified, ]; } }