' .
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();