getProvider()) { self::logWebhookError('incoming', 'No provider configured', $_POST, $restaurantId); self::respondWithError('No provider configured', 500); return; } // Get the raw payload $payload = self::getPayload(); // Validate signature if enabled if (self::shouldValidateSignature()) { $signature = self::getSignatureHeader(); $url = self::getCurrentUrl(); if (!$manager->validateWebhookSignature($signature, $url, $payload)) { self::logWebhookError('incoming', 'Invalid signature', $payload, $restaurantId); self::respondWithError('Invalid signature', 403); return; } } // Process the webhook $result = $manager->processWebhook($payload); // Log for debugging self::logWebhook('incoming', $payload, $result, $restaurantId); // Generate response $response = apply_filters('alexr_whatsapp_webhook_response', null, $result, $restaurantId); header('Content-Type: ' . $manager->getWebhookResponseContentType()); echo $manager->generateWebhookResponse($response); wp_die(); } /** * Handle status callback webhook */ public static function handleStatusCallback(): void { $restaurantId = self::getRestaurantId(); // For status callbacks, try to find restaurant from the message if not in URL if (!$restaurantId) { $payload = self::getPayload(); $messageSid = $payload['MessageSid'] ?? null; // Also check Cloud API format if (!$messageSid) { $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $statuses = $value['statuses'][0] ?? []; $messageSid = $statuses['id'] ?? null; } if ($messageSid) { $message = WhatsappMessage::findBySid($messageSid); if ($message) { $restaurantId = $message->restaurant_id; } } } if (!$restaurantId) { self::logWebhookError('status', 'Restaurant not found', $_POST); self::respondWithError('Restaurant not found', 404); return; } $manager = WhatsappManager::forRestaurant($restaurantId); if (!$manager->getProvider()) { self::logWebhookError('status', 'No provider configured', $_POST, $restaurantId); self::respondWithError('No provider configured', 500); return; } $payload = self::getPayload(); // Validate signature if (self::shouldValidateSignature()) { $signature = self::getSignatureHeader(); $url = self::getCurrentUrl(); if (!$manager->validateWebhookSignature($signature, $url, $payload)) { self::logWebhookError('status', 'Invalid signature', $payload, $restaurantId); self::respondWithError('Invalid signature', 403); return; } } // Process status callback $result = $manager->processWebhook($payload); // Log for debugging self::logWebhook('status', $payload, $result, $restaurantId); // Respond with appropriate content type header('Content-Type: ' . $manager->getWebhookResponseContentType()); echo $manager->generateWebhookResponse(); wp_die(); } /** * Handle webhook verification (for 360dialog/Cloud API) */ protected static function handleWebhookVerification(): void { $restaurantId = self::getRestaurantId(); if (!$restaurantId) { // Try to find by hub.verify_token or default $restaurantId = apply_filters('alexr_whatsapp_default_restaurant_id', null); } if (!$restaurantId) { status_header(404); echo 'Restaurant not found'; wp_die(); } $manager = WhatsappManager::forRestaurant($restaurantId); $provider = $manager->getProvider(); // Only 360dialog needs verification handling if ($provider instanceof Dialog360Provider) { $challenge = $provider->handleWebhookVerification($_GET); if ($challenge !== null) { status_header(200); echo $challenge; wp_die(); } } status_header(403); echo 'Verification failed'; wp_die(); } /** * Get the webhook payload * * @return array */ protected static function getPayload(): array { // First try to get JSON body (for 360dialog/Cloud API) $rawBody = file_get_contents('php://input'); if ($rawBody) { $jsonPayload = json_decode($rawBody, true); if (is_array($jsonPayload)) { return $jsonPayload; } } // Fall back to $_POST (for Twilio) return $_POST; } /** * Get signature header (handles different provider formats) * * @return string */ protected static function getSignatureHeader(): string { // Twilio uses X-Twilio-Signature if (!empty($_SERVER['HTTP_X_TWILIO_SIGNATURE'])) { return $_SERVER['HTTP_X_TWILIO_SIGNATURE']; } // 360dialog/Cloud API uses X-Hub-Signature-256 if (!empty($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) { return $_SERVER['HTTP_X_HUB_SIGNATURE_256']; } // Also check lowercase versions if (!empty($_SERVER['HTTP_X_HUB_SIGNATURE'])) { return $_SERVER['HTTP_X_HUB_SIGNATURE']; } return ''; } /** * Get restaurant ID from request * * @return int|null */ protected static function getRestaurantId(): ?int { // First, try from query string if (!empty($_GET['restaurant_id'])) { return (int)$_GET['restaurant_id']; } // Second, try to determine from the 'To' phone number (Twilio format) // For inbound messages, 'To' is the restaurant's number $toPhone = $_POST['To'] ?? ''; $toPhone = str_replace('whatsapp:', '', $toPhone); if ($toPhone) { $restaurantId = WhatsappProviderFactory::findRestaurantByPhone($toPhone); if ($restaurantId) { return $restaurantId; } } // Third, try 'From' phone number (Twilio format) // For outbound status callbacks, 'From' is the restaurant's number $fromPhone = $_POST['From'] ?? ''; $fromPhone = str_replace('whatsapp:', '', $fromPhone); if ($fromPhone) { $restaurantId = WhatsappProviderFactory::findRestaurantByPhone($fromPhone); if ($restaurantId) { return $restaurantId; } } // Fourth, try Cloud API format (360dialog) $payload = self::getPayload(); $entry = $payload['entry'][0] ?? []; $changes = $entry['changes'][0] ?? []; $value = $changes['value'] ?? []; $metadata = $value['metadata'] ?? []; $displayPhone = $metadata['display_phone_number'] ?? ''; if ($displayPhone) { $restaurantId = WhatsappProviderFactory::findRestaurantByPhone($displayPhone); if ($restaurantId) { return $restaurantId; } } // Fifth, use default restaurant filter $defaultRestaurant = apply_filters('alexr_whatsapp_default_restaurant_id', null); if ($defaultRestaurant) { return (int)$defaultRestaurant; } return null; } /** * Check if we should validate webhook signature * * @return bool */ protected static function shouldValidateSignature(): bool { // Disable in development/sandbox mode if (defined('ALEXR_WHATSAPP_SKIP_SIGNATURE') && ALEXR_WHATSAPP_SKIP_SIGNATURE) { return false; } // Can be filtered return apply_filters('alexr_whatsapp_validate_signature', true); } /** * Get current request URL * * @return string */ protected static function getCurrentUrl(): string { $protocol = is_ssl() ? 'https://' : 'http://'; return $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; } /** * Respond with error * * @param string $message * @param int $statusCode */ protected static function respondWithError(string $message, int $statusCode = 400): void { status_header($statusCode); // Try to determine provider for response format $restaurantId = self::getRestaurantId(); if ($restaurantId) { $manager = WhatsappManager::forRestaurant($restaurantId); header('Content-Type: ' . $manager->getWebhookResponseContentType()); echo $manager->generateWebhookResponse(); } else { // Default to XML (Twilio format) header('Content-Type: text/xml'); echo ''; } wp_die(); } /** * Log webhook for debugging * * @param string $type * @param array $payload * @param array $result * @param int $restaurantId */ protected static function logWebhook(string $type, array $payload, array $result, int $restaurantId): void { // Only log if debugging is enabled if (!defined('ALEXR_WHATSAPP_DEBUG') || !ALEXR_WHATSAPP_DEBUG) { return; } $manager = WhatsappManager::forRestaurant($restaurantId); $logData = [ 'type' => $type, 'provider' => $manager->getProviderName(), 'restaurant_id' => $restaurantId, 'payload' => $payload, 'result' => $result, 'timestamp' => current_time('mysql'), ]; // Use Ray if available (for development) if (function_exists('ray')) { ray('WhatsApp Webhook', $logData)->blue(); } // Fire action for custom logging do_action('alexr_whatsapp_webhook_log', $logData); // Optionally log to file if (defined('ALEXR_WHATSAPP_LOG_FILE') && ALEXR_WHATSAPP_LOG_FILE) { error_log( '[WhatsApp Webhook] ' . json_encode($logData) . PHP_EOL, 3, ALEXR_WHATSAPP_LOG_FILE ); } } /** * Log webhook error * * @param string $type * @param string $error * @param array $payload * @param int|null $restaurantId */ protected static function logWebhookError(string $type, string $error, array $payload, ?int $restaurantId = null): void { $provider = null; if ($restaurantId) { $manager = WhatsappManager::forRestaurant($restaurantId); $provider = $manager->getProviderName(); } $logData = [ 'type' => $type, 'error' => $error, 'provider' => $provider, 'restaurant_id' => $restaurantId, 'payload' => $payload, 'timestamp' => current_time('mysql'), ]; // Always log errors if (function_exists('ray')) { ray('WhatsApp Webhook Error', $logData)->red(); } // Fire action for custom error handling do_action('alexr_whatsapp_webhook_error', $logData); // Log to error log error_log('[WhatsApp Webhook Error] ' . json_encode($logData)); } } // Register the controller WhatsappWebhookController::register();