namespace = 'ai-support-assistant/v1'; add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Register API routes. * * @return void */ public function register_routes(): void { // Admin: Discover models available to a direct API key. register_rest_route( $this->namespace, '/admin/models', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'discover_models' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'provider' => array( 'required' => true, 'type' => 'string', 'enum' => array( 'openai', 'gemini' ), 'sanitize_callback' => 'sanitize_key', ), 'api_key' => array( 'required' => true, 'type' => 'string', 'maxLength' => 512, ), ), ), ) ); // Admin: Index Media Attachment. register_rest_route( $this->namespace, '/admin/documents/upload-attachment', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'upload_attachment' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'attachment_id' => array( 'required' => true, 'sanitize_callback' => 'absint', ), ), ), ) ); // Admin: Crawl and Index Webpage URL. register_rest_route( $this->namespace, '/admin/documents/crawl-url', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'crawl_url' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'url' => array( 'required' => true, 'sanitize_callback' => 'esc_url_raw', ), ), ), ) ); // Admin: Reindex Document. register_rest_route( $this->namespace, '/admin/documents/(?P\d+)/reindex', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'reindex_document' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Admin: Get document indexing status. register_rest_route( $this->namespace, '/admin/documents/(?P\d+)/status', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_document_status' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Admin: Delete Document. register_rest_route( $this->namespace, '/admin/documents/(?P\d+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_document' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Admin: Get Document Chunks Preview. register_rest_route( $this->namespace, '/admin/documents/(?P\d+)/chunks', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_document_chunks' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint' ), 'per_page' => array( 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 200, 'sanitize_callback' => 'absint' ), ), ), ) ); // Admin: Export Logs. register_rest_route( $this->namespace, '/history/export', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'export_history' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Admin: Get & Delete Chat History Log — both methods on same URI in a single registration. register_rest_route( $this->namespace, '/admin/history/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_history_log' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_history_log' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Public: Chat Query Endpoint. register_rest_route( $this->namespace, '/chat', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_chat' ), 'permission_callback' => '__return_true', // Nonce checked in callback for fine-grained visitor filtering. 'args' => array( 'question' => array( 'required' => true, 'type' => 'string', 'minLength' => 1, 'maxLength' => 2000, 'sanitize_callback' => 'sanitize_text_field', ), 'session_id' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'session_token' => array( 'type' => 'string', 'maxLength' => 64 ), 'provider_override' => array( 'type' => 'string', 'enum' => array( '', 'openai', 'gemini' ) ), 'provider_token' => array( 'type' => 'string', 'maxLength' => 64 ), 'stream' => array( 'type' => 'boolean', 'default' => false ), 'nonce' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); } /** * Check if current user is admin. * * @return bool */ public function check_admin_permission(): bool { return current_user_can( 'manage_options' ); } /** * REST Callback: Discover models available to the submitted provider key. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function discover_models( WP_REST_Request $request ) { $provider = $request->get_param( 'provider' ); $api_key = trim( (string) $request->get_param( 'api_key' ) ); if ( '' === $api_key ) { return new WP_Error( 'ai_support_assistant_model_key_missing', __( 'Enter an API key to discover available models.', 'ai-support-assistant' ), array( 'status' => 400 ) ); } if ( strlen( $api_key ) > 512 ) { return new WP_Error( 'ai_support_assistant_model_key_invalid', __( 'The API key is too long.', 'ai-support-assistant' ), array( 'status' => 400 ) ); } try { return new WP_REST_Response( ModelDiscovery::fetch_models( $provider, $api_key ) ); } catch ( \RuntimeException $e ) { return new WP_Error( 'ai_support_assistant_model_discovery_failed', $e->getMessage(), array( 'status' => 502 ) ); } } /** * REST Callback: Index a WP Media Attachment. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function upload_attachment( WP_REST_Request $request ) { $attachment_id = $request->get_param( 'attachment_id' ); try { $doc_id = DocumentManager::upload_attachment( $attachment_id ); if ( $doc_id > 0 ) { return new WP_REST_Response( array( 'success' => true, 'document_id' => $doc_id, 'status' => 'pending', 'message' => __( 'Document queued for indexing.', 'ai-support-assistant' ), ), 202 ); } return new WP_Error( 'ai_support_assistant_upload_failed', __( 'Unable to process and index the document.', 'ai-support-assistant' ), array( 'status' => 500 ) ); } catch ( \Exception $e ) { return new WP_Error( 'ai_support_assistant_upload_failed', $e->getMessage(), array( 'status' => 500 ) ); } } /** * REST Callback: Crawl and index URL. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function crawl_url( WP_REST_Request $request ) { $url = $request->get_param( 'url' ); try { $doc_id = DocumentManager::crawl_and_index_url( $url ); if ( $doc_id > 0 ) { return new WP_REST_Response( array( 'success' => true, 'document_id' => $doc_id, 'status' => 'pending', 'message' => __( 'URL queued for crawling and indexing.', 'ai-support-assistant' ), ), 202 ); } return new WP_Error( 'ai_support_assistant_crawl_failed', __( 'Unable to process and index the URL.', 'ai-support-assistant' ), array( 'status' => 500 ) ); } catch ( \Exception $e ) { return new WP_Error( 'ai_support_assistant_crawl_failed', $e->getMessage(), array( 'status' => 500 ) ); } } /** * REST Callback: Reindex document. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function reindex_document( WP_REST_Request $request ) { $id = $request->get_param( 'id' ); try { $success = DocumentManager::schedule_indexing( $id ); if ( $success ) { return new WP_REST_Response( array( 'success' => true, 'status' => 'pending', 'message' => __( 'Document queued for re-indexing.', 'ai-support-assistant' ), ), 202 ); } return new WP_Error( 'ai_support_assistant_reindex_failed', __( 'Re-indexing process failed.', 'ai-support-assistant' ), array( 'status' => 500 ) ); } catch ( \Exception $e ) { return new WP_Error( 'ai_support_assistant_reindex_failed', $e->getMessage(), array( 'status' => 500 ) ); } } /** * REST Callback: Retrieve document indexing status. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function get_document_status( WP_REST_Request $request ) { global $wpdb; $document = $wpdb->get_row( $wpdb->prepare( 'SELECT status, error_message FROM %i WHERE id = %d', DatabaseManager::get_documents_table(), $request->get_param( 'id' ) ), ARRAY_A ); if ( ! $document ) { return new WP_Error( 'ai_support_assistant_document_not_found', __( 'Document not found.', 'ai-support-assistant' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'status' => $document['status'], 'error_message' => $document['error_message'], ), 200 ); } /** * REST Callback: Delete document. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function delete_document( WP_REST_Request $request ) { $id = $request->get_param( 'id' ); $success = DocumentManager::delete_document( $id ); if ( $success ) { return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Document and vector embeddings deleted.', 'ai-support-assistant' ), ), 200 ); } return new WP_Error( 'ai_support_assistant_delete_failed', __( 'Delete document operation failed.', 'ai-support-assistant' ), array( 'status' => 500 ) ); } /** * REST Callback: Retrieve document chunks for admin preview list. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function get_document_chunks( WP_REST_Request $request ) { global $wpdb; $id = $request->get_param( 'id' ); $chunks_table = DatabaseManager::get_chunks_table(); $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = max( 1, min( 200, (int) $request->get_param( 'per_page' ) ) ); $offset = ( $page - 1 ) * $per_page; $chunks = $wpdb->get_results( $wpdb->prepare( 'SELECT chunk_number, page_number, chunk_text, token_count FROM %i WHERE document_id = %d ORDER BY chunk_number ASC LIMIT %d OFFSET %d', $chunks_table, $id, $per_page, $offset ), ARRAY_A ); return new WP_REST_Response( array( 'chunks' => $chunks, 'page' => $page, 'per_page' => $per_page, 'has_more' => count( $chunks ) === $per_page ), 200 ); } /** * REST Callback: Export logs CSV. * * Streams a CSV file download directly. ChatHistory::export_csv() will * send HTTP headers and call exit(), so the return below is unreachable * in practice but satisfies the REST API return-type contract. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function export_history( WP_REST_Request $request ) { ChatHistory::export_csv(); // Unreachable — export_csv() always calls exit() after streaming. return new WP_REST_Response( array( 'success' => true ), 200 ); // phpcs:ignore Generic.CodeAnalysis.UnreachableCode.Found } /** * REST Callback: Handle public chat request. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function handle_chat( WP_REST_Request $request ) { $nonce = $request->get_param( 'nonce' ); $wp_nonce = $request->get_header( 'x_wp_nonce' ); $authorized = false; if ( ! empty( $nonce ) && wp_verify_nonce( $nonce, 'ai_support_assistant_action' ) ) { $authorized = true; } elseif ( ! empty( $wp_nonce ) && wp_verify_nonce( $wp_nonce, 'wp_rest' ) ) { $authorized = true; } elseif ( current_user_can( 'manage_options' ) ) { $authorized = true; } if ( ! $authorized ) { return new WP_Error( 'ai_support_assistant_unauthorized', __( 'Invalid security nonce.', 'ai-support-assistant' ), array( 'status' => 403 ) ); } $question = (string) $request->get_param( 'question' ); $session_id = (string) $request->get_param( 'session_id' ); $session_token = (string) $request->get_param( 'session_token' ); $override = (string) $request->get_param( 'provider_override' ); if ( '' !== $override && ( ! in_array( $override, array( 'openai', 'gemini' ), true ) || ! hash_equals( \AISupportAssistant\Chat\SessionIdentity::widget_token( $override ), (string) $request->get_param( 'provider_token' ) ) ) ) { return new WP_Error( 'ai_support_assistant_invalid_widget', __( 'Invalid widget configuration.', 'ai-support-assistant' ), array( 'status' => 403 ) ); } if ( '' !== $session_token && ! \AISupportAssistant\Chat\SessionIdentity::verify( $session_id, $session_token ) ) { return new WP_Error( 'ai_support_assistant_invalid_session', __( 'Invalid conversation credentials. Start a new chat.', 'ai-support-assistant' ), array( 'status' => 403 ) ); } if ( empty( trim( $question ) ) ) { return new WP_Error( 'ai_support_assistant_empty_question', __( 'Please enter a question.', 'ai-support-assistant' ), array( 'status' => 400 ) ); } if ( mb_strlen( $question, 'UTF-8' ) > 2000 ) { return new WP_Error( 'ai_support_assistant_question_too_long', __( 'Questions must be 2,000 characters or fewer.', 'ai-support-assistant' ), array( 'status' => 400 ) ); } $rate_limit_response = $this->check_chat_rate_limit(); if ( $rate_limit_response ) { return $rate_limit_response; } // If no session ID provided, generate a unique random hash. if ( empty( $session_token ) || empty( $session_id ) || mb_strlen( $session_id, 'UTF-8' ) > 100 ) { $session_id = wp_generate_uuid4(); } $session_token = \AISupportAssistant\Chat\SessionIdentity::token( $session_id ); if ( $request->get_param( 'stream' ) && Settings::get_instance()->get( 'enable_streaming', 1 ) ) { add_filter( 'rest_pre_serve_request', function ( $served, $response, $stream_request ) use ( $request, $session_id, $session_token, $question, $override ) { if ( $stream_request !== $request || $served ) { return $served; } if ( $response->get_status() >= 400 ) { return $served; } header( 'Content-Type: text/event-stream; charset=utf-8' ); header( 'Cache-Control: no-cache, no-transform' ); header( 'X-Accel-Buffering: no' ); while ( ob_get_level() > 0 ) { if ( ! @ob_end_flush() ) { break; } } $emit = static function ( string $event, array $data ) { echo 'event: ' . esc_html( $event ) . "\ndata: " . wp_json_encode( $data ) . "\n\n"; flush(); }; $emit( 'session', array( 'session_id' => $session_id, 'session_token' => $session_token ) ); try { $result = ChatService::answer_question( $session_id, $question, $override, static function ( $text ) use ( $emit ) { $emit( 'delta', array( 'text' => $text ) ); } ); $emit( 'done', $result ); } catch ( \Throwable $error ) { $this->record_chat_failure( $session_id, $question, $override, $error ); $message = in_array( $error->getCode(), array( 502, 503, 504 ), true ) ? __( 'The AI provider is busy. Please try again shortly.', 'ai-support-assistant' ) : __( 'The assistant is temporarily unavailable. Please try again shortly.', 'ai-support-assistant' ); $emit( 'error', array( 'message' => $message ) ); } return true; }, 10, 3 ); return new WP_REST_Response( null, 200 ); } try { $result = ChatService::answer_question( $session_id, $question, $override ); return new WP_REST_Response( array( 'answer' => $result['answer'], 'citations' => $result['citations'], 'session_id' => $session_id, 'session_token' => $session_token, 'enable_streaming' => ! empty( Settings::get_instance()->get( 'enable_streaming', 1 ) ) ? 1 : 0, ), 200 ); } catch ( \Throwable $e ) { Logger::error( 'Chat request error: ' . $e->getMessage() ); $settings = Settings::get_instance(); $provider = $override ?: (string) $settings->get( 'ai_provider', 'wp_native_ai' ); if ( 'openai' === $provider || 'wp_connector_openai' === $provider ) { $model = (string) $settings->get( 'openai_chat_model', 'gpt-4o-mini' ); } elseif ( 'gemini' === $provider || 0 === strpos( $provider, 'wp_connector_google' ) || 'wp_connector_gemini' === $provider ) { $model = (string) $settings->get( 'gemini_chat_model', 'gemini-3.1-flash-lite' ); } else { $model = 'wp-native-ai'; } try { ChatHistory::log_failure( $session_id, $question, $provider, $model, $e->getMessage() ); } catch ( \Throwable $log_error ) { Logger::error( 'Unable to record failed chat in admin history: ' . $log_error->getMessage() ); } return new WP_Error( 'ai_support_assistant_chat_failed', __( 'The assistant is temporarily unavailable. Please try again shortly.', 'ai-support-assistant' ), array( 'status' => 503 ) ); } } /** Record stream failures without exposing provider diagnostics to visitors. */ private function record_chat_failure( string $session_id, string $question, string $override, \Throwable $error ): void { Logger::error( 'Chat stream error: ' . $error->getMessage() ); $settings = Settings::get_instance(); $provider = $override ?: (string) $settings->get( 'ai_provider', 'wp_native_ai' ); $model = 'wp_native_ai' === $provider ? 'wp-native-ai' : (string) $settings->get( false !== strpos( $provider, 'openai' ) ? 'openai_chat_model' : 'gemini_chat_model', '' ); try { ChatHistory::log_failure( $session_id, $question, $provider, $model, $error->getMessage() ); } catch ( \Throwable $log_error ) { Logger::error( 'Unable to record failed chat stream.' ); } } /** * Apply a per-IP request limit to public chat requests. * * @return WP_REST_Response|null Rate-limit response or null when allowed. */ private function check_chat_rate_limit(): ?WP_REST_Response { global $wpdb; if ( current_user_can( 'manage_options' ) ) { return null; } // Serialize counter updates across workers, including sites without object caching. $address = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $address = filter_var( $address, FILTER_VALIDATE_IP ) ? $address : 'unknown'; $lock = 'ai_sa_rate_' . substr( hash( 'sha256', $wpdb->prefix . $address ), 0, 40 ); if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock ) ) ) { return $this->chat_rate_limit_unavailable_response(); } try { return $this->update_chat_rate_limit(); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** Update the request bucket while holding its database lock. */ private function update_chat_rate_limit(): ?WP_REST_Response { if ( current_user_can( 'manage_options' ) ) { return null; } $limit = max( 1, min( 1000, (int) Settings::get_instance()->get( 'chat_rate_limit', 20 ) ) ); $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { $ip = 'unknown'; } $key = 'ai_sa_chat_rate_' . substr( hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ), 0, 32 ); $window = 10 * MINUTE_IN_SECONDS; $bucket = get_transient( $key ); if ( ! is_array( $bucket ) || ! isset( $bucket['count'], $bucket['started_at'] ) || time() - (int) $bucket['started_at'] >= $window ) { if ( ! set_transient( $key, array( 'count' => 1, 'started_at' => time(), ), $window ) ) { return $this->chat_rate_limit_unavailable_response(); } return null; } $retry_after = max( 1, $window - ( time() - (int) $bucket['started_at'] ) ); if ( (int) $bucket['count'] >= $limit ) { $response = new WP_REST_Response( array( 'code' => 'ai_support_assistant_rate_limited', 'message' => __( 'Too many chat requests. Please wait a few minutes and try again.', 'ai-support-assistant' ), 'data' => array( 'status' => 429 ), ), 429 ); $response->header( 'Retry-After', (string) $retry_after ); return $response; } ++$bucket['count']; if ( ! set_transient( $key, $bucket, $retry_after ) ) { return $this->chat_rate_limit_unavailable_response(); } return null; } /** * Return an error when the rate-limit counter cannot be persisted. * * @return WP_REST_Response Service-unavailable response. */ private function chat_rate_limit_unavailable_response(): WP_REST_Response { return new WP_REST_Response( array( 'code' => 'ai_support_assistant_rate_limit_unavailable', 'message' => __( 'Chat is temporarily unavailable. Please try again shortly.', 'ai-support-assistant' ), 'data' => array( 'status' => 503 ), ), 503 ); } /** * REST Callback: Delete a single history log entry. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function delete_history_log( WP_REST_Request $request ) { global $wpdb; $id = $request->get_param( 'id' ); $chat_table = DatabaseManager::get_chat_history_table(); $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE id = %d', $chat_table, $id ) ); if ( ! $exists ) { return new WP_Error( 'ai_support_assistant_not_found', __( 'Log entry not found.', 'ai-support-assistant' ), array( 'status' => 404 ) ); } $deleted = $wpdb->delete( $chat_table, array( 'id' => $id ), array( '%d' ) ); if ( false !== $deleted ) { return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Log entry deleted successfully.', 'ai-support-assistant' ), ), 200 ); } return new WP_Error( 'ai_support_assistant_delete_failed', __( 'Delete log operation failed.', 'ai-support-assistant' ), array( 'status' => 500 ) ); } /** * REST Callback: Get details of a single log entry. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function get_history_log( WP_REST_Request $request ) { global $wpdb; $id = $request->get_param( 'id' ); $chat_table = DatabaseManager::get_chat_history_table(); $log = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $chat_table, $id ) ); if ( $log ) { $messages = array(); if ( ! empty( $log->messages ) ) { $decoded = json_decode( $log->messages, true ); if ( is_array( $decoded ) ) { $messages = $decoded; } } if ( empty( $messages ) ) { if ( ! empty( $log->question ) ) { $messages[] = array( 'role' => 'user', 'content' => $log->question, 'time' => $log->created_at, ); } if ( ! empty( $log->answer ) ) { $messages[] = array( 'role' => 'assistant', 'content' => $log->answer, 'time' => $log->created_at, ); } } return new WP_REST_Response( array( 'success' => true, 'log' => array( 'id' => (int) $log->id, 'session_id' => $log->session_id, 'question' => $log->question, 'answer' => $log->answer, 'messages' => $messages, 'provider' => $log->provider, 'model' => $log->model, 'status' => isset( $log->status ) ? $log->status : 'completed', 'error_message' => isset( $log->error_message ) ? $log->error_message : '', 'error_summary' => ! empty( $log->error_message ) ? \AISupportAssistant\Admin\Views::error_summary( $log->error_message ) : '', 'asked_at' => mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $log->created_at ), 'updated_at' => ! empty( $log->updated_at ) ? mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $log->updated_at ) : mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $log->created_at ), ), ), 200 ); } return new WP_Error( 'ai_support_assistant_not_found', __( 'Log entry not found.', 'ai-support-assistant' ), array( 'status' => 404 ) ); } }