* @license GPL-2.0+ https://www.gnu.org/licenses/gpl-2.0.html * @link https://aisk.chat */ if ( ! defined('ABSPATH') ) { exit; } /** * AISK Chat Handler Class * * Handles all chat related functionality including message processing, * intent classification, and conversation management. * * @category Class * @package AISK * @author AISK Team * @license GPL-2.0+ https://www.gnu.org/licenses/gpl-2.0.html * @link https://aisk.chat */ class AISK_Chat_Handler { /** * Minimum weighted similarity for a product to be shown as a product card. */ const PRODUCT_MATCH_THRESHOLD = 0.45; /** * Content types that make up the store knowledge base (everything except products). */ const KNOWLEDGE_CONTENT_TYPES = [ 'settings', 'page', 'post', 'external_url', 'pdf' ]; private $embeddings_handler; private $product_handler; private $order_handler; private $chat_storage; private $usage_tracker; private $openai_key; private $chat_model; /** * Constructor for the Chat Handler * * Initializes handlers and loads required settings * * @since 1.0.0 * * @return void */ public function __construct() { $this->embeddings_handler = new AISK_Embeddings_Handler(); $this->product_handler = new AISK_Product_Handler(); $this->order_handler = new AISK_Order_Handler(); $this->chat_storage = AISK_Chat_Storage::get_instance(); $this->usage_tracker = class_exists('AISK_API_Usage_Tracker') ? AISK_API_Usage_Tracker::get_instance() : null; $settings = get_option('aisk_settings'); // auth_key deprecated and no longer required $this->openai_key = isset($settings['general']['openai_key']) ? $settings['general']['openai_key'] : ''; $this->chat_model = AISK_Model_Manager::get_chat_model(); add_action('rest_api_init', [ $this, 'register_routes' ]); } /** * Register REST API routes * * Sets up all the REST API endpoints for chat functionality * * @since 1.0.0 * * @return void */ public function register_routes() { // Chat endpoint - Public access with nonce verification register_rest_route( 'aisk/v1', '/chat', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_chat_request' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], ] ); // Auth endpoint - Public access with nonce verification register_rest_route( 'aisk/v1', '/auth', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_auth_request' ], 'permission_callback' => [ $this, 'verify_auth_request' ], ] ); // Conversations endpoints - Authenticated access required register_rest_route( 'aisk/v1', '/conversations', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_conversations' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_conversation' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], ], ] ); // Single conversation endpoint - Authenticated access required register_rest_route( 'aisk/v1', '/conversations/(?P[a-zA-Z0-9-]+)', [ 'methods' => 'GET', 'callback' => [ $this, 'get_conversation' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], 'args' => [ 'id' => [ 'required' => true, 'validate_callback' => function ( $param ) { return is_string($param) && ! empty($param); }, ], ], ] ); // Messages endpoint - Authenticated access required register_rest_route( 'aisk/v1', '/messages/(?P[a-zA-Z0-9-]+)', [ 'methods' => 'GET', 'callback' => [ $this, 'get_messages' ], 'permission_callback' => [ $this, 'verify_conversation_access' ], 'args' => [ 'conversation_id' => [ 'required' => true, 'validate_callback' => function ( $param ) { return is_string($param) && ! empty($param); }, ], ], ] ); // Submit inquiry endpoint - Public access with nonce verification register_rest_route( 'aisk/v1', '/submit-inquiry', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_inquiry_submission' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], ] ); // Classify Intent endpoint - Public access with nonce verification register_rest_route( 'aisk/v1', '/classify-intent', [ 'methods' => 'POST', 'callback' => [ $this, 'classify_intent' ], 'permission_callback' => [ $this, 'verify_chat_request_authenticated' ], 'args' => [ 'message' => [ 'required' => true, 'type' => 'string', ], 'conversation_history' => [ 'required' => true, 'type' => 'array', ], 'conversation_id' => [ 'default' => '', 'type' => 'string', ], ], ] ); // Admin-only endpoints register_rest_route( 'aisk/v1', '/inquiries', [ 'methods' => 'GET', 'callback' => [ $this, 'get_inquiries' ], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'page' => [ 'default' => 1, 'sanitize_callback' => 'absint', ], 'per_page' => [ 'default' => 20, 'sanitize_callback' => 'absint', ], 'status' => [ 'validate_callback' => function ( $param ) { return in_array($param, [ 'pending', 'in_progress', 'resolved', '' ]); }, ], 'search' => [ 'sanitize_callback' => 'sanitize_text_field', ], 'start_date' => [ 'sanitize_callback' => 'sanitize_text_field', ], 'end_date' => [ 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); // Get single inquiry details register_rest_route( 'aisk/v1', '/inquiries/(?P\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'get_inquiry_details' ], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'id' => [ 'validate_callback' => function ( $param ) { return is_numeric($param); }, ], ], ] ); // Get inquiry notes register_rest_route( 'aisk/v1', '/inquiries/(?P\d+)/notes', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_inquiry_notes' ], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ], [ 'methods' => 'POST', 'callback' => [ $this, 'add_inquiry_note' ], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ], ] ); // Update inquiry status register_rest_route( 'aisk/v1', '/inquiries/(?P\d+)/status', [ 'methods' => 'POST', 'callback' => [ $this, 'update_inquiry_status' ], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'id' => [ 'validate_callback' => function ( $param ) { return is_numeric($param); }, ], 'status' => [ 'required' => true, 'validate_callback' => function ( $param ) { return in_array($param, [ 'pending', 'in_progress', 'resolved' ]); }, ], ], ] ); } /** * Verify chat request permission * * @return bool Whether the request is authorized */ public function verify_chat_request_authenticated() { // Every chat request must carry a valid REST nonce, for guests and logged-in users alike $nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ) : ''; if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return false; } return true; } /** * Verify auth request permission * * @return bool Whether the request is authorized */ public function verify_auth_request() { // Verify nonce $nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ) : ''; return ! empty( $nonce ) && wp_verify_nonce( $nonce, 'wp_rest' ); } /** * Verify conversation access permission * * @param WP_REST_Request $request The request object * @return bool Whether the user has access to the conversation */ public function verify_conversation_access($request) { if ( ! $this->verify_chat_request_authenticated() ) { return false; } $conversation_id = $request->get_param('conversation_id'); if (empty($conversation_id)) { return false; } $conversation = $this->chat_storage->get_conversation($conversation_id); if (!$conversation) { return false; } return $this->user_owns_conversation($conversation); } /** * Whether the current visitor owns the given conversation * * Admins can access every conversation, logged-in users only their own, * and guests only conversations started from the same IP and browser. * * @param object|array $conversation Conversation row * @return bool */ private function user_owns_conversation($conversation) { $conversation = (object) $conversation; if (current_user_can('manage_options')) { return true; } $user_id = get_current_user_id(); if ($user_id) { return isset($conversation->user_id) && (int) $conversation->user_id === $user_id; } $ip_address = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; return empty($conversation->user_id) && '' !== $ip_address && isset($conversation->ip_address, $conversation->user_agent) && hash_equals((string) $conversation->ip_address, $ip_address) && hash_equals((string) $conversation->user_agent, $user_agent); } /** * Handle incoming chat requests * * Processes chat messages, determines intent, and returns appropriate responses * * @param WP_REST_Request $request The incoming request object * * @since 1.0.0 * * @return array | WP_Error Response object or error */ public function handle_chat_request( $request ) { if ( empty($this->openai_key) ) { return new WP_Error('no_openai_key', 'OpenAI key required. Please configure in the settings.', [ 'status' => 400 ]); } $params = $request->get_params(); if ( empty($params['message']) ) { return new WP_Error('no_message', 'No message provided', [ 'status' => 400 ]); } $message = sanitize_text_field($params['message']); $conversation_id = isset($params['conversation_id']) ? sanitize_text_field($params['conversation_id']) : ''; // Check for quick responses first $quick_response = $this->get_quick_response($message); if ($quick_response) { // Store user message if ($conversation_id) { $this->chat_storage->add_message($conversation_id, 'user', $message); // Store bot response $this->chat_storage->add_message($conversation_id, 'bot', $quick_response); } return [ 'message' => $quick_response, 'is_quick_response' => true ]; } // Get conversation to determine platform $conversation = null; if ( $conversation_id ) { $conversation = $this->chat_storage->get_conversation($conversation_id); $this->chat_storage->add_message($conversation_id, 'user', $message); } // Determine platform from conversation $platform = 'web'; // default if ($conversation && isset($conversation->platform)) { $platform = $conversation->platform; } // Process based on intent try { $intent = $this->classify_intent_internal($message, $conversation_id); if ( is_wp_error($intent) ) { return [ 'message' => $intent->get_error_message(), 'status' => $intent->get_error_data()['status'], ]; } // Deterministic escalation detection to avoid missing users asking for human help. $escalation_detection = $this->detect_human_assistance_intent($message, $conversation_id); if (!empty($escalation_detection['matched'])) { $intent['intent_type'] = 'contact_support'; $intent['response_type'] = 'form'; $intent['escalation_reason'] = $escalation_detection['reason']; } // Check WooCommerce state for product-related intents $settings = get_option('aisk_settings'); $is_woocommerce_enabled = !empty($settings['ai_config']['woocommerce_enabled']); if (!$is_woocommerce_enabled && in_array($intent['intent_type'], ['product_search', 'product_info_search'])) { return [ 'message' => __("Product search is currently disabled. Please enable WooCommerce integration in the settings.", 'aisk-ai-chat'), 'products' => [] ]; } $response = null; switch ( $intent['intent_type'] ) { case 'product_search': $content_type = [ 'product' ]; $response = $this->handle_product_search( $intent, $message, $content_type, $conversation_id, $platform ); break; case 'product_info_search': $content_type = [ 'product' ]; $response = $this->handle_product_info_search($message, $conversation_id, $content_type, $platform); break; case 'order_status': $response = $this->handle_order_status_request($intent); break; case 'contact_support': // Search the whole knowledge base so policy questions (returns, refunds) still get a real answer. $content_type = self::KNOWLEDGE_CONTENT_TYPES; $contact_details = $this->handle_general_query($message, $conversation_id, $content_type, $platform); // Check if contact form is enabled from settings $settings = get_option('aisk_settings'); $is_contact_form_enabled = !empty($settings['integrations']['contact_form']['enabled']); // Get the response type from the intent $response_type = isset($intent['response_type']) ? $intent['response_type'] : 'info'; // Force contact form for inferred human-escalation intent. $should_force_contact_form = $this->should_force_contact_form( $message, $intent, isset($escalation_detection) ? $escalation_detection : [] ); if ($should_force_contact_form) { $response_type = 'form'; } // Only show form if explicitly requested and enabled $should_show_form = $is_contact_form_enabled && $response_type === 'form'; $response_message = isset($contact_details['message']) ? $contact_details['message'] : ''; if ($should_show_form && $should_force_contact_form) { $lead_in = __('It sounds like you may want to talk to a real person - you can use the contact form below.', 'aisk-ai-chat'); $response_message = trim($lead_in . ' ' . $response_message); } // Prepare the response $response = [ 'message' => $response_message, 'type' => 'contact_support', 'support' => $should_show_form, 'response_type' => $response_type, 'escalation_reason' => isset($intent['escalation_reason']) ? $intent['escalation_reason'] : null ]; break; case 'general_conversation': $content_type = ['settings']; $response = $this->handle_general_query($message, $conversation_id, $content_type, $platform); break; case 'general_inquiries': $content_type = self::KNOWLEDGE_CONTENT_TYPES; $response = $this->handle_general_query($message, $conversation_id, $content_type, $platform); break; default: $content_type = [ 'external_url', 'pdf', 'page', 'post', 'settings' ]; $response = $this->handle_general_query($message, $conversation_id, $content_type, $platform); } // Store bot response if ( $conversation_id && !empty($response['message']) ) { // Ensure we have a valid message type $message_type = 'bot'; // Prepare metadata based on response type $metadata = [ 'products' => isset($response['products']) ? $response['products'] : null, 'order' => isset($response['order']) ? $response['order'] : null, 'type' => isset($response['type']) ? $response['type'] : null, 'support' => isset($response['support']) ? $response['support'] : null, 'response_type' => isset($response['response_type']) ? $response['response_type'] : null, 'contact_info' => isset($response['contact_info']) ? $response['contact_info'] : null, 'form_fields' => isset($response['form_fields']) ? $response['form_fields'] : null, 'escalation_reason' => isset($response['escalation_reason']) ? $response['escalation_reason'] : null ]; // Store the message with metadata $this->chat_storage->add_message( $conversation_id, $message_type, $response['message'], $metadata ); } // Ensure order data is included in the response if (isset($response['order'])) { $response['order_data'] = $response['order']; } return $response; } catch ( Exception $e ) { return new WP_Error( 'processing_failed', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Get contact information from settings * * @return array Contact information */ private function get_contact_info() { $settings = get_option('aisk_settings'); return [ 'email' => isset($settings['contact']['email']) ? $settings['contact']['email'] : '', 'phone' => isset($settings['contact']['phone']) ? $settings['contact']['phone'] : '', 'hours' => isset($settings['contact']['hours']) ? $settings['contact']['hours'] : '', 'address' => isset($settings['contact']['address']) ? $settings['contact']['address'] : '', ]; } /** * Get quick response for common queries * * @param string $message The user's message * @return string|null Quick response or null if no quick response available */ private function get_quick_response($message) { $lower_message = strtolower(trim($message)); // Professional greeting responses if (in_array($lower_message, ['hi', 'hello', 'hey', 'hi there', 'hello there']) || strpos($lower_message, 'hi ') === 0 || strpos($lower_message, 'hello ') === 0) { return __( 'Hello! Welcome to our support chat. How may I assist you today?', 'aisk-ai-chat' ); } // Time-based greetings if (in_array($lower_message, ['good morning', 'morning'])) { return __( "Good morning! I hope you're having a great start to your day. How can I help you?", 'aisk-ai-chat' ); } if (in_array($lower_message, ['good afternoon', 'afternoon'])) { return __( 'Good afternoon! Thank you for reaching out. What can I do for you today?', 'aisk-ai-chat' ); } if (in_array($lower_message, ['good evening', 'evening'])) { return __( "Good evening! I'm here to help with any questions you may have.", 'aisk-ai-chat' ); } if (in_array($lower_message, ['good night', 'night'])) { return __( "Good night! If you have any urgent questions, I'm here to help.", 'aisk-ai-chat' ); } // Professional goodbye responses if (strpos($lower_message, 'bye') !== false || strpos($lower_message, 'goodbye') !== false || strpos($lower_message, 'see you') !== false || strpos($lower_message, 'farewell') !== false || in_array($lower_message, ['bye', 'goodbye', 'see ya', 'take care'])) { return __( 'Thank you for contacting us today. Have a wonderful day and feel free to reach out anytime!', 'aisk-ai-chat' ); } // How are you responses if (strpos($lower_message, 'how are you') !== false || strpos($lower_message, 'how\'s it going') !== false || strpos($lower_message, 'how do you do') !== false || $lower_message === 'how are you?' || strpos($lower_message, 'what\'s up') !== false || strpos($lower_message, 'how\'s your day') !== false) { return __( "I'm doing well, thank you for asking! I'm here and ready to assist you with any questions or concerns you may have.", 'aisk-ai-chat' ); } // Help requests if ($lower_message === 'help' || $lower_message === 'help me' || strpos($lower_message, 'i need help') === 0 || strpos($lower_message, 'can you help') === 0 || strpos($lower_message, 'i need assistance') === 0) { return __( "I'd be happy to help you! I can assist with product information, order inquiries, technical support, and general questions. What specific area do you need help with?", 'aisk-ai-chat' ); } return null; // No quick response available } /** * Handle inquiry submission * * @param WP_REST_Request $request The incoming request object * @return array|WP_Error Success response or error */ public function handle_inquiry_submission($request) { $params = $request->get_params(); $required_params = ['note', 'order_number', 'conversation_id']; $missing_params = array_filter( $required_params, function($param) use ($params) { return empty($params[$param]); } ); if (!empty($missing_params)) { return new WP_Error( 'missing_params', 'Missing required parameters: ' . implode(', ', $missing_params), ['status' => 400] ); } // Get customer details from order $order = wc_get_order($params['order_number']); if (!$order) { return new WP_Error('invalid_order', 'Order not found', ['status' => 404]); } // @codingStandardsIgnoreStart global $wpdb; $result = $wpdb->insert( $wpdb->prefix . 'aisk_inquiries', [ 'conversation_id' => $params['conversation_id'], 'order_number' => $params['order_number'], 'customer_email' => $order->get_billing_email(), 'customer_phone' => $order->get_billing_phone(), 'note' => sanitize_textarea_field($params['note']), 'status' => 'pending', 'created_at' => gmdate('Y-m-d H:i:s'), 'updated_at' => gmdate('Y-m-d H:i:s'), ] ); // @codingStandardsIgnoreEnd if (!$result) { return new WP_Error('db_error', 'Failed to save inquiry', ['status' => 500]); } // Clear any related caches wp_cache_delete('aisk_inquiries_' . md5(serialize(['conversation_id' => $params['conversation_id']])), 'aisk_inquiries'); wp_cache_delete('aisk_inquiries_total_' . md5(serialize([])), 'aisk_inquiries'); return [ 'success' => true, 'message' => 'Inquiry submitted successfully', ]; } /** * Get list of inquiries * * Retrieves paginated list of inquiries with optional filters * * @param WP_REST_Request $request The incoming request object * * @since 1.0.0 * * @return array List of inquiries with pagination data */ public function get_inquiries($request) { global $wpdb; // Get parameters $user_page = $request->get_param('page'); $user_per_page = $request->get_param('per_page'); $page = isset($user_page) ? $user_page : 1; $per_page = isset($user_per_page) ? $user_per_page : 20; $status = $request->get_param('status'); $search = $request->get_param('search'); $start_date = $request->get_param('start_date'); $end_date = $request->get_param('end_date'); $offset = ($page - 1) * $per_page; // Start building query $where_clauses = []; $where_values = []; // Add status filter if (!empty($status)) { $where_clauses[] = 'status = %s'; $where_values[] = $status; } // Add date range filter in server's local timezone if (!empty($start_date)) { $where_clauses[] = 'created_at >= %s'; // Format the start date with beginning of day $formatted_start_date = gmdate('Y-m-d H:i:s', strtotime($start_date . ' 00:00:00')); $where_values[] = $formatted_start_date; } if (!empty($end_date)) { $where_clauses[] = 'created_at <= %s'; // Format the end date with end of day $formatted_end_date = gmdate('Y-m-d H:i:s', strtotime($end_date . ' 23:59:59')); $where_values[] = $formatted_end_date; } // Add search filter if (!empty($search)) { $search_term = '%' . $wpdb->esc_like($search) . '%'; $where_clauses[] = '(order_number LIKE %s OR customer_email LIKE %s OR customer_phone LIKE %s)'; $where_values[] = $search_term; $where_values[] = $search_term; $where_values[] = $search_term; } // Combine the where clauses $where_sql = ''; if (!empty($where_clauses)) { $where_sql = 'WHERE ' . implode(' AND ', $where_clauses); } // Generate a unique cache key based on query parameters $cache_key = 'aisk_inquiries_' . md5(serialize([$where_sql, $where_values, $per_page, $offset])); // Try to get cached results $inquiries = wp_cache_get($cache_key, 'aisk_inquiries'); if (false === $inquiries) { // @codingStandardsIgnoreStart $inquiries = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aisk_inquiries $where_sql ORDER BY created_at DESC LIMIT %d OFFSET %d", array_merge($where_values, [$per_page, $offset]) )); // @codingStandardsIgnoreEnd wp_cache_set($cache_key, $inquiries, 'aisk_inquiries', 300); // Cache for 5 minutes } // Get total count for pagination $total_cache_key = 'aisk_inquiries_total_' . md5(serialize([$where_sql, $where_values])); $total = wp_cache_get($total_cache_key, 'aisk_inquiries'); if (false === $total) { // @codingStandardsIgnoreStart if (empty($where_values)) { $total = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->prefix}aisk_inquiries")); } else { $total = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->prefix}aisk_inquiries %s", $where_sql)); } // @codingStandardsIgnoreEnd wp_cache_set($total_cache_key, $total, 'aisk_inquiries', 300); // Cache for 5 minutes } // Convert created_at and updated_at to ISO 8601 with Z (UTC) for each inquiry if ($inquiries) { foreach ($inquiries as &$inquiry) { if (isset($inquiry->created_at)) { $inquiry->created_at = gmdate('Y-m-d\TH:i:s\Z', strtotime($inquiry->created_at)); } if (isset($inquiry->updated_at)) { $inquiry->updated_at = gmdate('Y-m-d\TH:i:s\Z', strtotime($inquiry->updated_at)); } } } return [ 'inquiries' => $inquiries ? $inquiries : [], 'total' => (int) $total, 'pages' => ceil($total / $per_page), 'current_page' => $page, ]; } /** * Get inquiry details * * @param WP_REST_Request $request The incoming request object * @return array|WP_Error Inquiry details or error */ public function get_inquiry_details($request) { global $wpdb; $inquiry_id = $request->get_param('id'); // Generate cache key $cache_key = 'aisk_inquiry_details_' . $inquiry_id; // Try to get cached inquiry $inquiry = wp_cache_get($cache_key, 'aisk_inquiries'); if (false === $inquiry) { // @codingStandardsIgnoreStart $inquiry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aisk_inquiries WHERE id = %d", $inquiry_id ) ); // @codingStandardsIgnoreEnd if ($inquiry) { wp_cache_set($cache_key, $inquiry, 'aisk_inquiries', 300); // Cache for 5 minutes } } if (!$inquiry) { return new WP_Error( 'inquiry_not_found', 'Inquiry not found', ['status' => 404] ); } // Convert created_at and updated_at to ISO 8601 UTC if (isset($inquiry->created_at)) { $inquiry->created_at = gmdate('Y-m-d\TH:i:s\Z', strtotime($inquiry->created_at)); } if (isset($inquiry->updated_at)) { $inquiry->updated_at = gmdate('Y-m-d\TH:i:s\Z', strtotime($inquiry->updated_at)); } // Get notes for this inquiry $notes = $this->get_inquiry_notes($request); // Convert created_at for each note if ($notes) { foreach ($notes as &$note) { if (isset($note->created_at)) { $note->created_at = gmdate('Y-m-d\TH:i:s\Z', strtotime($note->created_at)); } } } return [ 'inquiry' => $inquiry, 'notes' => $notes, ]; } /** * Get inquiry notes * * @param WP_REST_Request $request The incoming request object * @return array List of inquiry notes */ public function get_inquiry_notes($request) { global $wpdb; $inquiry_id = $request->get_param('id'); // Generate cache key $cache_key = 'aisk_inquiry_notes_' . $inquiry_id; // Try to get cached notes $notes = wp_cache_get($cache_key, 'aisk_inquiries'); if (false === $notes) { // @codingStandardsIgnoreStart $notes = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aisk_inquiry_notes WHERE inquiry_id = %d ORDER BY created_at DESC", $inquiry_id ) ); // @codingStandardsIgnoreEnd if ($notes) { wp_cache_set($cache_key, $notes, 'aisk_inquiries', 300); // Cache for 5 minutes } } return $notes; } /** * Add note to inquiry * * @param WP_REST_Request $request The incoming request object * @return array|WP_Error Success response or error */ public function add_inquiry_note($request) { global $wpdb; $inquiry_id = $request->get_param('id'); $note = sanitize_textarea_field($request->get_param('note')); $current_user = wp_get_current_user(); if (empty($note)) { return new WP_Error( 'empty_note', 'Note cannot be empty', ['status' => 400] ); } // @codingStandardsIgnoreStart $result = $wpdb->insert( $wpdb->prefix . 'aisk_inquiry_notes', [ 'inquiry_id' => $inquiry_id, 'note' => $note, 'author_id' => get_current_user_id(), 'author' => $current_user->display_name, 'created_at' => gmdate('Y-m-d H:i:s'), ], ['%d', '%s', '%d', '%s', '%s'] ); // @codingStandardsIgnoreEnd if (false === $result) { return new WP_Error( 'note_creation_failed', 'Failed to create note', ['status' => 500] ); } // Clear related caches wp_cache_delete('aisk_inquiry_notes_' . $inquiry_id, 'aisk_inquiries'); wp_cache_delete('aisk_inquiry_details_' . $inquiry_id, 'aisk_inquiries'); // Send email notification to customer $this->send_note_notification($inquiry_id, $note); return [ 'success' => true, 'note_id' => $wpdb->insert_id, ]; } /** * Send note notification * * @param int $inquiry_id The inquiry ID * @param string $note The note content * @return void */ private function send_note_notification($inquiry_id, $note) { global $wpdb; // Get inquiry details from cache or database $cache_key = 'aisk_inquiry_details_' . $inquiry_id; $inquiry = wp_cache_get($cache_key, 'aisk_inquiries'); if (false === $inquiry) { // @codingStandardsIgnoreStart $inquiry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aisk_inquiries WHERE id = %d", $inquiry_id ) ); // @codingStandardsIgnoreEnd if ($inquiry) { wp_cache_set($cache_key, $inquiry, 'aisk_inquiries', 300); } } if (!$inquiry) { return; } $to = $inquiry->customer_email; $subject = sprintf('Update on your inquiry #%d', $inquiry_id); $message = "Hello,\n\n"; $message .= "There has been an update on your inquiry:\n\n"; $message .= $note . "\n\n"; $message .= "Best regards,\n"; $message .= get_bloginfo('name'); wp_mail($to, $subject, $message); } /** * Update inquiry status * * @param WP_REST_Request $request The incoming request object * @return array|WP_Error Success response or error */ public function update_inquiry_status($request) { global $wpdb; $inquiry_id = $request->get_param('id'); $new_status = $request->get_param('status'); // Get current inquiry data from cache or database $cache_key = 'aisk_inquiry_details_' . $inquiry_id; $inquiry = wp_cache_get($cache_key, 'aisk_inquiries'); if (false === $inquiry) { // @codingStandardsIgnoreStart $inquiry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aisk_inquiries WHERE id = %d", $inquiry_id ) ); // @codingStandardsIgnoreEnd } if (!$inquiry) { return new WP_Error('inquiry_not_found', 'Inquiry not found', ['status' => 404]); } // Update status // @codingStandardsIgnoreStart $result = $wpdb->update( $wpdb->prefix . 'aisk_inquiries', [ 'status' => $new_status, 'updated_at' => gmdate('Y-m-d H:i:s'), ], ['id' => $inquiry_id], ['%s'], ['%d'] ); // @codingStandardsIgnoreEnd if (false === $result) { return new WP_Error('status_update_failed', 'Failed to update status', ['status' => 500]); } // Clear related caches wp_cache_delete($cache_key, 'aisk_inquiries'); wp_cache_delete('aisk_inquiry_notes_' . $inquiry_id, 'aisk_inquiries'); // Send email notification $this->send_status_notification($inquiry, $new_status); return [ 'success' => true, 'status' => $new_status, ]; } /** * Send status notification * * @param object $inquiry The inquiry object * @param string $new_status The new status * @return void */ private function send_status_notification($inquiry, $new_status) { $to = $inquiry->customer_email; $subject = sprintf('Update on your inquiry #%d', $inquiry->id); $status_messages = [ 'pending' => 'Your inquiry is pending review by our support team.', 'in_progress' => 'Our support team is currently working on your inquiry.', 'resolved' => 'Your inquiry has been resolved. Please let us know if you need any further assistance.', ]; $message = "Hello,\n\n"; $message .= 'The status of your inquiry has been updated to: ' . strtoupper($new_status) . "\n\n"; $message .= $status_messages[ $new_status ] . "\n\n"; $message .= 'Order Number: ' . $inquiry->order_number . "\n"; $message .= 'Original Inquiry: ' . $inquiry->note . "\n\n"; $message .= "Best regards,\n"; $message .= get_bloginfo('name'); wp_mail($to, $subject, $message); } /** * Create new conversation * * Creates a new chat conversation with user details * * @param WP_REST_Request $request The incoming request object * * @since 1.0.0 * * @return array New conversation details */ public function create_conversation( $request ) { $user_id = get_current_user_id(); $params = $request->get_params(); $conversation_data = [ 'user_id' => $user_id ? $user_id : null, 'user_name' => $user_id ? wp_get_current_user()->display_name : null, 'user_email' => $user_id ? wp_get_current_user()->user_email : null, 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : null, 'user_agent' => sanitize_text_field(isset($params['userAgent']) ? $params['userAgent'] : ''), 'page_url' => esc_url_raw(isset($params['url']) ? $params['url'] : ''), 'platform' => 'web', 'city' => isset($params['city']) ? sanitize_text_field($params['city']) : null, 'country' => isset($params['country']) ? sanitize_text_field($params['country']) : null, 'country_code' => isset($params['country_code']) ? sanitize_text_field($params['country_code']) : null, 'intents' => json_encode([]), ]; try { $conversation_id = $this->chat_storage->create_conversation($conversation_data); // Verify the conversation was created $created_conversation = $this->chat_storage->get_conversation($conversation_id); if (!$created_conversation) { return new WP_Error('conversation_creation_failed', 'Failed to verify conversation creation'); } // Add welcome message // $settings = get_option('aisk_settings'); // $welcome_message = isset($settings['general']['welcome_message']) // ? $settings['general']['welcome_message'] // : 'Hello! How can I assist you today?'; // $this->chat_storage->add_message($conversation_id, 'bot', $welcome_message); return [ 'conversation_id' => $conversation_id, 'created_at' => gmdate('c'), ]; } catch (Exception $e) { return new WP_Error('conversation_creation_failed', $e->getMessage()); } } /** * Update conversation intents * * @param string $conversation_id The conversation ID * @param array $intents The intents to store * @return int|false Number of rows affected or false on error */ public function update_conversation_intents($conversation_id, $intents) { // Generate cache key $cache_key = 'aisk_conversation_intents_' . $conversation_id; // @codingStandardsIgnoreStart global $wpdb; $result = $wpdb->update( $wpdb->prefix . 'aisk_conversations', [ 'intents' => json_encode($intents), 'updated_at' => gmdate('c'), ], ['conversation_id' => $conversation_id] ); // @codingStandardsIgnoreEnd if ($result !== false) { // Clear the cache after successful update wp_cache_delete($cache_key, 'aisk_conversations'); wp_cache_delete('aisk_conversations_' . md5(serialize(['conversation_id' => $conversation_id])), 'aisk_conversations'); } return $result; } /** * Get conversations * * @param WP_REST_Request $request The incoming request object * @return array List of conversations */ public function get_conversations($request) { global $wpdb; $page = max(1, intval($request->get_param('page'))); $per_page = max(10, intval($request->get_param('per_page'))); $location_filter = sanitize_text_field($request->get_param('location_filter')); $time_filter = sanitize_text_field($request->get_param('time_filter')); // Generate cache key based on request parameters $cache_key = 'aisk_conversations_' . md5(serialize([ 'page' => $page, 'per_page' => $per_page, 'location' => $location_filter, 'time' => $time_filter, 'user_id' => get_current_user_id(), 'is_admin' => current_user_can('manage_options') ])); // Try to get cached results $cached_data = wp_cache_get($cache_key, 'aisk_conversations'); if (false !== $cached_data) { return $cached_data; } if (current_user_can('manage_options')) { // Admin query $query = "SELECT * FROM {$wpdb->prefix}aisk_conversations WHERE 1=1"; $params = []; if ($location_filter !== 'all' && !empty($location_filter)) { $query .= " AND LOWER(city) = LOWER(%s)"; $params[] = $location_filter; } if (!empty($time_filter)) { $query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)"; $params[] = intval(str_replace('days', '', $time_filter)); } // @codingStandardsIgnoreStart $total_query = str_replace("SELECT *", "SELECT COUNT(*)", $query); $total = !empty($params) ? $wpdb->get_var($wpdb->prepare($total_query, ...$params)) : $wpdb->get_var($total_query); $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $per_page; $params[] = ($page - 1) * $per_page; $conversations = !empty($params) ? $wpdb->get_results($wpdb->prepare($query, ...$params)) : $wpdb->get_results($query); $locations_query = "SELECT DISTINCT city FROM {$wpdb->prefix}aisk_conversations WHERE city IS NOT NULL AND city != '' ORDER BY city ASC"; $locations = $wpdb->get_col($locations_query); // @codingStandardsIgnoreEnd $result = [ 'conversations' => $conversations, 'total' => intval($total), 'pages' => ceil($total / $per_page), 'current_page' => $page, 'locations' => $locations, ]; } else { // For non-admin users $user_id = get_current_user_id(); $ip_address = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; $query = "SELECT * FROM {$wpdb->prefix}aisk_conversations WHERE "; $params = []; if ($user_id) { $query .= "user_id = %d"; $params[] = $user_id; } else { $query .= "(ip_address = %s AND user_agent = %s)"; $params[] = $ip_address; $params[] = $user_agent; } if (!empty($time_filter)) { $query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)"; $params[] = intval(str_replace('days', '', $time_filter)); } if ($location_filter !== 'all' && !empty($location_filter)) { $query .= " AND LOWER(city) = LOWER(%s)"; $params[] = $location_filter; } // @codingStandardsIgnoreStart $total_query = str_replace("SELECT *", "SELECT COUNT(*)", $query); $total = $wpdb->get_var($wpdb->prepare($total_query, ...$params)); $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $per_page; $params[] = ($page - 1) * $per_page; $conversations = $wpdb->get_results($wpdb->prepare($query, ...$params)); // @codingStandardsIgnoreEnd $result = [ 'conversations' => $conversations ? $conversations : [], 'total' => intval($total), 'pages' => ceil($total / $per_page), 'current_page' => $page, ]; } // Cache the results for 5 minutes wp_cache_set($cache_key, $result, 'aisk_conversations', 300); return $result; } /** * Get single conversation * * Retrieves details of a specific conversation * * @param WP_REST_Request $request The incoming request object * * @since 1.0.0 * * @return array|WP_Error Conversation details or error */ public function get_conversation( $request ) { $conversation_id = $request->get_param('id'); $conversation = $this->chat_storage->get_conversation($conversation_id); if ( ! $conversation ) { return new WP_Error( 'conversation_not_found', 'Conversation not found', [ 'status' => 404 ] ); } // Check if user has access to this conversation if ( ! $this->user_owns_conversation($conversation) ) { return new WP_Error( 'unauthorized', 'You do not have permission to view this conversation', [ 'status' => 403 ] ); } return $conversation; } /** * Get conversation messages * * Retrieves messages for a specific conversation * * @param WP_REST_Request $request The incoming request object * * @since 1.0.0 * * @return array|WP_Error List of messages or error */ public function get_messages($request) { $conversation_id = $request->get_param('conversation_id'); if (empty($conversation_id)) { return new WP_Error( 'missing_conversation_id', 'Conversation ID is required', ['status' => 400] ); } // Verify access to the conversation if (!$this->verify_conversation_access($request)) { return new WP_Error( 'unauthorized', 'You do not have access to this conversation', ['status' => 401] ); } $messages = $this->chat_storage->get_messages($conversation_id); if (empty($messages)) { return []; } return $messages; } /** * Handle product search * * Processes product search requests and returns matching products * * @param array $intent The classified intent data * * @since 1.0.0 * * @return array Search response with products */ private function handle_product_search( $intent, $message, $content_type = '', $conversation_id = '', $platform = 'web' ) { // Check if WooCommerce is enabled $settings = get_option('aisk_settings'); if (empty($settings['ai_config']['woocommerce_enabled'])) { return [ 'message' => __("Product search is currently disabled. Please enable WooCommerce integration in the settings.", 'aisk-ai-chat'), 'products' => [] ]; } // Get relevant content from embeddings. A stricter threshold keeps unrelated products out of the results. $similar_ids = $this->embeddings_handler->find_similar_content( $message, 5, self::PRODUCT_MATCH_THRESHOLD, $content_type, 'product_search' ); $products = $this->product_handler->search_products($similar_ids); if ( empty($products) ) { // No strong product match: the answer may live in pages, posts, external URLs or PDFs instead. $knowledge_matches = $this->embeddings_handler->find_similar_content( $message, 6, 0.3, self::KNOWLEDGE_CONTENT_TYPES, '' ); if ( ! empty($knowledge_matches) ) { return $this->handle_general_query( $message, $conversation_id, self::KNOWLEDGE_CONTENT_TYPES, $platform ); } $default_message = sprintf( /* translators: %s: product category or 'products' */ /* translators: %s: Content type (e.g., products, articles) */ __("I couldn't find any %s matching your request. Could you please try describing what you're looking for in a different way?", 'aisk-ai-chat'), isset($intent['category']) ? $intent['category'] : __('products', 'aisk-ai-chat') ); $message = isset($intent['responses']['not_found']) ? $intent['responses']['not_found'] : $default_message; $formatted_message = AISK_Response_Formatter::format_response($message); return [ 'message' => $message, 'products' => [], ]; } $message = isset($intent['responses']['found']) ? $intent['responses']['found'] : __('Awesome! Here are some products you might like:', 'aisk-ai-chat'); $formatted_message = AISK_Response_Formatter::format_response($message); return [ 'message' => $message, 'products' => $products, ]; } /** * Handle order requests * * Processes order-related requests and returns order information * * @param array $intent The classified intent data * * @since 1.0.0 * * @return array Order response data */ private function handle_order_status_request( $intent ) { $response = $this->order_handler->get_order_status($intent); if ( 'order_verified' !== $response['type'] ) { return [ 'message' => $response['message'], 'order' => $response, ]; } // Format order details for OrderStatus component $order_info = $response['order_info']; $formatted_order = [ 'order_number' => $order_info['order_number'], 'status' => $order_info['status'], 'date_created' => $order_info['date_created'], 'total' => $order_info['total'], 'shipping_method' => $order_info['shipping_method'], 'shipping_address' => $order_info['shipping_address'], 'tracking_number' => $order_info['tracking_number'] ?? '', 'items' => array_map(function($item) { return [ 'name' => $item['name'], 'quantity' => $item['quantity'], 'total' => $item['total'], 'image' => $item['image'] ?? '' ]; }, $order_info['items'] ?? []) ]; $order_message = $this->order_handler->format_order_status_response($response); $formatted_order_message = AISK_Response_Formatter::format_response($order_message); return [ 'message' => $formatted_order_message, 'order' => [ 'type' => 'order_verified', 'order_info' => $formatted_order ] ]; } /** * Handle general query * * Processes general chat queries using AI * * @param string $message The user's message * @param string $conversation_id The conversation ID * * @since 1.0.0 * * @return array Response message */ private function handle_general_query( $message, $conversation_id, $content_type = '', $platform = 'web' ) { try { $response = $this->get_ai_response( $message, $conversation_id, $content_type, $platform ); if (is_wp_error($response)) { return [ 'message' => $response->get_error_message() ]; } return $response; } catch ( Exception $e ) { return [ 'message' => __( "I apologize, but I'm having trouble processing your request. Please try again in a moment.", 'aisk-ai-chat' ), ]; } } private function handle_product_info_search( $message, $conversation_id, $content_type = '', $platform = 'web' ) { try { // First, try with 'product' content type only $primary_content_types = ['product']; $response = $this->get_ai_response($message, $conversation_id, $primary_content_types, $platform); // If the response is empty or generic, fallback to broader content types $no_info_phrases = [ "I'm sorry, but I don't have specific information", "I don't have specific information", "I recommend checking", __( "I apologize, but I'm having trouble processing your request. Please try again in a moment.", 'aisk-ai-chat' ), ]; $is_empty = false; if (is_array($response) && isset($response['message'])) { foreach ($no_info_phrases as $phrase) { if (stripos($response['message'], $phrase) !== false) { $is_empty = true; break; } } } elseif (is_string($response)) { foreach ($no_info_phrases as $phrase) { if (stripos($response, $phrase) !== false) { $is_empty = true; break; } } } // If empty or generic, fallback to all content types if ($is_empty || empty($response) || (is_array($response) && empty($response['message']))) { $fallback_content_types = ['external_url', 'post', 'page', 'pdf', 'webpage']; $response = $this->get_ai_response($message, $conversation_id, $fallback_content_types, $platform); } return is_array($response) ? $response : [ 'message' => $response ]; } catch ( Exception $e ) { return [ 'message' => __( "I apologize, but I'm having trouble processing your request. Please try again in a moment.", 'aisk-ai-chat' ), ]; } } /** * Get AI response * * Retrieves AI-generated response for a message * * @param string $message The user's message * @param string $conversation_id The conversation ID * * @since 1.0.0 * * @return array|WP_Error AI response or error */ private function get_ai_response( $message, $conversation_id, $content_type = '', $platform = 'web' ) { try { // Check for cached response for simple queries (no conversation history needed) $cache_key = 'aisk_ai_response_' . md5($message . $platform); $cached_response = wp_cache_get($cache_key, 'aisk_ai_responses'); // Only use cache for simple queries without conversation context $history = $this->chat_storage->get_recent_message_history($conversation_id, 5); if (empty($history) && $cached_response) { return $cached_response; } $conversation_context = ''; // Format conversation history foreach ($history as $msg) { $role = $msg['bot'] ? 'Assistant' : 'User'; $conversation_context .= "{$role}: {$msg['message']}\n"; } // Get relevant content with lower similarity threshold $similar_content = $this->embeddings_handler->find_similar_content( $message, 6, 0.3, // Lowered threshold for better matches $content_type, '' ); // Enhanced context preparation $content_context = ''; if (!empty($similar_content)) { // Sort content by similarity score if available if (isset($similar_content[0]['similarity_score'])) { usort($similar_content, function($a, $b) { $scoreA = isset($a['similarity_score']) ? $a['similarity_score'] : 0; $scoreB = isset($b['similarity_score']) ? $b['similarity_score'] : 0; return $scoreB <=> $scoreA; }); } // Add most relevant content first foreach ($similar_content as $content) { if (!isset($content['content_type']) || !isset($content['content_chunk'])) { continue; // Skip invalid entries } if ($content['content_type'] === 'settings') { // For settings, directly use the content as it contains structured information $content_context .= $content['content_chunk'] . "\n\n"; } else { $source_label = $content['content_type']; if (!empty($content['source_url'])) { // Let the model tell sources apart (e.g. "the return policy of wornideas"). $source_label .= ': ' . $content['source_url']; } $content_context .= "Source ({$source_label}): " . $content['content_chunk'] . "\n\n"; } } } // Prepare system message with enhanced context and instructions $site_locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale(); $system_message = "You are a helpful AI assistant for an e-commerce website. "; $system_message .= "Always reply in the same language as the user's latest message. If the language is unclear, reply in the site language ({$site_locale}). "; $system_message .= "Use the following information to answer the user's question accurately and directly:\n\n"; $system_message .= $content_context; $system_message .= "\nPrevious conversation:\n" . $conversation_context; $system_message .= "\nImportant instructions:"; $system_message .= "\n1. For general conversation (greetings, small talk):"; $system_message .= "\n - Use friendly, casual responses"; $system_message .= "\n - Match the user's tone and formality level"; $system_message .= "\n - Keep it light and welcoming"; $system_message .= "\n2. For general inquiries (store policies, information):"; $system_message .= "\n - Use informative, helpful responses"; $system_message .= "\n - Be clear about what information you're providing"; $system_message .= "\n - Include relevant details from the context"; $system_message .= "\n3. If the question is about store information (hours, contact, etc), use ONLY the provided store information."; $system_message .= "\n4. Give direct, specific answers based on the provided information."; $system_message .= "\n5. Do not make assumptions or provide generic responses when specific information is available."; $system_message .= "\n6. If specific information is not found in the context, acknowledge that and suggest contacting support."; $system_message .= "\n7. For contact support queries:"; $system_message .= "\n - If user needs to submit a form (account issues, technical problems, urgent assistance), set response_type to 'form'"; $system_message .= "\n - If user just needs information (store hours, location, general contact), set response_type to 'info'"; if (in_array($platform, ['telegram', 'whatsapp'])) { } else { $system_message .= "\n - When providing a single-line response, wrap only the key fact in **bold** inside a natural sentence (e.g. 'The phone number is **8100600242**.'). If the response contains multiple important text segments (one or two key phrases), wrap each in **bold** for emphasis."; } // Prepare messages for API with enhanced instructions $messages = [ ['role' => 'system', 'content' => $system_message], ['role' => 'user', 'content' => $message] ]; // Call OpenAI API with adjusted parameters $request_id = uniqid('chat_', true); $start_time = microtime(true); $response = wp_remote_post(AISK_OPENAI_API_URL . '/chat/completions', [ 'headers' => [ 'Authorization' => 'Bearer ' . $this->openai_key, 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'model' => $this->chat_model, 'messages' => $messages, 'temperature' => 0.3, // Lowered for more consistent responses 'max_tokens' => 500, 'presence_penalty' => 0.3, // Adjusted for better context adherence 'frequency_penalty' => 0.3 ]), 'timeout' => 30 ]); if (is_wp_error($response)) { if ($this->usage_tracker) { $latency_ms = (int) ((microtime(true) - $start_time) * 1000); $this->usage_tracker->log_chat_usage( $request_id, $conversation_id, 'openai', $this->chat_model, 'error', $latency_ms, strlen($message) > 0 ? intval(strlen($message) / 4) : 0, 0, 'request_failed' ); } return new WP_Error('api_error', 'Failed to generate response'); } $body = json_decode(wp_remote_retrieve_body($response), true); if (empty($body['choices'][0]['message']['content'])) { if ($this->usage_tracker) { $latency_ms = (int) ((microtime(true) - $start_time) * 1000); $this->usage_tracker->log_chat_usage( $request_id, $conversation_id, 'openai', $this->chat_model, 'error', $latency_ms, strlen($message) > 0 ? intval(strlen($message) / 4) : 0, 0, 'empty_content' ); } return new WP_Error('api_error', 'Invalid response from API'); } if (isset($body['error'])) { $body['choices'][0]['message']['content'] = $body['error']['message']; } $ai_response = $body['choices'][0]['message']['content']; $usage = isset($body['usage']) ? $body['usage'] : null; $prompt_tokens = isset($usage['prompt_tokens']) ? intval($usage['prompt_tokens']) : (strlen($message) > 0 ? intval(strlen($message) / 4) : 0); $completion_tokens = isset($usage['completion_tokens']) ? intval($usage['completion_tokens']) : (strlen($ai_response) > 0 ? intval(strlen($ai_response) / 4) : 0); // Log successful usage if ($this->usage_tracker) { $latency_ms = (int) ((microtime(true) - $start_time) * 1000); $tokens_in = $prompt_tokens; $tokens_out = $completion_tokens; $this->usage_tracker->log_chat_usage( $request_id, $conversation_id, 'openai', $this->chat_model, 'success', $latency_ms, $tokens_in, $tokens_out, null ); } // Format the response using the Response Formatter $formatted_response = AISK_Response_Formatter::format_response($ai_response); // Prepare response based on platform $response_data = []; if (in_array($platform, ['telegram', 'whatsapp'])) { $response_data = [ 'message' => $ai_response, 'content_type' => $content_type ]; } else { $response_data = [ 'message' => $formatted_response, 'content_type' => $content_type ]; } // Cache simple responses (without conversation history) for faster future responses if (empty($history)) { wp_cache_set($cache_key, $response_data, 'aisk_ai_responses', 300); // Cache for 5 minutes } return $response_data; } catch (Exception $e) { return new WP_Error('response_error', 'Failed to generate response: ' . $e->getMessage()); } } /** * Prepare context for AI * * Formats content for AI context * * @param array $similar_content Array of similar content * * @since 1.0.0 * * @return string Formatted context */ private function prepare_context($similar_content) { $context = ''; // Group content by type $grouped_content = []; foreach ($similar_content as $content) { $type = $content['content_type']; if (!isset($grouped_content[$type])) { $grouped_content[$type] = []; } $grouped_content[$type][] = $content; } // Add content type headers and format content foreach ($grouped_content as $type => $contents) { switch ($type) { case 'product': $context .= esc_html__('Product Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'order': $context .= esc_html__('Order Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'pdf': $context .= esc_html__('Document Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'external_url': $context .= esc_html__('External Resource Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'post': $context .= esc_html__('Blog Post Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'page': $context .= esc_html__('Page Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; case 'settings': $context .= esc_html__('Store Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } break; default: $context .= esc_html__('Additional Information:', 'aisk-ai-chat') . "\n"; foreach ($contents as $content) { $context .= "- " . esc_html($content['content_chunk']) . "\n"; } } $context .= "\n"; } return $context; } /** * Internal method to classify intent (for internal use) * * @param string $message * @param string $conversation_id * @return array|WP_Error */ private function classify_intent_internal($message, $conversation_id) { // Get recent message history $message_history = $this->chat_storage->get_recent_message_history($conversation_id); // Create a mock request object $request = new WP_REST_Request('POST', '/aisk/v1/classify-intent'); $request->set_param('message', $message); $request->set_param('conversation_history', $message_history); $request->set_param('conversation_id', $conversation_id); // Call the public method $response = $this->classify_intent($request); // If it's a WP_Error, return it directly if (is_wp_error($response)) { return $response; } // If it's a WP_REST_Response, get the data if ($response instanceof WP_REST_Response) { return $response->get_data(); } // Otherwise return as is return $response; } /** * Normalize free-text for intent phrase matching. * * @param string $text Input text. * @return string */ private function normalize_intent_text($text) { $normalized = wp_strip_all_tags((string) $text); $normalized = html_entity_decode($normalized, ENT_QUOTES, 'UTF-8'); $normalized = strtolower($normalized); $normalized = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $normalized); $normalized = preg_replace('/\s+/', ' ', $normalized); return trim($normalized); } /** * Detect whether the user intends to escalate to a real person. * * @param string $message User message. * @param string $conversation_id Optional conversation id. * @return array{matched:bool,reason:string} */ private function detect_human_assistance_intent($message, $conversation_id = '') { $normalized = $this->normalize_intent_text($message); if ($normalized === '') { return ['matched' => false, 'reason' => 'none']; } $phrase_groups = [ 'direct_request' => [ 'contact support', 'talk to a human', 'human agent', 'real person', 'customer service', 'help from someone', 'call you', 'email you', 'speak to someone', 'speak with someone', 'reach your team', 'talk to support team' ], 'indirect_help' => [ 'this didnt help', 'this did not help', 'i need more help', 'can someone assist me', 'is there a way to reach you', 'how do i get help from your team' ], 'frustration' => [ 'this is not working', 'im stuck', 'i am stuck', 'this is useless', 'same problem', 'still have the same problem', 'still not working' ], 'escalation' => [ 'escalate this', 'complaint', 'report an issue', 'feedback', 'support team' ], 'sensitive_complex' => [ 'billing problem', 'billing issue', 'account access', 'cannot login', 'cant login', 'locked out', 'refund', 'privacy concern', 'legal concern', 'legal issue' ], ]; foreach ($phrase_groups as $reason => $phrases) { foreach ($phrases as $phrase) { if (strpos($normalized, $phrase) !== false) { return ['matched' => true, 'reason' => $reason]; } } } $regex_groups = [ 'direct_request' => '/\b(contact|talk|speak|reach|email|call)\b.{0,30}\b(support|human|agent|person|team|someone)\b/u', 'indirect_help' => '/\b(didn t help|did not help|need more help|someone assist|speak with someone|reach (you|team))\b/u', 'frustration' => '/\b(not working|stuck|useless|same problem|still (broken|failing|not working))\b/u', 'escalation' => '/\b(escalat(e|ion)|complaint|report (an )?issue|feedback)\b/u', 'sensitive_complex' => '/\b(billing|refund|account access|login issue|locked out|privacy|legal)\b/u', ]; foreach ($regex_groups as $reason => $pattern) { if (preg_match($pattern, $normalized)) { return ['matched' => true, 'reason' => $reason]; } } $negative_terms = ['not working', 'problem', 'issue', 'error', 'failed', 'cannot', 'cant', 'unable']; $human_help_terms = ['help', 'support', 'assist', 'someone', 'team', 'person', 'human', 'agent']; $has_negative = false; $has_help = false; foreach ($negative_terms as $term) { if (strpos($normalized, $term) !== false) { $has_negative = true; break; } } foreach ($human_help_terms as $term) { if (strpos($normalized, $term) !== false) { $has_help = true; break; } } if ($has_negative && $has_help) { return ['matched' => true, 'reason' => 'uncertain_fallback']; } // Repeated failed attempts in conversation are treated as escalation. if (!empty($conversation_id)) { $messages = $this->chat_storage->get_messages($conversation_id); if (is_array($messages) && !empty($messages)) { $failure_count = 0; foreach (array_slice($messages, -10) as $chat_message) { if (!isset($chat_message->message_type) || $chat_message->message_type !== 'user') { continue; } $recent_text = $this->normalize_intent_text(isset($chat_message->message) ? $chat_message->message : ''); if (preg_match('/\b(not working|still|same problem|failed|didn t help|did not help|stuck)\b/u', $recent_text)) { $failure_count++; } } if ($failure_count >= 2) { return ['matched' => true, 'reason' => 'repeated_failed_attempts']; } } } return ['matched' => false, 'reason' => 'none']; } /** * Decide whether contact form should be forced for support requests. * * @param string $message User message. * @param array $intent Classified/normalized intent. * @param array $escalation_detection Escalation detection result. * @return bool */ private function should_force_contact_form($message, $intent, $escalation_detection = []) { if (!empty($escalation_detection['matched'])) { return true; } if (!empty($intent['intent_type']) && $intent['intent_type'] === 'contact_support') { return true; } $normalized = $this->normalize_intent_text($message); return (bool) preg_match('/\b(refund|billing|account access|locked out|privacy|legal|complaint|escalate)\b/u', $normalized); } /** * Classify intent for a given message * * @param WP_REST_Request $request * @return WP_REST_Response|WP_Error */ public function classify_intent($request) { $conversation_id = ''; $conversation_id = $request->get_param('conversation_id') ?? $conversation_id; $message = $request->get_param('message'); $conversation_history = $request->get_param('conversation_history'); // Get OpenAI key from settings if (empty($this->openai_key)) { return new WP_Error( 'openai_key_missing', 'OpenAI key is required', ['status' => 401] ); } try { // Format conversation history with clear role labels $formatted_history = $this->format_conversation_history($conversation_history); // Create a focused system message $system_message = [ 'role' => 'system', 'content' => $this->get_classify_system_prompt() ]; // Add example exchanges to help with few-shot learning $few_shot_examples = [ [ 'role' => 'user', 'content' => 'Show me some blue sunglasses under $100' ], [ 'role' => 'assistant', 'content' => json_encode([ 'intent_type' => 'product_search', 'search_term' => 'blue', 'category' => 'sunglasses', 'filters' => [ 'price_max' => 100, 'color' => 'blue' ], 'responses' => [ 'found' => 'I found some stylish blue sunglasses under $100 that you might like.', 'not_found' => 'I couldn\'t find any blue sunglasses in that price range, but I can show you other options.' ] ]) ], [ 'role' => 'user', 'content' => 'Mostrami i prodotti sotto i 50 euro' ], [ 'role' => 'assistant', 'content' => json_encode([ 'intent_type' => 'product_search', 'search_term' => '', 'category' => '', 'filters' => [ 'price_max' => 50, ], 'responses' => [ 'found' => 'Ho trovato alcuni ottimi prodotti sotto i 50 euro. Dai un\'occhiata!', 'not_found' => 'Non ho trovato prodotti sotto i 50 euro, ma posso mostrarti altre opzioni.' ] ]) ], [ 'role' => 'user', 'content' => 'Hi there!' ], [ 'role' => 'assistant', 'content' => json_encode([ 'intent_type' => 'general_conversation', 'response'=> 'Hello! How can I help you today?', 'content_type' => ['settings'] ]) ], [ 'role' => 'user', 'content' => 'What\'s your return policy?' ], [ 'role' => 'assistant', 'content' => json_encode([ 'intent_type' => 'general_inquiries', 'query_topic'=> 'return policy', 'content_type'=> ['settings', 'page'], 'response'=> 'Let me find our return policy information for you.' ]) ], [ 'role' => 'user', 'content' => 'I needs support via form' ], [ 'role' => 'assistant', 'content' => json_encode([ 'intent_type' => 'contact_support', 'query_topic'=> 'contact support', 'response'=> 'your response here', 'response_type'=> 'form' ]) ] ]; // Combine all messages $messages = [ $system_message, ...$few_shot_examples, ...$formatted_history, [ 'role' => 'user', 'content' => $message ] ]; $clf_request_id = uniqid('clf_', true); $clf_start = microtime(true); $response = wp_remote_post(AISK_OPENAI_API_URL . '/chat/completions', [ 'headers' => [ 'Authorization' => 'Bearer ' . $this->openai_key, 'Content-Type' => 'application/json' ], 'body' => json_encode([ 'model' => $this->chat_model, 'messages' => $messages, 'temperature' => 0.3, 'max_tokens' => 500, 'response_format' => ['type' => 'json_object'] ]), 'timeout' => 15 ]); if (is_wp_error($response)) { if ($this->usage_tracker) { $this->usage_tracker->log_classify_usage( $clf_request_id, $conversation_id, 'openai', $this->chat_model, 'error', (int)((microtime(true) - $clf_start) * 1000), strlen($message) > 0 ? intval(strlen($message) / 4) : 0, 0, 'request_failed' ); } return new WP_Error( 'openai_request_failed', 'Failed to connect to OpenAI: ' . esc_html( $response->get_error_message() ), ['status' => 502] ); } $response_code = wp_remote_retrieve_response_code($response); if ($response_code !== 200) { if ($this->usage_tracker) { $this->usage_tracker->log_classify_usage( $clf_request_id, $conversation_id, 'openai', $this->chat_model, 'error', (int)((microtime(true) - $clf_start) * 1000), strlen($message) > 0 ? intval(strlen($message) / 4) : 0, 0, 'api_error' ); } return new WP_Error( 'openai_api_error', 'OpenAI API error: ' . wp_remote_retrieve_body($response), ['status' => $response_code] ); } $body = json_decode(wp_remote_retrieve_body($response), true); $content = $body['choices'][0]['message']['content']; $clf_usage = isset($body['usage']) ? $body['usage'] : null; $clf_prompt_tokens = isset($clf_usage['prompt_tokens']) ? intval($clf_usage['prompt_tokens']) : (strlen($message) > 0 ? intval(strlen($message) / 4) : 0); $clf_completion_tokens = isset($clf_usage['completion_tokens']) ? intval($clf_usage['completion_tokens']) : (strlen($content) > 0 ? intval(strlen($content) / 4) : 0); // Validate and clean the response if (empty($content)) { if ($this->usage_tracker) { $this->usage_tracker->log_classify_usage( $clf_request_id, $conversation_id, 'openai', $this->chat_model, 'error', (int)((microtime(true) - $clf_start) * 1000), strlen($message) > 0 ? intval(strlen($message) / 4) : 0, 0, 'empty_content' ); } return new WP_Error('empty_response', 'Empty response from OpenAI', ['status' => 502]); } if (isset($content)) { $content = trim($content); if (strpos($content, '{') !== 0) { return new WP_Error('invalid_json', 'OpenAI response is not valid JSON', ['status' => 502]); } $parsed = json_decode($content, true); if (json_last_error() === JSON_ERROR_NONE) { // Ensure response_type is always present if (!isset($parsed['response_type'])) { // Determine response_type based on intent_type if ($parsed['intent_type'] === 'product_search') { $parsed['response_type'] = 'info'; } elseif ($parsed['intent_type'] === 'contact_support') { $parsed['response_type'] = 'form'; } elseif ($parsed['intent_type'] === 'general_inquiries') { $parsed['response_type'] = 'info'; } else { $parsed['response_type'] = 'info'; // default fallback } } if($parsed['intent_type'] === 'product_search') { $format_product_query = $this->format_product_query($parsed); return rest_ensure_response([ 'intent_type' => $parsed['intent_type'], 'response_type' => $parsed['response_type'], 'responses' => $parsed['responses'] ]); } if ($this->usage_tracker) { $this->usage_tracker->log_classify_usage( $clf_request_id, $conversation_id, 'openai', $this->chat_model, 'success', (int)((microtime(true) - $clf_start) * 1000), $clf_prompt_tokens, $clf_completion_tokens, null ); } return rest_ensure_response($parsed); } } return new WP_Error( 'invalid_response', 'Invalid response from OpenAI', ['status' => 502] ); } catch (Exception $e) { return new WP_Error( 'intent_classification_failed', 'Exception while classifying intent: ' . $e->getMessage(), ['status' => 500] ); } } /** * Format conversation history for OpenAI API * * @param array $history * @return array */ private function format_conversation_history($history) { $formatted = []; foreach ($history as $msg) { $formatted[] = [ 'role' => $msg['bot'] == 'bot' ? 'assistant' : 'user', 'content' => $msg['message'] ]; } return $formatted; } /** * Format product query parameters for WooCommerce * * @param array $params * @return array */ private function format_product_query($params){ $args = [ 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 6, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query 'tax_query' => [], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => [] ]; // Add search term if (!empty($params['search_term'])) { $args['s'] = $params['search_term']; } // Add category if (!empty($params['category'])) { $args['tax_query'][] = [ 'taxonomy' => 'product_cat', 'field' => 'name', 'terms' => $params['category'], 'operator' => 'LIKE' ]; } // Add price range if (!empty($params['price_min']) || !empty($params['price_max'])) { $price_query = ['relation' => 'AND']; if (!empty($params['price_min'])) { $price_query[] = [ 'key' => '_price', 'value' => $params['price_min'], 'compare' => '>=', 'type' => 'NUMERIC' ]; } if (!empty($params['price_max'])) { $price_query[] = [ 'key' => '_price', 'value' => $params['price_max'], 'compare' => '<=', 'type' => 'NUMERIC' ]; } $args['meta_query'][] = $price_query; } // Add color attribute if specified if (!empty($params['color'])) { $args['tax_query'][] = [ 'taxonomy' => 'pa_color', 'field' => 'name', 'terms' => $params['color'], 'operator' => 'LIKE' ]; } // Add brand if specified if (!empty($params['brand'])) { $args['tax_query'][] = [ 'taxonomy' => 'pa_brand', 'field' => 'name', 'terms' => $params['brand'], 'operator' => 'LIKE' ]; } // Add sorting if (!empty($params['sort_by'])) { switch ($params['sort_by']) { case 'price_low': $args['orderby'] = 'meta_value_num'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['meta_key'] = '_price'; $args['order'] = 'ASC'; break; case 'price_high': $args['orderby'] = 'meta_value_num'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['meta_key'] = '_price'; $args['order'] = 'DESC'; break; case 'newest': $args['orderby'] = 'date'; $args['order'] = 'DESC'; break; case 'popularity': $args['orderby'] = 'meta_value_num'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['meta_key'] = 'total_sales'; $args['order'] = 'DESC'; break; } } return $args; } /** * Get system prompt for intent classification * * @return string */ private function get_classify_system_prompt() { $site_locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale(); $language_name = function_exists( 'locale_get_display_language' ) ? locale_get_display_language( $site_locale, 'en' ) : $site_locale; return 'You are a context-aware shopping assistant. For each query, analyze the intent and if it\'s a product search, extract search parameters. Respond in JSON format with these fields: LANGUAGE RULE (critical): - Write EVERY customer-facing string in "responses" (found, not_found, auth_required, order_status, and any "response" field) in the SAME language as the user\'s latest message. - If the user writes in Italian, those strings must be Italian. If Spanish, Spanish. Do not default to English when the user used another language. - If the user language is unclear, use the store language: ' . $language_name . ' (' . $site_locale . '). - Keep JSON keys in English. Only the human-readable response values must be localized. { "intent_type": "product_search|order_status|contact_support|general_inquiries", "response_type": "form|info", "search_term": "extract only specific descriptive terms, exclude category", "category": "product category (sunglasses, glasses, shirts, pants etc.)", "price_min": "minimum price if mentioned (number only)", "price_max": "maximum price if mentioned (number only)", "color": "color if mentioned", "size": "size if mentioned", "brand": "brand if mentioned", "sort_by": "price_low|price_high|newest|popularity if mentioned", "attributes": { "material": "material type if mentioned", "style": "style if mentioned", "gender": "men|women|kids if mentioned", "season": "summer|winter|etc if mentioned" }, "responses": { "found": "warm response when products are found. Mention the specific item they asked about. MUST match the user\'s language.", "not_found": "empathetic response when products are not found. Suggest alternatives or ask for clarification. MUST match the user\'s language." } } Treat questions like "Do you have X?" or "Are there any X?" as product searches Example Mappings: - "Do you have X?" -> Show me X - "Is X available?" -> Show me X - "Any X in stock?" -> Show me X - "Looking for X" -> Show me X If users ask about orders and intent type is order_status, extract order numbers and email addresses using the contextual information. Pay careful attention to distinguish between order numbers and verification codes (OTPs). Respond in JSON format: { "intent_type": "product_search|order_status|contact_support|general_inquiries", "response_type": "form|info", "order_info": { "order_number": "extract any number that looks like an order number", "email": "extract email if mentioned", "auth_status": "new|needs_auth|awaiting_otp", "otp": "extract any number that is explicitly provided as a verification code", "query_type": "status|tracking|cancel|modify" }, "responses": { "auth_required": "polite response asking for authentication info", "order_status": "friendly response about order status" } } IMPORTANT: Context-based OTP vs Order Number Detection - If the previous assistant message mentioned "verification code" or "code" and the user responds with only a number (especially a 6-digit number), interpret it as an OTP, not an order number - If the conversation context indicates we are in the verification phase (after email was provided), treat numeric input as an OTP - Order numbers typically come earlier in the conversation, before an email is provided or verification is mentioned - OTPs are almost always 6 digits (e.g., "398355") and appear after a verification request - Order numbers are usually shorter (often 3-5 digits, e.g., "140") and appear at the beginning of the order inquiry process - If the conversation flow is: user asks about order → assistant asks for order number → user provides number → assistant asks for email → user provides email → assistant mentions verification code → user provides number, then this last number is an OTP, not a new order number Example order queries: 1. "What\'s the status of order #12345" -> extract order_number: "12345" 2. "Track my order, email is user@example.com" -> extract email 3. "Where is my order?" -> set auth_status: "needs_auth" 4. "Check order 12345 for user@example.com" -> extract both Sample conversation flow inference: - If conversation history shows: assistant asked for order number → user provided "140" → assistant asked for email → user provided email → assistant mentioned verification code → user responded with "398355", then "398355" is definitely an OTP, not an order number - In this case, maintain "order_number": "140" and set "otp": "398355" with "auth_status": "awaiting_otp" Rules: - Always identify main product type as category (sunglasses, glasses, shirts, etc.) - search_term should exclude the category and only include descriptive terms - If multiple product types mentioned, use the main one as category - Common product mappings: - sunglass/sunglasses -> category: sunglasses - glass/glasses -> category: glasses - tshirt/t-shirt -> category: tshirts etc. - Generate both found and not_found responses - Keep responses conversational and brief - Use emoji occasionally but appropriately - found response should express excitement about showing options - not_found response should be helpful and suggest alternatives For non-product and order_status searches, include intent_type and response_type. Extract as many parameters as possible from the query. Use null for missing values. Human escalation guidance: - If the user implies they want a real person, support team, agent, callback, or contact method, set intent_type to "contact_support" and response_type to "form". - Treat frustration statements ("this is not working", "I am stuck", "this did not help", "same problem") as likely escalation intent. - Treat billing issues, refund requests for a specific order, account access/login problems, complaints, and legal/privacy concerns as contact_support with response_type "form". - Questions ABOUT a policy (return policy, refund policy, exchange policy, shipping policy, warranty, terms) are general_inquiries with response_type "info", not contact_support. Example: "What is your return policy?" -> general_inquiries. - When uncertain between general inquiry and human escalation, prefer contact_support with response_type "form". IMPORTANT: Always include response_type in your JSON response. This field is crucial for determining how the frontend should handle the response.'; } } new AISK_Chat_Handler();