init_hooks(); } /** * Initialize hooks. * * @since 1.0.0 * @return void */ private function init_hooks() { // Register REST API endpoint for webhooks. add_action( 'rest_api_init', array( $this, 'register_webhook_endpoint' ) ); // Fallback: also register on init if rest_api_init has already been called. if ( did_action( 'rest_api_init' ) ) { $this->register_webhook_endpoint(); } // Bypass REST API restrictions for our webhook endpoint. // This ensures webhooks work even if security plugins block the REST API. // Our endpoint has its own security via HMAC signature verification. add_filter( 'rest_authentication_errors', array( $this, 'allow_webhook_endpoint' ), 1 ); } /** * Allow webhook endpoint to bypass REST API restrictions. * * Security plugins like Wordfence, iThemes Security, or "Disable REST API" * may block REST API access for non-authenticated users. This filter * ensures our webhook endpoint remains accessible since it has its own * security via HMAC signature verification. * * Why this is necessary: * - Webhooks from Agentorie SaaS need to reach WordPress even if REST is blocked * - The endpoint has its own security: HMAC-SHA256 signature + active license required * * Why this is safe: * - Only bypasses for POST requests to our specific route * - Requires X-Agentorie-Signature header to be present * - Actual signature verification happens in verify_webhook_permission() * - We do NOT alter authentication for any other endpoint * * @since 1.0.0 * @param WP_Error|null|true $result Authentication result. * @return WP_Error|null|true */ public function allow_webhook_endpoint( $result ) { // Only intervene if REST API is being blocked. if ( true === $result || null === $result ) { return $result; } // Only bypass for POST requests. $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'POST' !== $method ) { return $result; } // Require signature header to be present (actual verification in permission_callback). if ( empty( $_SERVER['HTTP_X_AGENTORIE_SIGNATURE'] ) ) { return $result; } // Check if this is a request to our webhook endpoint. // Support both /wp-json/agentorie-chat/v1/webhook and /?rest_route=/agentorie-chat/v1/webhook. $is_webhook_route = false; // Check REST route parameter (for sites using ?rest_route= format). // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only route check. if ( isset( $_GET['rest_route'] ) ) { $rest_route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended // Exact match: /agentorie-chat/v1/webhook (with or without trailing slash). if ( preg_match( '#^/agentorie-chat/v1/webhook/?$#', $rest_route ) ) { $is_webhook_route = true; } } // Check REQUEST_URI (for sites using pretty permalinks /wp-json/). if ( ! $is_webhook_route ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for route matching. $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // Match: /wp-json/agentorie-chat/v1/webhook (with optional trailing slash and query string). if ( preg_match( '#/wp-json/agentorie-chat/v1/webhook/?(\?|$)#', $request_uri ) ) { $is_webhook_route = true; } } if ( $is_webhook_route ) { // Allow the request - signature will be verified in verify_webhook_permission(). return true; } return $result; } /** * Register webhook REST endpoint. * * @since 1.0.0 * @return void */ public function register_webhook_endpoint() { register_rest_route( 'agentorie-chat/v1', '/webhook', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_webhook' ), 'permission_callback' => array( $this, 'verify_webhook_permission' ), ) ); } /** * Verify webhook permission via HMAC signature. * * @since 1.0.0 * @param WP_REST_Request $request The request object. * @return bool|WP_Error True if authorized, WP_Error otherwise. */ public function verify_webhook_permission( $request ) { $signature = $request->get_header( 'X-Agentorie-Signature' ); if ( empty( $signature ) ) { return new WP_Error( 'agentorie_chat_missing_signature', __( 'Missing webhook signature.', 'agentorie-chat' ), array( 'status' => 401 ) ); } $body = $request->get_body(); $license_key = agentorie_chat_get_setting( 'license_key', '' ); if ( empty( $license_key ) ) { return new WP_Error( 'agentorie_chat_no_license', __( 'No license key configured.', 'agentorie-chat' ), array( 'status' => 401 ) ); } $expected = hash_hmac( 'sha256', $body, $license_key ); if ( ! hash_equals( $expected, $signature ) ) { if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::warning( 'Webhook signature verification failed' ); } return new WP_Error( 'agentorie_chat_invalid_signature', __( 'Invalid webhook signature.', 'agentorie-chat' ), array( 'status' => 401 ) ); } return true; } /** * Handle incoming webhook. * * Note: Signature verification is done in verify_webhook_permission(). * * @since 1.0.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response or error. */ public function handle_webhook( $request ) { // Log incoming webhook for debugging. if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::debug( 'Webhook received', array( 'method' => $request->get_method(), ) ); } // Parse payload. $payload = $request->get_json_params(); if ( empty( $payload ) || empty( $payload['event'] ) ) { return new WP_Error( 'agentorie_chat_invalid_payload', __( 'Invalid webhook payload.', 'agentorie-chat' ), array( 'status' => 400 ) ); } // Process event. $result = $this->process_event( $payload['event'], isset( $payload['data'] ) ? $payload['data'] : array() ); if ( is_wp_error( $result ) ) { return $result; } // Clear all third-party caches to ensure visitors see updated config. // This is critical because the widget config (agentorieWidget) is embedded // in the HTML and cached by page caching plugins (SiteGround, WP Rocket, etc.). if ( function_exists( 'agentorie_chat_clear_third_party_caches' ) ) { agentorie_chat_clear_third_party_caches(); if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::debug( 'Third-party caches cleared after webhook' ); } } if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'Webhook processed', array( 'event' => $payload['event'], ) ); } return new WP_REST_Response( array( 'success' => true, 'message' => 'Webhook processed.', ), 200 ); } /** * Process webhook event. * * @since 1.0.0 * @param string $event Event type. * @param array $data Event data. * @return bool|WP_Error True on success, WP_Error on failure. */ private function process_event( $event, $data ) { switch ( $event ) { case 'license.updated': case 'license.activated': case 'license.deactivated': return $this->handle_license_update( $data ); case 'features.updated': case 'features.added': case 'features.removed': return $this->handle_features_update( $data ); case 'config.updated': return $this->handle_config_update( $data ); case 'ping': // Simple ping to test webhook connectivity. return true; default: if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::debug( 'Unknown webhook event', array( 'event' => $event ) ); } return true; // Don't error on unknown events. } } /** * Handle license update event. * * @since 1.0.0 * @param array $data Event data. * @return bool|WP_Error */ private function handle_license_update( $data ) { // Invalidate cache to force fresh data on next request. if ( class_exists( 'Agentorie_API' ) ) { Agentorie_API::invalidate_license_cache(); } // If status provided, update immediately. if ( ! empty( $data['status'] ) ) { agentorie_chat_update_setting( 'license_status', sanitize_key( $data['status'] ) ); // Disable widget if license is not active. if ( 'active' !== $data['status'] ) { agentorie_chat_update_setting( 'enabled', false ); } } // If features provided, update immediately. if ( ! empty( $data['features'] ) && is_array( $data['features'] ) ) { $features = array_map( 'sanitize_key', $data['features'] ); agentorie_chat_update_setting( 'license_features', $features ); } if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'License updated via webhook', array( 'status' => isset( $data['status'] ) ? $data['status'] : 'unchanged', 'features' => isset( $data['features'] ) ? $data['features'] : 'unchanged', ) ); } return true; } /** * Handle features update event. * * @since 1.0.0 * @param array $data Event data. * @return bool|WP_Error */ private function handle_features_update( $data ) { // Invalidate cache. if ( class_exists( 'Agentorie_API' ) ) { Agentorie_API::invalidate_license_cache(); } // Update features if provided. if ( ! empty( $data['features'] ) && is_array( $data['features'] ) ) { $features = array_map( 'sanitize_key', $data['features'] ); agentorie_chat_update_setting( 'license_features', $features ); if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'Features updated via webhook', array( 'features' => $features, ) ); } } // Update rate limit if provided. if ( isset( $data['rate_limit'] ) ) { agentorie_chat_update_setting( 'rate_limit', (int) $data['rate_limit'] ); } // Update demo counts if provided (harmonized with class-api-saas.php). if ( isset( $data['demos'] ) && is_array( $data['demos'] ) ) { if ( isset( $data['demos']['voice_active'] ) ) { agentorie_chat_update_setting( 'demo_voice_active', (bool) $data['demos']['voice_active'] ); } if ( isset( $data['demos']['voice_remaining'] ) ) { agentorie_chat_update_setting( 'demo_voice_remaining', (int) $data['demos']['voice_remaining'] ); } if ( isset( $data['demos']['lang_remaining'] ) ) { agentorie_chat_update_setting( 'demo_lang_remaining', (int) $data['demos']['lang_remaining'] ); } // Store the active demo language for flag display. if ( array_key_exists( 'lang_active', $data['demos'] ) ) { $lang_active = $data['demos']['lang_active']; agentorie_chat_update_setting( 'demo_lang_active', $lang_active ? sanitize_text_field( $lang_active ) : '' ); } } // Update voice config if provided (included in features.updated to avoid double webhook). if ( isset( $data['voice_config'] ) && is_array( $data['voice_config'] ) ) { if ( isset( $data['voice_config']['voice_enabled'] ) ) { agentorie_chat_update_setting( 'voice_enabled', (bool) $data['voice_config']['voice_enabled'] ); } if ( isset( $data['voice_config']['stt_enabled'] ) ) { agentorie_chat_update_setting( 'speech_to_text', (bool) $data['voice_config']['stt_enabled'] ); } if ( isset( $data['voice_config']['tts_enabled'] ) ) { agentorie_chat_update_setting( 'text_to_speech', (bool) $data['voice_config']['tts_enabled'] ); } if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'Voice config updated via webhook', $data['voice_config'] ); } } // Update usage stats if provided (test_conversations for admin quota display). if ( isset( $data['usage'] ) && is_array( $data['usage'] ) ) { if ( isset( $data['usage']['test_conversations'] ) ) { agentorie_chat_update_setting( 'test_conversations_count', (int) $data['usage']['test_conversations'] ); } } // Update authorized languages if provided. if ( isset( $data['authorized_languages'] ) && is_array( $data['authorized_languages'] ) ) { $languages = array_map( 'sanitize_text_field', $data['authorized_languages'] ); agentorie_chat_update_setting( 'authorized_languages', $languages ); if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'Authorized languages updated via webhook', array( 'languages' => $languages, ) ); } } // Update active languages from pack if provided. if ( isset( $data['active_languages'] ) && is_array( $data['active_languages'] ) ) { $active_langs = array_map( 'sanitize_text_field', $data['active_languages'] ); agentorie_chat_update_setting( 'active_languages', $active_langs ); } return true; } /** * Handle config update event. * * @since 1.0.0 * @param array $data Event data. * @return bool|WP_Error */ private function handle_config_update( $data ) { // Invalidate any cached config. if ( class_exists( 'Agentorie_API' ) ) { Agentorie_API::invalidate_license_cache(); } // Update voice settings if provided. if ( isset( $data['voice_enabled'] ) ) { agentorie_chat_update_setting( 'voice_enabled', (bool) $data['voice_enabled'] ); } if ( isset( $data['stt_enabled'] ) ) { agentorie_chat_update_setting( 'speech_to_text', (bool) $data['stt_enabled'] ); } if ( isset( $data['tts_enabled'] ) ) { agentorie_chat_update_setting( 'text_to_speech', (bool) $data['tts_enabled'] ); } if ( class_exists( 'Agentorie_Logger' ) ) { Agentorie_Logger::info( 'Config updated via webhook', array( 'voice_enabled' => isset( $data['voice_enabled'] ) ? $data['voice_enabled'] : 'unchanged', 'stt_enabled' => isset( $data['stt_enabled'] ) ? $data['stt_enabled'] : 'unchanged', 'tts_enabled' => isset( $data['tts_enabled'] ) ? $data['tts_enabled'] : 'unchanged', ) ); } return true; } /** * Get webhook URL for this site. * * @since 1.0.0 * @return string Webhook URL. */ public static function get_webhook_url() { return rest_url( 'agentorie-chat/v1/webhook' ); } }