'POST', 'callback' => array( $this, 'view' ), 'permission_callback' => '__return_true', 'args' => array( 'faq_id' => array( 'required' => true, 'sanitize_callback' => 'absint' ), 'token' => array( 'sanitize_callback' => 'sanitize_text_field' ), ), ) ); register_rest_route( self::NS, '/vote', array( 'methods' => 'POST', 'callback' => array( $this, 'vote' ), 'permission_callback' => '__return_true', 'args' => array( 'faq_id' => array( 'required' => true, 'sanitize_callback' => 'absint' ), 'dir' => array( 'required' => true, 'sanitize_callback' => 'sanitize_key' ), 'token' => array( 'sanitize_callback' => 'sanitize_text_field' ), ), ) ); register_rest_route( self::NS, '/search', array( 'methods' => 'GET', 'callback' => array( $this, 'search' ), 'permission_callback' => '__return_true', 'args' => array( 'q' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ), 'term' => array( 'sanitize_callback' => 'absint' ), 'limit' => array( 'sanitize_callback' => 'absint' ), 'commit' => array( 'sanitize_callback' => 'absint' ), ), ) ); register_rest_route( self::NS, '/submit', array( 'methods' => 'POST', 'callback' => array( $this, 'submit' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/generate', array( 'methods' => 'POST', 'callback' => array( $this, 'generate' ), 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'topic' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ), 'count' => array( 'sanitize_callback' => 'absint' ), ), ) ); } /** * Record a view. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function view( $req ) { if ( ! Options::boolean( 'enable_views' ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $count = Tracker::record_view( (int) $req['faq_id'], (string) $req->get_param( 'token' ) ); return new \WP_REST_Response( array( 'ok' => $count >= 0, 'views' => max( 0, $count ) ), 200 ); } /** * Record a vote. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function vote( $req ) { if ( ! Options::boolean( 'enable_voting' ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $res = Tracker::record_vote( (int) $req['faq_id'], (string) $req['dir'], (string) $req->get_param( 'token' ) ); if ( null === $res ) { return new \WP_REST_Response( array( 'ok' => false, 'reason' => 'duplicate' ), 200 ); } return new \WP_REST_Response( array( 'ok' => true ) + $res, 200 ); } /** * Server-side search across all published FAQs. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function search( $req ) { $q = trim( (string) $req['q'] ); $term = (int) $req->get_param( 'term' ); $limit = min( 20, max( 1, (int) $req->get_param( 'limit' ) ) ); if ( '' === $q ) { return new \WP_REST_Response( array( 'ok' => true, 'results' => array() ), 200 ); } $args = array( 'post_type' => PostType::POST_TYPE, 'post_status' => 'publish', 's' => $q, 'posts_per_page' => $limit, 'no_found_rows' => true, ); if ( $term ) { $args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => Taxonomy::TAXONOMY, 'field' => 'term_id', 'terms' => $term, ), ); } $results = array(); $search = new \WP_Query( $args ); foreach ( $search->posts as $post ) { $title = get_the_title( $post ); $results[] = array( 'id' => $post->ID, 'question' => $title, 'answer' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 40 ), 'anchor' => sanitize_title( $title ) . '-' . $post->ID, 'permalink' => (string) get_permalink( $post ), ); } wp_reset_postdata(); // Record the query for search-term analytics (content-gap insight), but // only on a committed search (Enter / submit) — not on every debounced // autocomplete keystroke, which would inflate partial-term counts. if ( $req->get_param( 'commit' ) && Options::boolean( 'enable_search_analytics' ) ) { Searches::record( $q, count( $results ) ); } return new \WP_REST_Response( array( 'ok' => true, 'results' => $results ), 200 ); } /** * Handle a front-end FAQ submission (creates a pending FAQ). * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function submit( $req ) { if ( ! Options::boolean( 'enable_submissions' ) ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'Submissions are disabled.', 'accordion-faq-plugin' ) ), 403 ); } $params = $req->get_json_params(); if ( empty( $params ) ) { $params = $req->get_params(); } // Honeypot: a hidden "website" field must be empty. if ( ! empty( $params['website'] ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } // Timing token: submissions faster than 3s are likely bots. $started = isset( $params['t'] ) ? absint( $params['t'] ) : 0; if ( $started && ( time() - $started ) < 3 ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } // Rate limit per IP. $ip_key = 'afaq_submit_' . md5( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); if ( (int) get_transient( $ip_key ) >= 5 ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'Too many submissions. Please try later.', 'accordion-faq-plugin' ) ), 429 ); } $question = isset( $params['question'] ) ? sanitize_text_field( $params['question'] ) : ''; $answer = isset( $params['answer'] ) ? wp_kses_post( $params['answer'] ) : ''; if ( '' === $question ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'A question is required.', 'accordion-faq-plugin' ) ), 400 ); } $post_id = wp_insert_post( array( 'post_type' => PostType::POST_TYPE, 'post_status' => 'pending', 'post_title' => $question, 'post_content' => $answer, ), true ); if ( is_wp_error( $post_id ) ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'Could not save your question.', 'accordion-faq-plugin' ) ), 500 ); } update_post_meta( $post_id, '_afaq_user_submitted', 1 ); $cat = Options::get( 'submissions_cat', 0 ); if ( $cat ) { wp_set_object_terms( $post_id, array( (int) $cat ), Taxonomy::TAXONOMY ); } set_transient( $ip_key, (int) get_transient( $ip_key ) + 1, HOUR_IN_SECONDS ); /** * Fires after a front-end FAQ submission is stored. * * @param int $post_id New pending FAQ ID. * @param array $params Sanitized-ish raw params. */ do_action( 'accordion_faq_submission_received', $post_id, $params ); return new \WP_REST_Response( array( 'ok' => true, 'message' => __( 'Thanks! Your question was submitted for review.', 'accordion-faq-plugin' ) ), 201 ); } /** * AI-generate FAQ question/answer pairs via the WordPress core AI client. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function generate( $req ) { if ( ! Options::boolean( 'enable_ai' ) ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'AI generation is disabled.', 'accordion-faq-plugin' ) ), 403 ); } $topic = (string) $req['topic']; $count = min( 10, max( 1, (int) $req->get_param( 'count' ) ) ); $prompt = sprintf( 'Generate %1$d frequently asked questions with concise answers about: "%2$s". Return ONLY valid JSON: an array of objects each with "question" and "answer" string fields. No markdown, no commentary.', $count, $topic ); $text = $this->run_ai( $prompt ); if ( is_wp_error( $text ) ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => $text->get_error_message() ), 200 ); } $json = trim( preg_replace( '/^```(?:json)?|```$/m', '', (string) $text ) ); $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { return new \WP_REST_Response( array( 'ok' => false, 'message' => __( 'The AI response could not be parsed.', 'accordion-faq-plugin' ), 'raw' => $text ), 200 ); } $pairs = array(); foreach ( $data as $row ) { if ( ! empty( $row['question'] ) ) { $pairs[] = array( 'question' => sanitize_text_field( $row['question'] ), 'answer' => wp_kses_post( isset( $row['answer'] ) ? $row['answer'] : '' ), ); } } return new \WP_REST_Response( array( 'ok' => true, 'pairs' => $pairs ), 200 ); } /** * Call the WordPress core AI client if available. * * @param string $prompt Prompt. * @return string|\WP_Error */ private function run_ai( $prompt ) { if ( function_exists( 'wp_ai_client_prompt' ) ) { // Called indirectly: this WordPress 7.0+ helper is an optional // enhancement guarded by the function_exists() check above, so it // must not be resolved statically against the plugin's minimum. $result = call_user_func( 'wp_ai_client_prompt', $prompt ); if ( is_wp_error( $result ) ) { return $result; } return is_string( $result ) ? $result : wp_json_encode( $result ); } if ( class_exists( '\WordPress\AiClient\AiClient' ) && method_exists( '\WordPress\AiClient\AiClient', 'prompt' ) ) { try { return (string) \WordPress\AiClient\AiClient::prompt( $prompt )->generateText(); } catch ( \Throwable $e ) { return new \WP_Error( 'afaq_ai', $e->getMessage() ); } } return new \WP_Error( 'afaq_ai_unavailable', __( 'AI is not available on this site. WordPress 7.0+ with a configured AI provider is required.', 'accordion-faq-plugin' ) ); } }