' . esc_html__( 'Raychat AI requires WooCommerce to be installed and activated.', 'raychat-ai' ) . '

'; } /* * --------------------------------------------------------------------- * Admin UI * --------------------------------------------------------------------- */ /** * Register the admin menu page. */ public function register_admin_menu() { $icon_url = plugins_url( 'assets/images/raychat-logo.svg', RAYCHAT_AI_PLUGIN_FILE ); add_menu_page( __( 'Raychat AI', 'raychat-ai' ), __( 'Raychat AI', 'raychat-ai' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_admin_page' ), $icon_url, 58 ); } /** * Add a "Settings" link on the Plugins screen. * * @param array $links Existing links. * @return array */ public function add_action_links( $links ) { $settings = sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ), esc_html__( 'Settings', 'raychat-ai' ) ); array_unshift( $links, $settings ); return $links; } /** * Remove the WordPress footer text on the plugin page only. * * @param string $text Footer text. * @return string */ public function remove_admin_footer_text( $text ) { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && 'toplevel_page_' . self::PAGE_SLUG === $screen->id ) { return ''; } return $text; } /** * Human readable "last sync" value. * * @return string */ private function get_last_sync_label() { $last_sync = get_option( self::OPTION_LAST_SYNC, '' ); if ( is_numeric( $last_sync ) && (int) $last_sync > 0 ) { return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $last_sync ); } // Value saved by pre-release builds (local MySQL datetime string). if ( is_string( $last_sync ) && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $last_sync ) ) { return $last_sync; } return __( 'Never', 'raychat-ai' ); } /** * Render the admin page. */ public function render_admin_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'raychat-ai' ) ); } $api_key = (string) get_option( self::OPTION_API_KEY, '' ); $has_api_key = '' !== $api_key; $counts = wp_count_posts( 'product' ); $total_products = isset( $counts->publish ) ? (int) $counts->publish : 0; ?>

get_last_sync_label() ); ?>

admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'messages' => array( 'syncing' => __( 'Synchronizing products...', 'raychat-ai' ), 'synced' => __( 'Products synchronized successfully!', 'raychat-ai' ), 'error' => __( 'An error occurred. Please try again.', 'raychat-ai' ), 'error_short' => __( 'Error', 'raychat-ai' ), 'network_error' => __( 'Network error. Please check your internet connection.', 'raychat-ai' ), 'timeout' => __( 'Request timed out. Please try again.', 'raychat-ai' ), 'empty_api_key' => __( 'Please enter an API key first.', 'raychat-ai' ), 'short_api_key' => __( 'API key must be at least 10 characters long.', 'raychat-ai' ), 'success_label' => __( 'Success:', 'raychat-ai' ), 'error_label' => __( 'Error:', 'raychat-ai' ), 'dismiss' => __( 'Dismiss this notice.', 'raychat-ai' ), ), ) ); } /* * --------------------------------------------------------------------- * AJAX * --------------------------------------------------------------------- */ /** * Shared security checks for AJAX handlers. */ private function verify_ajax_request() { if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed. Please reload the page and try again.', 'raychat-ai' ) ), 403 ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'raychat-ai' ) ), 403 ); } } /** * AJAX handler: save API key. */ public function ajax_save_api_key() { $this->verify_ajax_request(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in verify_ajax_request(). $api_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; if ( '' === $api_key ) { wp_send_json_error( array( 'message' => __( 'API key cannot be empty.', 'raychat-ai' ) ) ); } if ( strlen( $api_key ) < 10 ) { wp_send_json_error( array( 'message' => __( 'API key is too short.', 'raychat-ai' ) ) ); } $had_api_key = '' !== (string) get_option( self::OPTION_API_KEY, '' ); // Not autoloaded: the key is only needed on the settings page and during a sync. update_option( self::OPTION_API_KEY, $api_key, false ); $this->clear_pending(); wp_send_json_success( array( 'message' => __( 'API key saved successfully!', 'raychat-ai' ), 'reload' => ! $had_api_key, ) ); } /** * AJAX handler: sync all products. */ public function ajax_sync_products() { $this->verify_ajax_request(); $api_key = (string) get_option( self::OPTION_API_KEY, '' ); if ( '' === $api_key ) { wp_send_json_error( array( 'message' => __( 'API key not configured.', 'raychat-ai' ) ) ); } $result = $this->sync_all_products( $api_key ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } $this->clear_pending(); wp_send_json_success( array( 'message' => sprintf( /* translators: %d: number of products */ _n( '%d product synchronized successfully!', '%d products synchronized successfully!', $result, 'raychat-ai' ), $result ), 'count' => $result, 'last_sync' => $this->get_last_sync_label(), ) ); } /* * --------------------------------------------------------------------- * Change tracking and background sync * --------------------------------------------------------------------- */ /** * Queue a product for background sync. * * Hooked to several WooCommerce actions which pass either a product ID or a product object. * * @param int|WC_Product $product Product ID or object. */ public function queue_product( $product ) { $product_id = is_object( $product ) && method_exists( $product, 'get_id' ) ? $product->get_id() : absint( $product ); if ( ! $product_id || ! $this->has_api_key() ) { return; } // Variations are synced as part of their parent product. if ( 'product_variation' === get_post_type( $product_id ) ) { $product_id = wp_get_post_parent_id( $product_id ); if ( ! $product_id ) { return; } } $pending = $this->get_pending_ids(); if ( ! in_array( $product_id, $pending, true ) ) { $pending[] = $product_id; update_option( self::OPTION_PENDING, $pending, false ); } $this->schedule_sync(); } /** * A product was published or unpublished (trashed, set to draft, ...): the catalog needs a full re-sync. * * @param string $new_status New post status. * @param string $old_status Old post status. * @param WP_Post $post Post object. */ public function handle_status_transition( $new_status, $old_status, $post ) { if ( 'product' !== $post->post_type || $new_status === $old_status ) { return; } if ( 'publish' === $old_status ) { $this->queue_full_sync(); } elseif ( 'publish' === $new_status ) { $this->queue_product( $post->ID ); } } /** * A product was permanently deleted. * * @param int $post_id Post ID. * @param WP_Post $post Post object. */ public function handle_deleted_post( $post_id, $post ) { if ( $post && 'product' === $post->post_type && 'publish' === $post->post_status ) { $this->queue_full_sync(); } } /** * Request a full catalog sync in the background. */ private function queue_full_sync() { if ( ! $this->has_api_key() ) { return; } update_option( self::OPTION_FULL_SYNC, 1, false ); $this->schedule_sync(); } /** * Whether an API key has been saved. * * @return bool */ private function has_api_key() { return '' !== (string) get_option( self::OPTION_API_KEY, '' ); } /** * Pending product IDs. * * @return int[] */ private function get_pending_ids() { $pending = get_option( self::OPTION_PENDING, array() ); return is_array( $pending ) ? array_values( array_unique( array_filter( array_map( 'absint', $pending ) ) ) ) : array(); } /** * Forget all pending work. */ private function clear_pending() { delete_option( self::OPTION_PENDING ); delete_option( self::OPTION_FULL_SYNC ); delete_option( self::OPTION_ATTEMPTS ); } /** * Schedule the background sync, unless one is already waiting. * * Uses Action Scheduler (bundled with WooCommerce) and falls back to WP-Cron. * * @param int $delay Delay in seconds. */ private function schedule_sync( $delay = self::SYNC_DELAY ) { if ( function_exists( 'as_schedule_single_action' ) && function_exists( 'as_has_scheduled_action' ) && did_action( 'action_scheduler_init' ) ) { if ( ! as_has_scheduled_action( self::SYNC_HOOK, array(), self::SYNC_GROUP ) ) { as_schedule_single_action( time() + $delay, self::SYNC_HOOK, array(), self::SYNC_GROUP ); } return; } if ( ! wp_next_scheduled( self::SYNC_HOOK ) ) { wp_schedule_single_event( time() + $delay, self::SYNC_HOOK ); } } /** * Remove any scheduled background sync. */ public static function unschedule_sync() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::SYNC_HOOK, array(), self::SYNC_GROUP ); } wp_clear_scheduled_hook( self::SYNC_HOOK ); } /** * Background job: send queued product changes to Raychat. */ public function process_pending() { $api_key = (string) get_option( self::OPTION_API_KEY, '' ); $pending = $this->get_pending_ids(); $full = (bool) get_option( self::OPTION_FULL_SYNC, false ); if ( '' === $api_key || ( ! $full && empty( $pending ) ) ) { $this->clear_pending(); return; } // Take the work off the queue first so changes made while we are syncing are not lost. delete_option( self::OPTION_PENDING ); delete_option( self::OPTION_FULL_SYNC ); if ( $full || count( $pending ) > 1 ) { $result = $this->sync_all_products( $api_key ); } else { $product = wc_get_product( $pending[0] ); // Products that are not published are not part of the Raychat catalog. $result = ( $product && 'publish' === $product->get_status() ) ? $this->sync_single_product( $product, $api_key ) : true; } if ( ! is_wp_error( $result ) ) { delete_option( self::OPTION_ATTEMPTS ); return; } // Failed: put the work back and retry later, up to a limit. $attempts = (int) get_option( self::OPTION_ATTEMPTS, 0 ) + 1; if ( $attempts >= self::MAX_ATTEMPTS ) { $this->log( 'Background sync failed ' . $attempts . ' times, giving up until the next product change: ' . $result->get_error_message() ); $this->clear_pending(); return; } update_option( self::OPTION_ATTEMPTS, $attempts, false ); update_option( self::OPTION_PENDING, array_values( array_unique( array_merge( $pending, $this->get_pending_ids() ) ) ), false ); if ( $full ) { update_option( self::OPTION_FULL_SYNC, 1, false ); } $this->schedule_sync( self::RETRY_DELAY ); } /* * --------------------------------------------------------------------- * Product data * --------------------------------------------------------------------- */ /** * Convert a price from Iranian Rial (IRR) to Toman (IRT) when the store currency is IRR. * * @param mixed $price Price. * @return float */ private function convert_price_if_irr( $price ) { $price = (float) $price; return 'IRR' === get_woocommerce_currency() ? $price / 10 : $price; } /** * Format a WooCommerce date as an ISO 8601 UTC string. * * @param WC_DateTime|null $date Date. * @return string */ private function format_date( $date ) { return $date ? gmdate( 'Y-m-d\TH:i:s.000\Z', $date->getTimestamp() ) : ''; } /** * Stable, non-guessable identifier for a product: HMAC-SHA256(product ID, API key), first 16 bytes, Base64URL. * * @param string $api_key API key. * @param int $product_id Product ID. * @return string */ private function generate_woocommerce_id( $api_key, $product_id ) { $hash = hash_hmac( 'sha256', (string) $product_id, $api_key, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a binary hash for transport. return 'wc-' . rtrim( strtr( base64_encode( substr( $hash, 0, 16 ) ), '+/', '-_' ), '=' ); } /** * Build the Raychat API payload for one product. * * @param WC_Product $product Product. * @param string $api_key API key. * @return array */ private function build_product_payload( $product, $api_key ) { $payload = array( 'url' => $product->get_permalink(), 'name' => $product->get_name(), 'woocommerceId' => $this->generate_woocommerce_id( $api_key, $product->get_id() ), 'description' => $product->get_description(), 'imageUrl' => $this->get_product_image_urls( $product ), 'externalProductId' => 'ext-wc-' . $product->get_id(), 'source' => 'api', 'createdDate' => $this->format_date( $product->get_date_created() ), 'updatedDate' => $this->format_date( $product->get_date_modified() ), ); if ( $product->is_type( 'variable' ) ) { $payload['variations'] = $this->get_product_variations( $product ); return $payload; } // Simple products are sent as a single variation carrying the product attributes. $attributes = array(); foreach ( array_keys( $product->get_attributes() ) as $attr_key ) { $value = $product->get_attribute( $attr_key ); if ( '' !== (string) $value ) { $attributes[ urldecode( str_replace( 'pa_', '', $attr_key ) ) ] = $value; } } if ( empty( $attributes ) ) { $attributes = array( 'default' => 'simple' ); } $image_url = wp_get_attachment_url( $product->get_image_id() ); $payload['variations'] = array( array( 'attributes' => $attributes, 'price' => (string) $this->convert_price_if_irr( $product->get_regular_price() ), 'priceAfterOff' => (string) $this->convert_price_if_irr( $product->get_price() ), 'stockQuantity' => (int) $product->get_stock_quantity(), 'imageUrl' => $image_url ? $image_url : '', 'url' => $product->get_permalink(), ), ); return $payload; } /** * Featured and gallery image URLs of a product. * * @param WC_Product $product Product. * @return string[] */ private function get_product_image_urls( $product ) { $image_ids = array_merge( array( $product->get_image_id() ), $product->get_gallery_image_ids() ); $images = array(); foreach ( array_filter( $image_ids ) as $image_id ) { $image_url = wp_get_attachment_url( $image_id ); if ( $image_url ) { $images[] = $image_url; } } return $images; } /** * Variations of a variable product in Raychat API format. * * @param WC_Product $product Variable product. * @return array */ private function get_product_variations( $product ) { $variations = array(); $parent_image = wp_get_attachment_url( $product->get_image_id() ); foreach ( $product->get_children() as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( ! $variation ) { continue; } $attributes = array(); foreach ( $variation->get_attributes() as $attr_key => $attr_value ) { $attributes[ str_replace( 'pa_', '', $attr_key ) ] = $attr_value; } $image_url = wp_get_attachment_url( $variation->get_image_id() ); if ( ! $image_url ) { $image_url = $parent_image ? $parent_image : ''; } $variations[] = array( 'attributes' => ! empty( $attributes ) ? $attributes : array( 'variant' => 'default' ), 'price' => (string) $this->convert_price_if_irr( $variation->get_regular_price() ), 'priceAfterOff' => (string) $this->convert_price_if_irr( $variation->get_price() ), 'stockQuantity' => (int) $variation->get_stock_quantity(), 'imageUrl' => $image_url, 'url' => $variation->get_permalink(), ); } return $variations; } /* * --------------------------------------------------------------------- * Raychat API * --------------------------------------------------------------------- */ /** * Send every published product to Raychat. * * @param string $api_key API key. * @return int|WP_Error Number of products sent, or an error. */ private function sync_all_products( $api_key ) { $product_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'suppress_filters' => false, ) ); $products = array(); foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); if ( $product ) { $products[] = $this->build_product_payload( $product, $api_key ); } } if ( empty( $products ) ) { return new WP_Error( 'raychat_ai_no_products', __( 'No products found to synchronize.', 'raychat-ai' ) ); } /** * Filters the Raychat bulk import endpoint. * * @param string $endpoint Endpoint URL. */ $endpoint = apply_filters( 'raychat_ai_api_endpoint_all', 'https://raychat.io/ecommerce/api/bulkImport' ); $result = $this->request( 'POST', $endpoint, array( 'products' => $products ), $api_key, 60 ); if ( is_wp_error( $result ) ) { return $result; } update_option( self::OPTION_LAST_SYNC, time(), false ); return count( $products ); } /** * Send one product to Raychat. * * @param WC_Product $product Product. * @param string $api_key API key. * @return true|WP_Error */ private function sync_single_product( $product, $api_key ) { /** * Filters the Raychat single product endpoint. * * @param string $endpoint Endpoint URL. */ $endpoint = untrailingslashit( apply_filters( 'raychat_ai_api_endpoint_single', 'https://raychat.io/ecommerce/api' ) ); return $this->request( 'PUT', $endpoint, $this->build_product_payload( $product, $api_key ), $api_key, 30 ); } /** * Perform a request against the Raychat API. * * @param string $method HTTP method. * @param string $endpoint Endpoint URL. * @param array $body Request body. * @param string $api_key API key. * @param int $timeout Timeout in seconds. * @return true|WP_Error */ private function request( $method, $endpoint, $body, $api_key, $timeout ) { $response = wp_remote_request( esc_url_raw( $endpoint ), array( 'method' => $method, 'timeout' => $timeout, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'x-api-key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { $this->log( $method . ' ' . $endpoint . ' failed: ' . $response->get_error_message() ); return new WP_Error( 'raychat_ai_request_failed', sprintf( /* translators: %s: error message */ __( 'Could not reach Raychat: %s', 'raychat-ai' ), $response->get_error_message() ) ); } $status_code = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $status_code || 403 === $status_code ) { $this->log( $method . ' ' . $endpoint . ' returned HTTP ' . $status_code ); return new WP_Error( 'raychat_ai_unauthorized', __( 'Raychat rejected the API key. Please check that it is correct.', 'raychat-ai' ) ); } if ( $status_code < 200 || $status_code >= 300 ) { $this->log( $method . ' ' . $endpoint . ' returned HTTP ' . $status_code ); return new WP_Error( 'raychat_ai_bad_status', sprintf( /* translators: %d: HTTP status code */ __( 'Raychat returned status code %d.', 'raychat-ai' ), $status_code ) ); } return true; } /** * Write an error to the WooCommerce log (WooCommerce > Status > Logs, source "raychat-ai"). * * Never logs the API key or product data. * * @param string $message Message. */ private function log( $message ) { if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( $message, array( 'source' => 'raychat-ai' ) ); } } } } register_deactivation_hook( __FILE__, array( 'Raychat_AI', 'unschedule_sync' ) ); Raychat_AI::instance();