api = new ActoSync_API(); $this->context = new ActoSync_Context(); // AJAX handlers must be registered for all contexts, including admin-ajax.php. add_action('wp_ajax_nopriv_actosync_send_message', [$this, 'handle_ajax_message']); add_action('wp_ajax_actosync_send_message', [$this, 'handle_ajax_message']); add_action('wp_ajax_nopriv_actosync_get_suggestions', [$this, 'handle_ajax_suggestions']); add_action('wp_ajax_actosync_get_suggestions', [$this, 'handle_ajax_suggestions']); // Shortcode can be registered globally. add_shortcode('actosync_chat_widget', [$this, 'render_shortcode']); // Frontend-only hooks. if (!is_admin()) { add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']); add_action('wp_footer', [$this, 'render_widget_in_footer']); } } /** * Enqueue frontend assets (CSS, JS) */ public function enqueue_assets() { if (is_feed()) { return; } // Only load if properly configured if (!$this->api->is_configured()) { return; } // Enqueue JavaScript wp_enqueue_script( 'actosync-chat-frontend', ACTOSYNC_PLUGIN_URL . 'assets/js/chat.js', [], ACTOSYNC_PLUGIN_VERSION, true ); // Localize script with configuration $config = $this->api->get_config(); $page_context = $this->context->collect(); wp_localize_script('actosync-chat-frontend', 'ActoSyncChatConfig', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('actosync_chat_nonce'), 'widgetId' => sanitize_text_field($config['widget_id'] ?? ''), 'agentId' => sanitize_text_field($config['agent_id'] ?? ''), 'userId' => sanitize_text_field($config['user_id'] ?? ''), 'agentName' => sanitize_text_field($config['agent_name'] ?? ''), 'pageContext' => $page_context, 'suggestions' => [ 'enabled' => !empty($config['enable_smart_suggestions']), 'maxChips' => (int) ($config['max_suggestion_chips'] ?? 5), 'showOnClosed' => !empty($config['suggest_on_widget_closed']), 'autoRefresh' => !empty($config['auto_refresh_suggestions']), 'followupSuggestions' => !empty($config['followup_suggestions']), 'productRecommendations' => !empty($config['product_recommendations']), 'crossSell' => !empty($config['cross_sell_suggestions']), 'upsell' => !empty($config['upsell_suggestions']), 'personalize' => !empty($config['personalize_suggestions']), 'delaySeconds' => (int) ($config['suggestion_delay_seconds'] ?? 3), ], ]); // Enqueue CSS wp_enqueue_style( 'actosync-chat-style', ACTOSYNC_PLUGIN_URL . 'assets/css/chat.css', [], ACTOSYNC_PLUGIN_VERSION ); } /** * Render shortcode */ public function render_shortcode($atts = []) { $atts = shortcode_atts([ 'widget_id' => '', 'agent_id' => '', 'user_id' => '', ], $atts, 'actosync_chat_widget'); $config = $this->api->get_config(); $widget_id = sanitize_text_field((string) ($atts['widget_id'] ?: ($config['widget_id'] ?? ''))); $agent_id = sanitize_text_field((string) ($atts['agent_id'] ?: ($config['agent_id'] ?? ''))); $user_id = sanitize_text_field((string) ($atts['user_id'] ?: ($config['user_id'] ?? ''))); $agent_name = sanitize_text_field((string) ($config['agent_name'] ?? 'AI Assistant')); // VIP: strict type validation foreach ([['widget_id', $widget_id], ['agent_id', $agent_id], ['user_id', $user_id]] as [$label, $val]) { if ($val === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $val)) { return ''; } } return wp_kses_post($this->build_widget_markup([ 'widget_id' => $widget_id, 'agent_id' => $agent_id, 'user_id' => $user_id, 'agent_name' => $agent_name, ])); } /** * VIP-safe widget markup builder */ private function build_widget_markup(array $data) { $widget_id = esc_attr($data['widget_id']); $agent_id = esc_attr($data['agent_id']); $user_id = esc_attr($data['user_id']); $agent_name = esc_attr($data['agent_name']); return sprintf( '
', $widget_id, $widget_id, $agent_id, $user_id, $agent_name ); } /** * Render widget in footer if auto-insert enabled */ public function render_widget_in_footer() { $config = $this->api->get_config(); if (empty($config['auto_insert'])) { return; } $widget_id = sanitize_text_field((string) ($config['widget_id'] ?? '')); $agent_id = sanitize_text_field((string) ($config['agent_id'] ?? '')); $user_id = sanitize_text_field((string) ($config['user_id'] ?? '')); // VIP: strict type validation foreach ([['widget_id', $widget_id], ['agent_id', $agent_id], ['user_id', $user_id]] as [$label, $val]) { if ($val === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $val)) { return; } } echo wp_kses_post($this->build_widget_markup([ 'widget_id' => $widget_id, 'agent_id' => $agent_id, 'user_id' => $user_id, 'agent_name' => sanitize_text_field((string) ($config['agent_name'] ?? 'AI Assistant')), ])); } /** * Handle AJAX message requests */ public function handle_ajax_message() { // VIP: lightweight rate limiting $remote_addr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; $rate_key = 'actosync_rate_' . md5($remote_addr); if (!get_transient($rate_key)) { set_transient($rate_key, 1, 2); // 2 sec throttle } else { wp_send_json_error(['message' => 'Too many requests'], 429); } $nonce_valid = check_ajax_referer('actosync_chat_nonce', 'nonce', false); if (!$nonce_valid) { wp_send_json_error(['message' => 'Security check failed. Please refresh the page and try again.'], 403); } $question = sanitize_text_field(wp_unslash($_POST['question'] ?? '')); $session_id = sanitize_text_field(wp_unslash($_POST['session_id'] ?? '')); $agent_id = sanitize_text_field(wp_unslash($_POST['agent_id'] ?? '')); $user_id = sanitize_text_field(wp_unslash($_POST['user_id'] ?? '')); $widget_id = sanitize_text_field(wp_unslash($_POST['widget_id'] ?? '')); $config = $this->api->get_config(); // VIP: strict type validation for IDs foreach ([['widget_id', $widget_id], ['agent_id', $agent_id], ['user_id', $user_id], ['session_id', $session_id]] as [$label, $val]) { if ($val === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $val)) { wp_send_json_error(['message' => 'Invalid ' . $label], 400); } } if ($question === '') { wp_send_json_error(['message' => 'Missing required parameters']); } $api_token = $config['api_token'] ?? ''; if (empty($api_token)) { wp_send_json_error(['message' => 'Server configuration error']); } $raw_client_context = ''; if (isset($_POST['context'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and sanitized below. $raw_client_context = wp_unslash($_POST['context']); } $payload = [ 'question' => $question, 'session_id' => $session_id, 'agent_id' => $agent_id, 'user_id' => $user_id, 'widget_id' => $widget_id, 'context' => $this->merge_client_context( $this->context->collect(), $this->parse_client_context($raw_client_context) ), ]; $suggestion_flags = $this->get_suggestion_flags($config); if (!empty($suggestion_flags)) { $payload['suggestion_settings'] = $suggestion_flags; } $request_origin = !empty($_SERVER['HTTP_ORIGIN']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_ORIGIN'])) : ''; $request_referer = !empty($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; if ($request_origin === '' && $request_referer !== '') { $referer_parts = wp_parse_url($request_referer); if (!empty($referer_parts['scheme']) && !empty($referer_parts['host'])) { $request_origin = $referer_parts['scheme'] . '://' . $referer_parts['host']; } } if ($request_origin === '') { $request_origin = home_url(); } $payload['origin'] = $request_origin; if ($request_referer !== '') { $payload['referer'] = $request_referer; } $response = $this->api->send_message($payload, $api_token, $widget_id); if (is_wp_error($response)) { $error_message = $response->get_error_message(); if (empty($error_message)) { $error_message = 'Unable to get response from chat service'; } wp_send_json_error(['message' => $error_message]); } wp_send_json_success($response); } /** * Handle AJAX suggestion requests for the AI Smart Suggestions engine. */ public function handle_ajax_suggestions() { $remote_addr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; $rate_key = 'actosync_suggest_rate_' . md5($remote_addr); if (!get_transient($rate_key)) { set_transient($rate_key, 1, 2); } else { wp_send_json_error(['message' => 'Too many requests'], 429); } $nonce_valid = check_ajax_referer('actosync_chat_nonce', 'nonce', false); if (!$nonce_valid) { wp_send_json_error(['message' => 'Security check failed. Please refresh the page and try again.'], 403); } $session_id = sanitize_text_field(wp_unslash($_POST['session_id'] ?? '')); $agent_id = sanitize_text_field(wp_unslash($_POST['agent_id'] ?? '')); $user_id = sanitize_text_field(wp_unslash($_POST['user_id'] ?? '')); $widget_id = sanitize_text_field(wp_unslash($_POST['widget_id'] ?? '')); $config = $this->api->get_config(); if (empty($config['enable_smart_suggestions'])) { wp_send_json_success(['questions' => []]); } foreach ([['widget_id', $widget_id], ['agent_id', $agent_id], ['user_id', $user_id], ['session_id', $session_id]] as [$label, $val]) { if ($val === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $val)) { wp_send_json_error(['message' => 'Invalid ' . $label], 400); } } $api_token = $config['api_token'] ?? ''; if (empty($api_token)) { wp_send_json_error(['message' => 'Server configuration error']); } $raw_client_context = ''; if (isset($_POST['context'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and sanitized below. $raw_client_context = wp_unslash($_POST['context']); } $client_context = $this->parse_client_context($raw_client_context); $payload = array_merge( $this->merge_client_context($this->context->collect(), $client_context), [ 'session_id' => $session_id, 'agent_id' => $agent_id, 'user_id' => $user_id, 'widget_id' => $widget_id, 'conversation_id' => $session_id, 'suggestion_settings' => $this->get_suggestion_flags($config), ] ); $request_origin = !empty($_SERVER['HTTP_ORIGIN']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_ORIGIN'])) : ''; $request_referer = !empty($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; if ($request_origin === '' && $request_referer !== '') { $referer_parts = wp_parse_url($request_referer); if (!empty($referer_parts['scheme']) && !empty($referer_parts['host'])) { $request_origin = $referer_parts['scheme'] . '://' . $referer_parts['host']; } } if ($request_origin === '') { $request_origin = home_url(); } $payload['origin'] = $request_origin; if ($request_referer !== '') { $payload['referer'] = $request_referer; } $response = $this->api->get_suggestions($payload, $api_token, $widget_id); if (is_wp_error($response)) { wp_send_json_success(['questions' => []]); } wp_send_json_success($response); } /** * Parse and sanitize optional client context JSON from the widget. * * @param string $raw_context * @return array */ private function parse_client_context($raw_context) { if (!is_string($raw_context) || $raw_context === '') { return []; } $decoded = json_decode($raw_context, true); if (!is_array($decoded)) { return []; } return $this->sanitize_client_context($decoded); } /** * Sanitize shopping context fields received from the widget. * * @param array $context * @return array */ private function sanitize_client_context(array $context) { $sanitized = []; if (!empty($context['page']) && is_string($context['page'])) { $sanitized['page'] = sanitize_key($context['page']); } if (!empty($context['page_url']) && is_string($context['page_url'])) { $sanitized['page_url'] = esc_url_raw($context['page_url']); } if (!empty($context['category']) && is_string($context['category'])) { $sanitized['category'] = sanitize_text_field($context['category']); } if (isset($context['product_id'])) { $sanitized['product_id'] = absint($context['product_id']); } if (isset($context['category_id'])) { $sanitized['category_id'] = absint($context['category_id']); } if (!empty($context['search_term']) && is_string($context['search_term'])) { $sanitized['search_term'] = sanitize_text_field($context['search_term']); } if (!empty($context['language']) && is_string($context['language'])) { $sanitized['language'] = sanitize_text_field($context['language']); } if (!empty($context['country']) && is_string($context['country'])) { $sanitized['country'] = sanitize_text_field($context['country']); } if (!empty($context['device']) && is_string($context['device'])) { $sanitized['device'] = sanitize_key($context['device']); } if (!empty($context['cart']) && is_array($context['cart'])) { $sanitized['cart'] = array_values(array_filter(array_map('absint', $context['cart']))); } if (!empty($context['catalog_categories']) && is_array($context['catalog_categories'])) { $sanitized['catalog_categories'] = array_values( array_filter(array_map('sanitize_text_field', $context['catalog_categories'])) ); } if (!empty($context['customer_id'])) { $sanitized['customer_id'] = absint($context['customer_id']); } if (!empty($context['product']) && is_array($context['product'])) { $product = $context['product']; $sanitized['product'] = [ 'id' => isset($product['id']) ? absint($product['id']) : 0, 'name' => !empty($product['name']) ? sanitize_text_field($product['name']) : '', 'price' => isset($product['price']) ? (float) $product['price'] : 0, 'sku' => !empty($product['sku']) ? sanitize_text_field($product['sku']) : '', 'in_stock' => !empty($product['in_stock']), 'on_sale' => !empty($product['on_sale']), 'has_description' => !empty($product['has_description']), 'has_variations' => !empty($product['has_variations']), 'categories' => [], 'tags' => [], 'brands' => [], 'options' => [], ]; foreach (['categories', 'tags', 'brands'] as $list_key) { if (!empty($product[$list_key]) && is_array($product[$list_key])) { $sanitized['product'][$list_key] = array_values( array_filter(array_map('sanitize_text_field', $product[$list_key])) ); } } if (!empty($product['options']) && is_array($product['options'])) { foreach ($product['options'] as $option_name => $option_values) { $label = sanitize_text_field((string) $option_name); if ($label === '' || !is_array($option_values)) { continue; } $values = array_values(array_filter(array_map('sanitize_text_field', $option_values))); if (!empty($values)) { $sanitized['product']['options'][$label] = $values; } } } } return $sanitized; } /** * Merge optional client-provided context with server-collected context. * * @param array $server_context * @param array $client_context * @return array */ private function merge_client_context(array $server_context, array $client_context = []) { if (empty($client_context)) { return $server_context; } return array_merge($server_context, $client_context); } /** * Build suggestion behavior flags for the backend engine. * * @param array $config * @return array */ private function get_suggestion_flags(array $config) { if (empty($config['enable_smart_suggestions'])) { return []; } return [ 'enabled' => true, 'max_chips' => (int) ($config['max_suggestion_chips'] ?? 5), 'followup_suggestions' => !empty($config['followup_suggestions']), 'product_recommendations' => !empty($config['product_recommendations']), 'cross_sell' => !empty($config['cross_sell_suggestions']), 'upsell' => !empty($config['upsell_suggestions']), 'personalize' => !empty($config['personalize_suggestions']), ]; } }