settings = $settings; } /** * Register hooks. * * @return void */ public function init() { add_filter( 'agizoai_agentic_public_tools', array( $this, 'add_product_tools' ), 10, 2 ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Return true when WooCommerce product APIs are available. * * @return bool */ public function is_active() { return class_exists( 'WooCommerce' ) && function_exists( 'wc_get_product' ); } /** * Register read-only product REST routes. * * @return void */ public function register_routes() { register_rest_route( AgizoAI_Agentic_Tools::REST_NAMESPACE, '/products', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_products' ), 'permission_callback' => array( $this, 'public_read_permission' ), 'args' => array( 'query' => array( 'description' => __( 'Public product search query.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'q' => array( 'description' => __( 'Public product search query alias.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'category' => array( 'description' => __( 'Optional product category slug.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_title', ), 'per_page' => array( 'description' => __( 'Number of results, between 1 and 20.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( AgizoAI_Agentic_Tools::REST_NAMESPACE, '/product/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_product' ), 'permission_callback' => array( $this, 'public_read_permission' ), 'args' => array( 'id' => array( 'description' => __( 'Public product ID.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( AgizoAI_Agentic_Tools::REST_NAMESPACE, '/product-categories', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_product_categories' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); } /** * Permission callback for intentionally public read-only product endpoints. * * These routes only expose selected published public product information and * are unavailable when WooCommerce product tools are disabled. * * @return bool */ public function public_read_permission() { return $this->is_active() && (bool) $this->settings->get( 'agent_tools_enabled', 1 ) && (bool) $this->settings->get( 'agent_tools_rest_enabled', 1 ) && (bool) $this->settings->get( 'ecommerce_product_tools_enabled', 1 ); } /** * Add read-only product tools to the public registry. * * @param array> $tools Existing tools. * @param array $options Options. * @return array> */ public function add_product_tools( array $tools, array $options ) { if ( empty( $options['ecommerce_product_tools_enabled'] ) || ! $this->is_active() ) { return $tools; } $prefix = $this->tool_prefix(); if ( ! empty( $options['ecommerce_product_search_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_search_products', __( 'Search public products', 'agizoai-agentic-browsing-optimizer' ), __( 'Searches published WooCommerce products and returns safe public product summaries only.', 'agizoai-agentic-browsing-optimizer' ), rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/products' ), array( 'query' => __( 'Optional product search phrase.', 'agizoai-agentic-browsing-optimizer' ), 'category' => __( 'Optional product category slug.', 'agizoai-agentic-browsing-optimizer' ), 'per_page' => __( 'Optional result limit from 1 to 20.', 'agizoai-agentic-browsing-optimizer' ), ) ); } if ( ! empty( $options['ecommerce_product_details_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_product_details', __( 'Get public product details', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns safe public metadata for one published WooCommerce product. It does not expose orders, customers or checkout data.', 'agizoai-agentic-browsing-optimizer' ), rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/product/{id}' ), array( 'id' => __( 'Public WooCommerce product ID.', 'agizoai-agentic-browsing-optimizer' ), ) ); } if ( ! empty( $options['ecommerce_product_categories_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_product_categories', __( 'Get public product categories', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns public WooCommerce product category names, URLs and product counts.', 'agizoai-agentic-browsing-optimizer' ), rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/product-categories' ), array() ); } return $tools; } /** * Scan WooCommerce product readiness. * * @param int $limit Product sample limit. * @return array */ public function scan( $limit = 25 ) { $options = $this->settings->get_options(); $active = $this->is_active(); $limit = max( 1, min( 50, absint( $limit ) ) ); $summary = array( 'active' => $active, 'enabled' => ! empty( $options['ecommerce_scanner_enabled'] ), 'product_tools_enabled' => ! empty( $options['ecommerce_product_tools_enabled'] ), 'products_scanned' => 0, 'published_products' => 0, 'product_categories' => 0, 'products_missing_summary' => 0, 'products_missing_image_alt' => 0, 'out_of_stock_products' => 0, 'issues' => array(), 'issue_count' => 0, 'critical_count' => 0, 'warning_count' => 0, 'score' => 0, 'endpoints' => array( 'products' => rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/products' ), 'product_categories' => rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/product-categories' ), ), ); if ( ! $active || empty( $options['ecommerce_scanner_enabled'] ) ) { $summary['score'] = $active ? 50 : 0; return $summary; } $query = new WP_Query( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $limit, 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => true, ) ); $summary['published_products'] = (int) wp_count_posts( 'product' )->publish; foreach ( $query->posts as $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { continue; } $summary['products_scanned']++; $description = trim( wp_strip_all_tags( $product->get_short_description() . ' ' . $product->get_description() ) ); if ( '' === $description ) { $summary['products_missing_summary']++; $summary['issues'][] = $this->issue( 'warning', get_the_title( $product_id ), get_permalink( $product_id ), __( 'Product has no useful public description or short description.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add a concise product summary so agents can explain the item accurately.', 'agizoai-agentic-browsing-optimizer' ) ); } $image_id = $product->get_image_id(); if ( $image_id ) { $alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true ); if ( '' === trim( (string) $alt ) ) { $summary['products_missing_image_alt']++; $summary['issues'][] = $this->issue( 'warning', get_the_title( $product_id ), get_permalink( $product_id ), __( 'Product featured image is missing alt text.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add descriptive image alt text for users and agents.', 'agizoai-agentic-browsing-optimizer' ) ); } } if ( method_exists( $product, 'is_in_stock' ) && ! $product->is_in_stock() ) { $summary['out_of_stock_products']++; } } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => true, 'number' => 100, ) ); $summary['product_categories'] = is_array( $terms ) && ! is_wp_error( $terms ) ? count( $terms ) : 0; $summary['issue_count'] = count( $summary['issues'] ); $summary['critical_count'] = count( array_filter( $summary['issues'], array( $this, 'is_critical_issue' ) ) ); $summary['warning_count'] = max( 0, $summary['issue_count'] - $summary['critical_count'] ); $score = 100; if ( 0 === absint( $summary['published_products'] ) ) { $score -= 30; } $score -= min( 30, absint( $summary['products_missing_summary'] ) * 5 ); $score -= min( 20, absint( $summary['products_missing_image_alt'] ) * 3 ); if ( empty( $options['ecommerce_product_tools_enabled'] ) ) { $score -= 20; } if ( 0 === absint( $summary['product_categories'] ) ) { $score -= 10; } $summary['score'] = max( 0, min( 100, $score ) ); return $summary; } /** * REST callback: search/list products. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_products( WP_REST_Request $request ) { if ( ! $this->can_serve_products( 'ecommerce_product_search_enabled' ) ) { return $this->disabled_response(); } $query = sanitize_text_field( (string) ( $request->get_param( 'query' ) ? $request->get_param( 'query' ) : $request->get_param( 'q' ) ) ); $category = sanitize_title( (string) $request->get_param( 'category' ) ); $per_page = $this->bounded_per_page( $request->get_param( 'per_page' ) ); $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $per_page, 's' => $query, 'orderby' => '' !== $query ? 'relevance' : 'modified', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => true, ); if ( '' !== $category ) { $args['tax_query'] = array( array( 'taxonomy' => 'product_cat', 'field' => 'slug', 'terms' => $category, ), ); } $posts = get_posts( $args ); $results = array(); foreach ( $posts as $post ) { $product = wc_get_product( $post->ID ); if ( $product ) { $results[] = $this->format_product( $product, false ); } } return rest_ensure_response( array( 'query' => $query, 'category' => $category, 'count' => count( $results ), 'results' => $results, ) ); } /** * REST callback: product detail. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_product( WP_REST_Request $request ) { if ( ! $this->can_serve_products( 'ecommerce_product_details_enabled' ) ) { return $this->disabled_response(); } $product = wc_get_product( absint( $request->get_param( 'id' ) ) ); if ( ! $product || 'publish' !== get_post_status( $product->get_id() ) ) { return new WP_REST_Response( array( 'error' => 'not_found', 'message' => __( 'Public product was not found or is not exposed to agent tools.', 'agizoai-agentic-browsing-optimizer' ), ), 404 ); } return rest_ensure_response( $this->format_product( $product, true ) ); } /** * REST callback: product categories. * * @return WP_REST_Response */ public function rest_product_categories() { if ( ! $this->can_serve_products( 'ecommerce_product_categories_enabled' ) ) { return $this->disabled_response(); } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => true, 'number' => 100, ) ); $categories = array(); if ( is_array( $terms ) && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( is_wp_error( $link ) ) { continue; } $categories[] = array( 'id' => absint( $term->term_id ), 'name' => $term->name, 'slug' => $term->slug, 'url' => esc_url_raw( $link ), 'count' => absint( $term->count ), 'description' => $this->short_text( $term->description, 30 ), ); } } return rest_ensure_response( array( 'count' => count( $categories ), 'categories' => $categories, ) ); } /** * Format public product data. * * @param WC_Product $product Product. * @param bool $include_description Include longer description. * @return array */ private function format_product( $product, $include_description ) { $product_id = $product->get_id(); $data = array( 'id' => absint( $product_id ), 'type' => sanitize_key( $product->get_type() ), 'name' => $product->get_name(), 'url' => get_permalink( $product_id ), 'price_html' => wp_strip_all_tags( $product->get_price_html() ), 'price' => $product->get_price(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', 'stock_status' => $product->get_stock_status(), 'in_stock' => $product->is_in_stock(), 'summary' => $this->short_text( $product->get_short_description() ? $product->get_short_description() : $product->get_description(), 44 ), 'categories' => $this->product_terms( $product_id ), 'detail_tool' => rest_url( AgizoAI_Agentic_Tools::REST_NAMESPACE . '/product/' . absint( $product_id ) ), ); $image_id = $product->get_image_id(); if ( $image_id ) { $data['image'] = array( 'url' => wp_get_attachment_image_url( $image_id, 'large' ), 'alt' => get_post_meta( $image_id, '_wp_attachment_image_alt', true ), ); } if ( $include_description ) { $data['description'] = $this->short_text( $product->get_description(), 240 ); $data['sku'] = $product->get_sku(); } return $data; } /** * Return product category terms. * * @param int $product_id Product ID. * @return array> */ private function product_terms( $product_id ) { $terms = get_the_terms( $product_id, 'product_cat' ); $out = array(); if ( is_array( $terms ) && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $out[] = array( 'name' => $term->name, 'slug' => $term->slug, ); } } return $out; } /** * Return if product routes can serve public data. * * @param string $option Option name. * @return bool */ private function can_serve_products( $option ) { return $this->is_active() && (bool) $this->settings->get( 'agent_tools_enabled', 1 ) && (bool) $this->settings->get( 'agent_tools_rest_enabled', 1 ) && (bool) $this->settings->get( 'ecommerce_product_tools_enabled', 1 ) && (bool) $this->settings->get( $option, 1 ); } /** * Return disabled REST response. * * @return WP_REST_Response */ private function disabled_response() { return new WP_REST_Response( array( 'enabled' => false, 'message' => __( 'This public read-only WooCommerce agent tool is disabled or WooCommerce is inactive.', 'agizoai-agentic-browsing-optimizer' ), ), 403 ); } /** * Build issue row. * * @param string $severity Severity. * @param string $title Title. * @param string $url URL. * @param string $problem Problem. * @param string $fix Fix. * @return array */ private function issue( $severity, $title, $url, $problem, $fix ) { return array( 'severity' => sanitize_key( $severity ), 'title' => wp_strip_all_tags( $title ), 'url' => esc_url_raw( $url ), 'problem' => $problem, 'fix' => $fix, ); } /** * Whether an issue is critical. * * @param array $issue Issue. * @return bool */ private function is_critical_issue( $issue ) { return isset( $issue['severity'] ) && 'critical' === $issue['severity']; } /** * Tool registry definition. * * @param string $name Tool name. * @param string $title Title. * @param string $description Description. * @param string $endpoint Endpoint. * @param array $parameters Params. * @return array */ private function tool_definition( $name, $title, $description, $endpoint, array $parameters ) { return array( 'name' => sanitize_key( $name ), 'title' => $title, 'description' => $description, 'endpoint' => esc_url_raw( $endpoint ), 'method' => 'GET', 'parameters' => $parameters, 'readOnlyHint' => true, 'untrustedContentHint' => true, 'requiresAuth' => false, 'changesState' => false, 'category' => 'woocommerce', ); } /** * Bound per page. * * @param mixed $value Raw value. * @return int */ private function bounded_per_page( $value ) { $value = absint( $value ); if ( $value < 1 ) { return 10; } return min( 20, $value ); } /** * Shorten text. * * @param string $text Text. * @param int $words Word limit. * @return string */ private function short_text( $text, $words ) { $text = strip_shortcodes( (string) $text ); $text = wp_strip_all_tags( $text, true ); $text = preg_replace( '/\s+/', ' ', $text ); $text = trim( (string) $text ); return wp_trim_words( $text, absint( $words ), '…' ); } /** * Safe tool prefix. * * @return string */ private function tool_prefix() { $prefix = (string) $this->settings->get( 'webmcp_tool_prefix', 'site' ); $prefix = preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $prefix ) ); $prefix = trim( (string) $prefix, '_' ); return '' !== $prefix ? $prefix : 'site'; } }