'int', 'restaurant_id' => 'int', 'message_id' => 'int', 'conversation_id' => 'int', 'processed' => 'boolean', 'raw_payload' => 'array', ]; protected $fillable = [ 'restaurant_id', 'provider', 'message_id', 'conversation_id', 'message_sid', 'account_sid', 'event_type', 'status', 'phone_from', 'phone_to', 'error_code', 'error_message', 'channel_prefix', 'raw_payload', 'processed', 'processed_at', ]; // Provider constants const PROVIDER_TWILIO = 'twilio'; const PROVIDER_360DIALOG = '360dialog'; // Event type constants const EVENT_STATUS_CALLBACK = 'status_callback'; const EVENT_DELIVERY_RECEIPT = 'delivery_receipt'; const EVENT_READ_RECEIPT = 'read_receipt'; const EVENT_FAILED = 'failed'; const EVENT_INCOMING_MESSAGE = 'incoming_message'; // Status constants (same as WhatsappMessage for consistency) 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 message() { return $this->belongsTo(WhatsappMessage::class, 'message_id'); } public function conversation() { return $this->belongsTo(WhatsappConversation::class, 'conversation_id'); } // ------------------------------------------------------------------------- // Static Methods - Create Webhooks // ------------------------------------------------------------------------- /** * Create webhook from status callback payload (multi-provider) * * @param array $payload Raw webhook payload * @param int|null $restaurantId Restaurant ID * @param string|null $provider Provider name (auto-detected if null) * @return self */ public static function createFromStatusCallback(array $payload, ?int $restaurantId = null, ?string $provider = null): self { // Auto-detect provider if not specified if ($provider === null) { $provider = self::detectProviderFromPayload($payload); } // Extract data based on provider $data = self::extractStatusData($payload, $provider); // Try to find the message $message = null; $conversationId = null; if ($data['message_id']) { $message = WhatsappMessage::findBySid($data['message_id']); if ($message) { $restaurantId = $restaurantId ?? $message->restaurant_id; $conversationId = $message->conversation_id; } } // Determine event type based on status $eventType = self::determineEventType($data['status']); $webhook = self::create([ 'restaurant_id' => $restaurantId, 'provider' => $provider, 'message_id' => $message?->id, 'conversation_id' => $conversationId, 'message_sid' => $data['message_id'], 'account_sid' => $data['account_id'], 'event_type' => $eventType, 'status' => $data['status'], 'phone_from' => $data['from_phone'], 'phone_to' => $data['to_phone'], 'error_code' => $data['error_code'], 'error_message' => $data['error_message'], 'channel_prefix' => $data['channel_prefix'], 'raw_payload' => $payload, 'processed' => false, 'date_created' => evavel_now(), ]); return $webhook; } /** * Create webhook from incoming message payload (multi-provider) * * @param array $payload Raw webhook payload * @param int $restaurantId Restaurant ID * @param string|null $provider Provider name (auto-detected if null) * @return self */ public static function createFromIncomingMessage(array $payload, int $restaurantId, ?string $provider = null): self { // Auto-detect provider if not specified if ($provider === null) { $provider = self::detectProviderFromPayload($payload); } // Extract message ID based on provider $data = self::extractIncomingMessageData($payload, $provider); return self::create([ 'restaurant_id' => $restaurantId, 'provider' => $provider, 'message_sid' => $data['message_id'], 'account_sid' => $data['account_id'], 'event_type' => self::EVENT_INCOMING_MESSAGE, 'phone_from' => $data['from_phone'], 'phone_to' => $data['to_phone'], 'raw_payload' => $payload, 'processed' => false, 'date_created' => evavel_now(), ]); } // ------------------------------------------------------------------------- // Provider Detection & Data Extraction // ------------------------------------------------------------------------- /** * 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['MessageStatus'])) { 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; } // Also 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; } /** * Extract status data from payload based on provider * * @param array $payload * @param string $provider * @return array */ protected static function extractStatusData(array $payload, string $provider): array { return match ($provider) { self::PROVIDER_360DIALOG => self::extract360DialogStatusData($payload), default => self::extractTwilioStatusData($payload), }; } /** * Extract status data from Twilio payload * * @param array $payload * @return array */ protected static function extractTwilioStatusData(array $payload): array { return [ 'message_id' => $payload['MessageSid'] ?? null, 'account_id' => $payload['AccountSid'] ?? null, 'status' => $payload['MessageStatus'] ?? null, 'error_code' => $payload['ErrorCode'] ?? null, 'error_message' => $payload['ErrorMessage'] ?? null, 'to_phone' => str_replace('whatsapp:', '', $payload['To'] ?? ''), 'from_phone' => str_replace('whatsapp:', '', $payload['From'] ?? ''), 'channel_prefix' => $payload['ChannelPrefix'] ?? null, ]; } /** * Extract status data from 360dialog/Cloud API payload * * @param array $payload * @return array */ protected static function extract360DialogStatusData(array $payload): array { $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $statusData = $value['statuses'][0] ?? []; $metadata = $value['metadata'] ?? []; $errors = $statusData['errors'][0] ?? []; return [ 'message_id' => $statusData['id'] ?? null, 'account_id' => $entry['id'] ?? null, 'status' => $statusData['status'] ?? null, 'error_code' => isset($errors['code']) ? (string)$errors['code'] : null, 'error_message' => $errors['title'] ?? $errors['message'] ?? null, 'to_phone' => $statusData['recipient_id'] ?? '', 'from_phone' => $metadata['display_phone_number'] ?? '', 'channel_prefix' => null, ]; } /** * Extract incoming message data based on provider * * @param array $payload * @param string $provider * @return array */ protected static function extractIncomingMessageData(array $payload, string $provider): array { return match ($provider) { self::PROVIDER_360DIALOG => self::extract360DialogIncomingData($payload), default => self::extractTwilioIncomingData($payload), }; } /** * Extract incoming message data from Twilio payload * * @param array $payload * @return array */ protected static function extractTwilioIncomingData(array $payload): array { return [ 'message_id' => $payload['MessageSid'] ?? null, 'account_id' => $payload['AccountSid'] ?? null, 'from_phone' => str_replace('whatsapp:', '', $payload['From'] ?? ''), 'to_phone' => str_replace('whatsapp:', '', $payload['To'] ?? ''), ]; } /** * Extract incoming message data from 360dialog payload * * @param array $payload * @return array */ protected static function extract360DialogIncomingData(array $payload): array { $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $message = $value['messages'][0] ?? []; $metadata = $value['metadata'] ?? []; return [ 'message_id' => $message['id'] ?? null, 'account_id' => $entry['id'] ?? null, 'from_phone' => $message['from'] ?? '', 'to_phone' => $metadata['display_phone_number'] ?? '', ]; } /** * Determine event type based on status * * @param string|null $status * @return string */ protected static function determineEventType(?string $status): string { if ($status === null) { return self::EVENT_STATUS_CALLBACK; } return match ($status) { self::STATUS_FAILED, self::STATUS_UNDELIVERED => self::EVENT_FAILED, self::STATUS_DELIVERED => self::EVENT_DELIVERY_RECEIPT, self::STATUS_READ => self::EVENT_READ_RECEIPT, default => self::EVENT_STATUS_CALLBACK, }; } // ------------------------------------------------------------------------- // Instance Methods // ------------------------------------------------------------------------- /** * Process the webhook and update related message */ public function process(): bool { if ($this->processed) { return true; } $message = $this->message; if ($message && $this->status) { $message->updateStatus( $this->status, $this->error_code, $this->error_message ); } $this->processed = true; $this->processed_at = evavel_now(); $this->save(); // Fire action for custom processing do_action('alexr_whatsapp_webhook_processed', $this); return true; } /** * Check if this webhook is from Twilio * * @return bool */ public function isTwilio(): bool { return $this->provider === self::PROVIDER_TWILIO; } /** * Check if this webhook is from 360dialog * * @return bool */ public function is360Dialog(): bool { return $this->provider === self::PROVIDER_360DIALOG; } // ------------------------------------------------------------------------- // Static Query Methods // ------------------------------------------------------------------------- /** * Get unprocessed webhooks */ public static function getUnprocessed(int $limit = 100) { return self::where('processed', false) ->orderBy('date_created', 'ASC') ->limit($limit) ->get(); } /** * Process all pending webhooks */ public static function processAllPending(int $limit = 100): int { $webhooks = self::getUnprocessed($limit); $count = 0; foreach ($webhooks as $webhook) { if ($webhook->process()) { $count++; } } return $count; } /** * Get webhooks for a message */ public static function getForMessage(int $messageId) { return self::where('message_id', $messageId) ->orderBy('date_created', 'ASC') ->get(); } /** * Get webhooks by message SID */ public static function getByMessageSid(string $messageSid) { return self::where('message_sid', $messageSid) ->orderBy('date_created', 'ASC') ->get(); } /** * Get recent webhooks for a restaurant */ public static function getRecent(int $restaurantId, int $limit = 50) { return self::where('restaurant_id', $restaurantId) ->orderBy('date_created', 'DESC') ->limit($limit) ->get(); } /** * Get recent webhooks for a restaurant filtered by provider */ public static function getRecentByProvider(int $restaurantId, string $provider, int $limit = 50) { return self::where('restaurant_id', $restaurantId) ->where('provider', $provider) ->orderBy('date_created', 'DESC') ->limit($limit) ->get(); } /** * Get failed webhooks for a restaurant */ public static function getFailed(int $restaurantId, int $limit = 50) { return self::where('restaurant_id', $restaurantId) ->where('event_type', self::EVENT_FAILED) ->orderBy('date_created', 'DESC') ->limit($limit) ->get(); } /** * Clean old processed webhooks */ public static function cleanOld(int $daysOld = 30): int { $cutoff = date('Y-m-d H:i:s', strtotime("-{$daysOld} days")); return self::where('processed', true) ->where('date_created', '<', $cutoff) ->delete(); } /** * Convert to array */ public function toArray(): array { return [ 'id' => $this->id, 'restaurant_id' => $this->restaurant_id, 'provider' => $this->provider, 'message_id' => $this->message_id, 'conversation_id' => $this->conversation_id, 'message_sid' => $this->message_sid, 'account_sid' => $this->account_sid, 'event_type' => $this->event_type, 'status' => $this->status, 'phone_from' => $this->phone_from, 'phone_to' => $this->phone_to, 'error_code' => $this->error_code, 'error_message' => $this->error_message, 'channel_prefix' => $this->channel_prefix, 'processed' => $this->processed, 'processed_at' => $this->processed_at, 'date_created' => $this->date_created, ]; } }