use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod; } public function __construct() { // Read testing mode from global constant (defined in onwebchat.php) $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false; // One-time migration to the "short + full" default. The Description Mode // selector was hidden in the UI before this version, so a stored // 'short_fallback_full' was the hidden form field's value, never a real // merchant choice. An explicit 'short_only' is left untouched. if (!get_option('onwebchat_wc_desc_mode_migrated')) { if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') { update_option('onwebchat_wc_sync_mode', 'short_plus_full'); } update_option('onwebchat_wc_desc_mode_migrated', 1); } // Initialize settings add_action('admin_init', array($this, 'register_settings')); // Show authentication error notice globally (not just on WooCommerce tab) add_action('admin_notices', array($this, 'show_auth_error_notice')); // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1); add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1); // Lightweight availability hook: fires whenever a product's stock STATUS flips // (including order-driven stock reductions that may not trigger a full product save). // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the // parent product's stock status from its variations and fires this action for the // parent, which is the entity synced to onWebChat. add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3); // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale // prices via direct meta updates, NOT through a product save, so // woocommerce_update_product never fires and the AI would keep quoting the // pre-sale price. These two actions receive the affected product/variation // IDs right after the cron applies or removes the sale prices. add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1); add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1); // Variation price edits (the Variations tab saves via AJAX without always // re-saving the parent post). Collect the parent IDs and sync each parent // once on shutdown, so the synced price range follows variation changes. add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1); add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1); // Handle product deletion (both trash and permanent delete) add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1); add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2); // Bulk sync via WP Cron add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch')); // Admin AJAX handlers add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products')); // Client-driven chunked bulk sync: the browser starts a run, then calls // the batch action repeatedly (one page per request) until it completes. // This replaces the single long request that timed out on large // catalogues and reported a false "sync failed" while products kept // syncing. add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync')); add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch')); add_action('wp_ajax_onwebchat_wc_scope_remove_start', array($this, 'ajax_scope_remove_start')); add_action('wp_ajax_onwebchat_wc_scope_remove_batch', array($this, 'ajax_scope_remove_batch')); add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret')); add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status')); add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce')); add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch')); add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status')); add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled')); } /** * AJAX: Connect WooCommerce with authentication */ public function ajax_connect_woocommerce() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } // The password is only forwarded to onWebChat, never stored, echoed or put in a query, // so it must NOT be sanitized. WordPress slash-escapes $_POST (wp_magic_quotes), and // sanitize_text_field() on top of that trims it, collapses repeated spaces, turns "<" // into an entity and DELETES any %xx sequence, so a correct password containing a quote, // a space or a percent sign could never authenticate. wp_unslash() alone is right here. $email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : ''; $password = isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : ''; if (empty($email) || empty($password)) { wp_send_json_error('Email and password are required'); } $result = $this->request_secret_with_auth($email, $password); if ($result['success']) { // Clear any previous authentication errors delete_transient('onwebchat_wc_auth_error'); wp_send_json_success(array( 'message' => 'WooCommerce sync connected successfully!' )); } else { wp_send_json_error($result['error']); } } /** * Show authentication error notice globally across all admin pages * (Hidden when already on WooCommerce tab since it has its own error message) */ public function show_auth_error_notice() { $auth_error = get_transient('onwebchat_wc_auth_error'); // Don't show if we're already on the WooCommerce tab (it has its own error message) $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings' && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce'; if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) { ?>

⚠️ onWebChat WooCommerce Sync Error: Fix Authentication

get_status() !== 'publish') { return; } // Check if product category is excluded if ($this->is_product_excluded($product)) { return; } // Respect the merchant's sync scope. When a category selection is active // and this product belongs to none of the scoped categories, remove it // (it may have been moved out of a scoped category after being synced) // and stop. Deletes always remove, regardless of scope. if (!$this->product_in_scope($product)) { $this->send_product_delete($product_id); return; } // Prepare and send product data $product_data = $this->prepare_product_data($product); $this->send_product_upsert($product_data, $product_id); } /** * Hook: WooCommerce's scheduled-sales cron started or ended sales on these * products. IDs can be simple products OR variations; variations are mapped to * their parent (the entity synced to onWebChat) and each product is re-pushed * once, so the AI immediately quotes the new (sale or regular) price. * * @param array $product_ids */ public function on_scheduled_sales($product_ids) { if (!get_option('onwebchat_wc_sync_enabled', false)) { return; } $ids = array(); foreach ((array) $product_ids as $product_id) { $product = wc_get_product($product_id); if (!$product) { continue; } $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id(); if ($id > 0) { $ids[$id] = $id; // de-duplicate } } foreach ($ids as $id) { $this->on_product_update($id); } } /** * Parent product IDs whose variations changed in this request; flushed once on * shutdown so a save touching 30 variations pushes the parent a single time. */ private $pending_variation_parents = array(); /** * Hook: a variation was created/updated (Variations tab saves happen over AJAX * and don't always re-save the parent post, so woocommerce_update_product may * never fire). Queue the parent for one sync at the end of the request. * * @param int $variation_id */ public function on_variation_update($variation_id) { if (!get_option('onwebchat_wc_sync_enabled', false)) { return; } $variation = wc_get_product($variation_id); if (!$variation || !$variation->is_type('variation')) { return; } $parent_id = (int) $variation->get_parent_id(); if ($parent_id <= 0) { return; } if (empty($this->pending_variation_parents)) { add_action('shutdown', array($this, 'flush_variation_parent_syncs')); } $this->pending_variation_parents[$parent_id] = $parent_id; } /** * Shutdown: sync every parent whose variations changed in this request. */ public function flush_variation_parent_syncs() { $parent_ids = $this->pending_variation_parents; $this->pending_variation_parents = array(); foreach ($parent_ids as $parent_id) { $this->on_product_update($parent_id); } } /** * Hook: product stock STATUS changed (in stock / out of stock / on backorder). * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder" * is treated as available since the store still accepts orders. * * @param int $product_id * @param string $status 'instock' | 'outofstock' | 'onbackorder' * @param WC_Product|null $product */ public function on_stock_status_change($product_id, $status, $product = null) { if (!get_option('onwebchat_wc_sync_enabled', false)) { return; } if (!$product || !is_object($product)) { $product = wc_get_product($product_id); } if (!$product) { return; } // Only products that would actually be synced: published, not excluded, in scope. // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update. if ($product->get_status() !== 'publish') { return; } if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) { return; } $in_stock = ($status !== 'outofstock'); $this->send_product_stock($product_id, $in_stock); } /** * Hook: Product trash (when moved to trash) */ public function on_product_trash($post_id) { // Check if it's a product if (get_post_type($post_id) !== 'product') { return; } if (!get_option('onwebchat_wc_sync_enabled', false)) { return; } // Send delete request when product is trashed $this->send_product_delete($post_id); } /** * Hook: Product permanent delete */ public function on_product_delete($post_id, $post) { if ($post->post_type !== 'product') { return; } if (!get_option('onwebchat_wc_sync_enabled', false)) { return; } // Send delete request when product is permanently deleted $this->send_product_delete($post_id); } /** * Check if product is in excluded categories */ private function is_product_excluded($product) { $excluded_categories = get_option('onwebchat_wc_excluded_categories', array()); if (empty($excluded_categories)) { return false; } $product_categories = $product->get_category_ids(); foreach ($product_categories as $cat_id) { if (in_array($cat_id, $excluded_categories)) { return true; } } return false; } /** * Get the saved sync scope as an array of product_cat term IDs. * An empty array on its own is ambiguous, so what it means is held * separately, see is_scope_all(): with no categories the scope is either the * whole catalogue or nothing at all. */ private function get_sync_scope() { return $this->parse_id_list(get_option('onwebchat_wc_sync_categories', '')); } /** * Comma-separated ids (as stored in options and posted by the picker) to a * de-duplicated array of positive ints. */ private function parse_id_list($raw) { $raw = (string) $raw; if ($raw === '') { return array(); } $ids = array(); foreach (explode(',', $raw) as $id) { $id = (int) trim($id); if ($id > 0) { $ids[$id] = $id; // de-duplicate } } return array_values($ids); } /** * Is the scope the whole catalogue? An empty category list means two * opposite things, so the answer is stored explicitly: * '1' whole catalogue, '0' exactly the saved categories (none = nothing). * Sites upgraded from an older version have no flag yet, and there an empty * list always meant "the whole catalogue", which is what they keep until * their next sync or removal writes the flag. */ private function is_scope_all() { $raw = (string) get_option('onwebchat_wc_sync_scope_all', ''); if ($raw === '') { return !$this->get_sync_scope(); } return $raw === '1'; } /** * Has the site ever recorded what its empty scope means? False only on a * site upgraded from an older version that never picked categories, where * a synced catalogue and an empty one look exactly the same. */ private function is_scope_known() { return (string) get_option('onwebchat_wc_sync_scope_all', '') !== '' || (bool) $this->get_sync_scope(); } /** * Running a product sync is the merchant asking for their products in the * chatbot, so it also switches automatic sync on: later edits, stock changes * and images then reach the bot on their own. Before 3.10.0 the Sync button * left the switch alone, so a store that never ticked it synced once and * went stale without anyone noticing. The "off by removal" note (set when * removing every category turned the switch off) has served its purpose. */ private function enable_auto_sync_for_run() { delete_option('onwebchat_wc_sync_off_by_removal'); if (!get_option('onwebchat_wc_sync_enabled', false)) { update_option('onwebchat_wc_sync_enabled', true); } } /** * Persist the sync scope: the categories auto-sync covers, plus whether the * scope is the whole catalogue. An empty array with $all false means the AI * training data holds nothing (a fresh site, or one whose products were * removed), so auto-sync has nothing to cover either. */ private function save_sync_scope($category_ids, $all) { $clean = array(); foreach ((array) $category_ids as $id) { $id = (int) $id; if ($id > 0) { $clean[$id] = $id; } } update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean))); update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0'); } /** * Is the product within the current sync scope? * With no categories saved it comes down to what the empty list means: the * whole catalogue (any product qualifies) or nothing at all. The picker offers the whole * category tree and selecting a category covers its whole subtree, so a * product is in scope when any of its categories is a scoped category OR a * descendant of one. This mirrors the bulk sync tax query * (include_children = true). */ private function product_in_scope($product) { $scope = $this->get_sync_scope(); if (empty($scope)) { return $this->is_scope_all(); } foreach ($product->get_category_ids() as $cat_id) { $cat_id = (int) $cat_id; if (in_array($cat_id, $scope, true)) { return true; } // Walk up to the root: a scoped ancestor puts the product in scope. foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) { if (in_array((int) $ancestor_id, $scope, true)) { return true; } } } return false; } /** * Is this category already covered by the given scope? A scope covers a * category when it holds the category itself or any of its ancestors, * because selecting a category always includes its whole subtree. */ private function scope_covers($scope, $category_id) { $category_id = (int) $category_id; if (in_array($category_id, $scope, true)) { return true; } foreach (get_ancestors($category_id, 'product_cat', 'taxonomy') as $ancestor_id) { if (in_array((int) $ancestor_id, $scope, true)) { return true; } } return false; } /** * Which of the submitted categories are NOT yet covered by the saved scope. * These are the only ones a sync has to push: everything already in scope is * in the training data already. */ private function categories_added($submitted, $saved_scope) { if (empty($saved_scope)) { return array(); // whole catalogue already in scope, nothing is new } $added = array(); foreach ($submitted as $category_id) { $category_id = (int) $category_id; if ($category_id > 0 && !$this->scope_covers($saved_scope, $category_id)) { $added[$category_id] = $category_id; } } return array_values($added); } /** * Which of the saved categories the merchant just unticked. Used to offer an * explicit removal: unticking alone never drops anything (see * ajax_scope_remove_start), because the saved scope only grows on sync. */ private function categories_removed($submitted, $saved_scope) { if (empty($saved_scope)) { return array(); } $removed = array(); foreach ($saved_scope as $category_id) { $category_id = (int) $category_id; if ($category_id > 0 && !$this->scope_covers($submitted, $category_id)) { $removed[$category_id] = $category_id; } } return array_values($removed); } /** * tax_query for "products inside $terms but not inside $exclude", both * including their subtrees. Empty $terms means the whole catalogue. * Returns null when no restriction applies at all. */ private function build_scope_tax_query($terms, $exclude = array()) { $clauses = array(); if (!empty($terms)) { $clauses[] = array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => array_map('intval', $terms), 'include_children' => true, ); } if (!empty($exclude)) { $clauses[] = array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => array_map('intval', $exclude), 'include_children' => true, 'operator' => 'NOT IN', ); } if (empty($clauses)) { return null; } if (count($clauses) > 1) { $clauses['relation'] = 'AND'; } return $clauses; } /** * Count published products within the given scope (empty = whole catalogue), * optionally excluding everything inside $exclude and its subtrees. * Uses found_posts so we do not load every ID into memory. */ private function count_products_in_scope($category_ids, $exclude = array()) { $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, ); $tax_query = $this->build_scope_tax_query($category_ids, $exclude); if ($tax_query !== null) { $args['tax_query'] = $tax_query; } $query = new WP_Query($args); return (int) $query->found_posts; } /** * What the AI training data currently covers, for the settings screen: * array(categories, products, whole_catalogue, nothing, known). Three * states: the whole catalogue, the saved categories, or nothing synced yet. * 'known' is false only on a site upgraded from an older version whose * empty scope could mean either, and there the screen says nothing at all * rather than something wrong. */ public function get_scope_summary() { $scope = $this->get_sync_scope(); $all = $this->is_scope_all(); return array( 'categories' => count($scope), 'products' => $all ? $this->count_products_in_scope(array()) : ($scope ? $this->count_products_in_scope($scope) : 0), 'whole_catalogue' => $all, 'nothing' => !$all && !$scope, 'known' => $this->is_scope_known(), ); } /** * Turn HTML entities into real characters. Product text is often stored * double-encoded ("&quot;" for a quote), where a single pass still * leaves """ in the text the bot is trained on, so decode until the * string stops changing (3 passes is far more than any real content needs). * Always call this AFTER strip_tags: decoding first could turn text like * "price < 100 and > 50" into something strip_tags eats as a tag. */ private function decode_entities($value) { $value = (string) $value; for ($i = 0; $i < 3; $i++) { $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8'); if ($decoded === $value) { break; } $value = $decoded; } // html_entity_decode turns   into a non-breaking space; make it a // plain space so the text does not carry invisible oddities. return str_replace("\xC2\xA0", ' ', $value); } /** * Prepare product data for sync */ private function prepare_product_data($product) { $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full'); // Get description based on sync mode $description = ''; $short_description = $this->decode_entities(strip_tags($product->get_short_description())); if ($sync_mode === 'short_only') { $description = $short_description; } else if ($sync_mode === 'short_plus_full') { // Send both texts: the short description first, then the full one. $full_description = $this->decode_entities(strip_tags($product->get_description())); $parts = array_filter(array(trim($short_description), trim($full_description))); $description = implode("\n\n", $parts); } else if ($sync_mode === 'short_fallback_full') { if (!empty($short_description)) { $description = $short_description; } else { // Fallback to the first 200 words of the full description. // Split with a Unicode-aware regex: str_word_count() does not // recognize non-latin (e.g. Greek) words, so the old word cut // was unreliable on multibyte text. $full_description = $this->decode_entities(strip_tags($product->get_description())); $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY); if (is_array($words) && count($words) > 200) { $description = implode(' ', array_slice($words, 0, 200)) . '...'; } else { $description = $full_description; } } } // Enforce max length by characters, not bytes: a byte-based substr() // can cut a multibyte UTF-8 character (e.g. Greek text) in half. $max_length = ($sync_mode === 'short_plus_full') ? $this->max_description_length_combined : $this->max_description_length; if (mb_strlen($description, 'UTF-8') > $max_length) { $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...'; } $sku = $product->get_sku(); $categories = $this->get_product_category_names($product); $url = get_permalink($product->get_id()); // Structured fields. The server rebuilds the embedding text from these, // so there is no need to send a pre-formatted "text" blob. $data = array( 'product_id' => $product->get_id(), // Names are stored HTML-escaped ("Bags & Belts"), so decode them: the name is // also the title of the product card the widget shows (3.10.0+). 'name' => $this->decode_entities($product->get_name()), 'short_description' => trim($description), 'url' => $url, 'sku' => $sku, 'categories' => $categories, 'currency' => get_woocommerce_currency(), ); // Price, as the customer sees it in the shop. get_price() returns the value // as entered in admin, which excludes tax on shops that enter net prices but // display gross ones, so the AI would quote a price the visitor never sees. // wc_get_price_to_display() applies the shop's tax display settings. $raw_price = $product->get_price(); if ($raw_price !== '') { $data['price'] = wc_get_price_to_display($product); // When the shop displays taxed prices, also send the untaxed price so the // AI can quote both. if (wc_tax_enabled()) { $price_excl_tax = wc_get_price_excluding_tax($product); if ((float) $price_excl_tax !== (float) $data['price']) { $data['price_excl_tax'] = $price_excl_tax; } } } if ($product->is_type('variable')) { // Display min/max prices, consistent with the display price used for // simple products. $data['price_min'] = $product->get_variation_price('min', true); $data['price_max'] = $product->get_variation_price('max', true); } else { $regular_price = $product->get_regular_price(); if ($regular_price !== '') { $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price)); } // Only advertise a sale price while the sale is actually active. if ($product->is_on_sale() && $product->get_sale_price() !== '') { $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price())); } } // Stock availability $data['in_stock'] = $product->is_in_stock(); if ($product->managing_stock()) { $stock_qty = $product->get_stock_quantity(); if ($stock_qty !== null) { $data['quantity'] = (int) $stock_qty; } } // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies. $brand = $this->get_product_brand($product); if (!empty($brand)) { $data['manufacturer'] = $brand; } // Variation attributes / options (Color, Size, ...) $attributes = $this->get_product_attributes($product); if (!empty($attributes)) { $data['attributes'] = $attributes; } // Tags $tags = $this->get_product_tags($product); if (!empty($tags)) { $data['tags'] = $tags; } // Average rating and review count $rating = (float) $product->get_average_rating(); if ($rating > 0) { $data['rating'] = $rating; $data['review_count'] = (int) $product->get_review_count(); } // Product thumbnail (3.10.0+): shown as a small product card under the chatbot's reply // when it recommends this product. Always sent, '' when the product has no usable // image: the server then clears the thumbnail it stored for an earlier sync. $data['image'] = $this->get_product_image_url($product); return $data; } /** * Thumbnail URL for the product card the chat widget shows under a chatbot reply, or '' * when the product has no usable image. * * - The WooCommerce catalogue thumbnail size (300px by default), so the widget never * loads the full-size photo. WordPress falls back to the original file when that * size was never generated. * - Uploaded file names keep non-Latin letters (a Greek "κούπα.jpg" stays Greek in the * URL) and WordPress returns them unencoded, so every byte outside printable ASCII * is percent-encoded here: the widget, the dashboard and the server then all handle * one plain ASCII URL. Already encoded parts (%CE%BA...) are left as they are. * - Shops served over HTTPS get an HTTPS image link, otherwise the browser would block * the picture on the shop page as mixed content. * - Anything that is not an absolute http(s) URL, or is longer than the 1000 characters * the server stores, is dropped (the product then syncs without a picture). */ private function get_product_image_url($product) { $image_id = (int) $product->get_image_id(); if ($image_id <= 0) { return ''; } $image_url = wp_get_attachment_image_url($image_id, 'woocommerce_thumbnail'); if (!$image_url) { $image_url = wp_get_attachment_image_url($image_id, 'thumbnail'); } if (!is_string($image_url)) { return ''; } $image_url = trim($image_url); if ($image_url === '') { return ''; } $site_is_https = is_ssl() || (stripos(home_url('/'), 'https://') === 0); // Protocol-relative URL (some CDN plugins return "//cdn.example.com/..."). if (substr($image_url, 0, 2) === '//') { $image_url = ($site_is_https ? 'https:' : 'http:') . $image_url; } if ($site_is_https && stripos($image_url, 'http://') === 0) { $image_url = set_url_scheme($image_url, 'https'); } // Percent-encode every byte outside printable ASCII (multibyte letters, spaces, // control characters). No /u flag on purpose: each byte of a UTF-8 sequence is // encoded separately, which is exactly the encoding a browser would apply. $image_url = preg_replace_callback('/[^\x21-\x7E]/', function ($m) { return rawurlencode($m[0]); }, $image_url); if (!is_string($image_url) || !preg_match('#^https?://[^\s<>"\'\\\\]+$#i', $image_url)) { return ''; } if (strlen($image_url) > 1000) { return ''; } return $image_url; } /** * Get product category names */ private function get_product_category_names($product) { $categories = array(); $category_ids = $product->get_category_ids(); foreach ($category_ids as $cat_id) { $term = get_term($cat_id, 'product_cat'); if ($term && !is_wp_error($term)) { $categories[] = $this->decode_entities($term->name); } } return $categories; } /** * Get the product's brand name from whichever brand taxonomy is available. * Supports WooCommerce 9.6+ native brands and the common brand plugins. */ private function get_product_brand($product) { $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand'); foreach ($taxonomies as $taxonomy) { if (!taxonomy_exists($taxonomy)) { continue; } $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); if (!is_wp_error($terms) && !empty($terms)) { return $this->decode_entities($terms[0]); } } return ''; } /** * Get visible product attributes as an array of { name, options }. * Works for both custom and taxonomy-based (global) attributes. */ private function get_product_attributes($product) { $result = array(); foreach ($product->get_attributes() as $attribute) { if (!is_object($attribute) || !$attribute->get_visible()) { continue; } $name = wc_attribute_label($attribute->get_name()); if ($attribute->is_taxonomy()) { $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names')); } else { $options = $attribute->get_options(); } $options = array_values(array_filter(array_map('trim', (array) $options))); if (!empty($name) && !empty($options)) { $result[] = array( 'name' => $name, 'options' => $options, ); } } return $result; } /** * Get product tag names. */ private function get_product_tags($product) { $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names')); if (is_wp_error($tags) || empty($tags)) { return array(); } return array_map(array($this, 'decode_entities'), $tags); } /** * Send batch of products to API (optimized) * @param array $products - Array of product data * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run) * @param int $sync_done - Products pushed so far in the run, including this batch * * When $sync_total is > 0 the batch is tagged with the run total/progress so * the server can relay a live progress bar to open dashboards. */ private function send_product_batch($products, $sync_total = 0, $sync_done = 0) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { error_log('onWebChat WooCommerce Sync - Chat ID not configured'); return false; } // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings) $secret = $this->get_secret(false); if (empty($secret)) { error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.'); return array( 'success' => false, 'error' => 'No secret configured. Please connect WooCommerce integration.', 'needs_reconnect' => true ); } // Extract key part (before first slash if present) $chatIdKey = explode('/', $chatId)[0]; $endpoint = $this->get_api_endpoint() . '/product/batch'; $payload = array( 'site_id' => $chatIdKey, 'site_url' => get_site_url(), 'products' => $products ); // Tag bulk-run batches with the run total + progress so the server can // relay a live progress bar to open dashboards. Omitted for incremental // single-product syncs (which pass no total). if ((int) $sync_total > 0) { $payload['sync_total'] = (int) $sync_total; $payload['sync_done'] = min((int) $sync_done, (int) $sync_total); } // Generate authentication headers (same as send_authenticated_request) $timestamp = time(); $nonce = base64_encode(random_bytes(16)); $body_json = wp_json_encode($payload); // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body) $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json; $signature = hash_hmac('sha256', $message, $secret); // Send request $request_args = array( 'method' => 'POST', // A batch whose descriptions are summarized for the first time costs // the server one model call per oversized product, so it needs far // more than the 30s that is plenty for every other endpoint. 'timeout' => 180, 'headers' => array( 'Content-Type' => 'application/json', 'X-OWC-SiteId' => $chatIdKey, 'X-OWC-Timestamp' => $timestamp, 'X-OWC-Nonce' => $nonce, 'X-OWC-Signature' => $signature, ), 'body' => $body_json, ); // Disable SSL verification for local dev server if ($this->use_testing_mode) { $request_args['sslverify'] = false; } $response = wp_remote_post($endpoint, $request_args); if (is_wp_error($response)) { error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message()); return array( 'success' => false, 'error' => 'Network error: ' . $response->get_error_message() ); } $response_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); // If authentication failed (401), the secret is invalid or out of sync if ($response_code === 401) { // Clear the invalid secret delete_option('onwebchat_wc_sync_secret'); error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.'); // Store admin notice about authentication failure set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600); return array( 'success' => false, 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.', 'needs_reconnect' => true ); } if ($response_code === 200 && isset($body['success']) && $body['success']) { // Clear any previous auth errors on success delete_transient('onwebchat_wc_auth_error'); return $body; // Return full response with stats } $error_msg = 'Batch sync failed'; if (isset($body['error'])) { $error_msg .= ': ' . $body['error']; } error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true)); return array( 'success' => false, 'error' => $error_msg ); } /** * Send sync completion notification to server (triggers Angular modal) */ private function send_sync_completion_notification($total_stats) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { error_log('onWebChat WooCommerce Sync - Chat ID not configured'); return false; } $secret = $this->get_secret(false); if (empty($secret)) { error_log('onWebChat WooCommerce Sync - No secret configured'); return false; } $chatIdKey = explode('/', $chatId)[0]; $endpoint = $this->get_api_endpoint() . '/product/sync-complete'; $payload = array( 'site_id' => $chatIdKey, 'stats' => $total_stats ); // Generate authentication headers $timestamp = time(); $nonce = base64_encode(random_bytes(16)); $body_json = wp_json_encode($payload); $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json; $signature = hash_hmac('sha256', $message, $secret); $request_args = array( 'method' => 'POST', 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json', 'X-OWC-SiteId' => $chatIdKey, 'X-OWC-Timestamp' => $timestamp, 'X-OWC-Nonce' => $nonce, 'X-OWC-Signature' => $signature, ), 'body' => $body_json, ); if ($this->use_testing_mode) { $request_args['sslverify'] = false; } $response = wp_remote_post($endpoint, $request_args); if (is_wp_error($response)) { error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message()); return false; } $response_code = wp_remote_retrieve_response_code($response); // If authentication failed (401), the secret is invalid or out of sync if ($response_code === 401) { delete_option('onwebchat_wc_sync_secret'); error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.'); set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600); return false; } if ($response_code >= 200 && $response_code < 300) { error_log('onWebChat WooCommerce Sync - Completion notification sent successfully'); return true; } error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code); return false; } /** * Send product upsert to server (uses batch endpoint with single product) */ private function send_product_upsert($product_data, $product_id) { // Use batch endpoint with single product $result = $this->send_product_batch(array($product_data)); if ($result && isset($result['success']) && $result['success']) { // Clear any previous errors delete_post_meta($product_id, '_onwebchat_sync_error'); update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp')); return true; } else { // Log error if batch failed $error_message = 'Failed to sync product'; if ($result && isset($result['error'])) { $error_message = $result['error']; } elseif (!$result) { $error_message = 'Batch sync request failed'; } $this->log_error($product_id, $error_message); return false; } } /** * Send product delete to server */ private function send_product_delete($product_id) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { return false; } // Extract key part (before first slash if present) $chatIdKey = explode('/', $chatId)[0]; $endpoint = $this->get_api_endpoint() . '/product/delete'; $payload = array( 'site_id' => $chatIdKey, // Use key part only 'site_url' => get_site_url(), 'product_id' => $product_id ); $this->send_authenticated_request($endpoint, $payload, $product_id); } /** * Remove many products from the AI training data in one call. Used by the * scope-removal flow: one request per product would hit onWebChat's * product-sync rate limit on any real catalogue. * * @param array $product_ids * @return array {success, deleted, errors} */ private function send_products_delete_batch($product_ids) { $product_ids = array_values(array_unique(array_map('intval', (array) $product_ids))); if (empty($product_ids)) { return array('success' => true, 'deleted' => 0, 'errors' => 0); } $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); } $chatIdKey = explode('/', $chatId)[0]; $result = $this->send_authenticated_request($this->get_api_endpoint() . '/product/delete', array( 'site_id' => $chatIdKey, 'site_url' => get_site_url(), 'product_ids' => $product_ids, )); if (empty($result['success'])) { return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); } return array('success' => true, 'deleted' => count($product_ids), 'errors' => 0); } /** * Send a lightweight availability update to onWebChat (no re-embed on the server). */ private function send_product_stock($product_id, $in_stock) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { return false; } // Extract key part (before first slash if present) $chatIdKey = explode('/', $chatId)[0]; $endpoint = $this->get_api_endpoint() . '/product/stock'; $payload = array( 'site_id' => $chatIdKey, // Use key part only 'site_url' => get_site_url(), 'product_id' => $product_id, 'in_stock' => (bool) $in_stock, ); $this->send_authenticated_request($endpoint, $payload, $product_id); } /** * Get cached secret from local options * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request */ private function get_secret($force_refresh = false) { // Always return cached secret - never fetch automatically // Secret must be obtained via authenticated request in WooCommerce settings $secret = get_option('onwebchat_wc_sync_secret'); if (!empty($secret)) { return $secret; } // No secret available - user must authenticate in WooCommerce settings return null; } /** * Request secret from server with authentication * This is called when user clicks "Connect WooCommerce" with their password * * @param {string} email - User's onWebChat email * @param {string} password - User's onWebChat password * @return {array} - ['success' => bool, 'secret' => string, 'error' => string] */ public function request_secret_with_auth($email, $password) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { return array('success' => false, 'error' => 'No Chat ID configured'); } // Extract key part (before first slash if present) $key = explode('/', $chatId)[0]; // Request secret from server with authentication $secret_endpoint = $this->get_api_endpoint() . '/secret'; $response = wp_remote_post($secret_endpoint, array( 'timeout' => 15, 'sslverify' => !$this->use_testing_mode, 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => wp_json_encode(array( 'email' => $email, 'password' => $password, 'site_key' => $key, 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '', )), )); if (is_wp_error($response)) { $error_message = $response->get_error_message(); error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message); return array('success' => false, 'error' => 'Connection failed: ' . $error_message); } $status_code = wp_remote_retrieve_response_code($response); $response_body_raw = wp_remote_retrieve_body($response); $body = json_decode($response_body_raw, true); // Log response for debugging. Redact the secret so it never lands in server/debug logs // (a successful response body contains the HMAC secret). $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw); error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500)); // Handle specific HTTP status codes if ($status_code === 401) { return array('success' => false, 'error' => 'Invalid email or password'); } if ($status_code === 403) { return array('success' => false, 'error' => 'You do not have access to this site'); } // Success case if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) { $secret = isset($body['secret']) ? $body['secret'] : null; if (empty($secret)) { error_log('onWebChat WooCommerce Sync - Success response but no secret provided'); return array('success' => false, 'error' => 'Server response missing secret'); } update_option('onwebchat_wc_sync_secret', $secret); // Enable AI order-status lookup by default on connect and register our // callback URL with onWebChat (best effort; the merchant can toggle it off). update_option('onwebchat_wc_order_lookup_enabled', true); global $onwebchat_wc_orders; if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) { $onwebchat_wc_orders->push_order_lookup_config(true); } return array('success' => true, 'secret' => $secret); } // Extract error message from various possible response formats $error_message = 'Unknown error'; if (is_array($body)) { // Try different possible error fields if (isset($body['error'])) { $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']); } elseif (isset($body['message'])) { $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']); } elseif (isset($body['errors']) && is_array($body['errors'])) { $error_message = implode(', ', $body['errors']); } } elseif (!empty($response_body_raw)) { // If body is not JSON or empty, use raw response (truncated) $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200); } // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML. $error_message = sanitize_text_field($error_message); // Include status code in error message if not already included if ($status_code && strpos($error_message, 'HTTP') === false) { $error_message = 'HTTP ' . $status_code . ': ' . $error_message; } error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message); return array('success' => false, 'error' => $error_message); } /** * Send authenticated request with HMAC signature */ private function send_authenticated_request($endpoint, $payload, $product_id = null) { // Get cached secret (must be obtained via authenticated connection in WooCommerce settings) $secret = $this->get_secret(false); if (empty($secret)) { if ($product_id) { $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.'); } return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.'); } $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; // Extract key part (before first slash if present) for consistency with server // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034" $chatIdKey = explode('/', $chatId)[0]; // Generate authentication headers $timestamp = time(); $nonce = base64_encode(random_bytes(16)); $body_json = wp_json_encode($payload); // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body) // IMPORTANT: Use the key part (not full chat_id) to match server-side verification $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json; $signature = hash_hmac('sha256', $message, $secret); // Send request $request_args = array( 'method' => 'POST', 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json', 'X-OWC-SiteId' => $chatIdKey, // Use key part only 'X-OWC-Timestamp' => $timestamp, 'X-OWC-Nonce' => $nonce, 'X-OWC-Signature' => $signature, ), 'body' => $body_json, ); // Disable SSL verification for local dev server if ($this->use_testing_mode) { $request_args['sslverify'] = false; } $response = wp_remote_post($endpoint, $request_args); // Handle response if (is_wp_error($response)) { $error_message = $response->get_error_message(); if ($product_id) { $this->log_error($product_id, $error_message); } return array('success' => false, 'error' => $error_message); } $status_code = wp_remote_retrieve_response_code($response); // Success if ($status_code >= 200 && $status_code < 300) { return array('success' => true); } // If authentication failed (401), the secret may be invalid if ($status_code === 401) { // Clear the invalid secret delete_option('onwebchat_wc_sync_secret'); if ($product_id) { $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.'); } return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.'); } // Error $error_body = wp_remote_retrieve_body($response); if ($product_id) { $this->log_error($product_id, "HTTP $status_code: $error_body"); } return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code); } /** * Log sync error to product meta */ private function log_error($product_id, $error_message) { update_post_meta($product_id, '_onwebchat_sync_error', array( 'message' => $error_message, 'timestamp' => current_time('timestamp') )); } /** * AJAX: Start bulk sync */ public function ajax_sync_existing_products() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); // This path can walk the whole catalogue in one request, and each batch // waits for the server (which may summarize descriptions with a model). @set_time_limit(0); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } // Check if sync is already in progress if (get_option('onwebchat_wc_bulk_in_progress', false)) { wp_send_json_error('A sync is already in progress. Please wait for it to complete.'); } // Rate limiting: prevent syncing more than once every 5 minutes $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0); $cooldown_period = 30; // seconds; keep in step with admin/tabs/woocommerce.php $time_since_last_sync = time() - $last_sync_time; if ($time_since_last_sync < $cooldown_period) { $wait_time = max(1, $cooldown_period - $time_since_last_sync); wp_send_json_error('Please wait ' . $wait_time . ' second(s) before syncing again.'); } // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. $category_ids = array(); if (isset($_POST['categories']) && $_POST['categories'] !== '') { foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) { $id = (int) trim($id); if ($id > 0) { $category_ids[] = $id; } } } // Above the hard cap a category selection is required: refuse an // unrestricted "sync all" when the catalogue is larger than the cap. $published_total = $this->count_products_in_scope(array()); if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) { wp_send_json_error(sprintf( 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.', number_format_i18n($published_total), number_format_i18n(self::MAX_SYNC_PRODUCTS) )); } // The saved scope only ever GROWS on a sync. Ticking more categories adds // them to what the bot knows; unticking never silently drops products, // removal is its own explicit, confirmed action (ajax_scope_remove_*). // An empty selection means the whole catalogue, which covers everything, // so it clears the scope. // // $run_terms / $run_exclude are what THIS run pushes, which is not the // same as the scope: when categories are added to an existing scope only // the added ones are pushed, so adding one subcategory to a 10,000 // product scope no longer re-sends all 10,000. $saved_scope = $this->get_sync_scope(); if (empty($category_ids)) { // Nothing ticked: the whole catalogue is the scope. $new_scope = array(); $new_all = true; $run_terms = array(); // push everything $run_exclude = array(); } elseif (empty($saved_scope)) { // Nothing picked before (a fresh site, or one whose scope was // removed, or one that used to sync everything): the ticks become // the scope, so they are still ticked after a refresh and the // summary can name them. $new_scope = $category_ids; $new_all = false; $run_terms = $category_ids; $run_exclude = array(); } else { $added = $this->categories_added($category_ids, $saved_scope); $new_scope = array_values(array_unique(array_merge($saved_scope, $category_ids))); $new_all = false; if (!empty($added)) { $run_terms = $added; $run_exclude = $saved_scope; // already synced, skip it } else { // Nothing new was ticked, so the click means "refresh what I have". $run_terms = $new_scope; $run_exclude = array(); } } $this->save_sync_scope($new_scope, $new_all); $this->enable_auto_sync_for_run(); update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms))); update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude))); // Store the current sync start time update_option('onwebchat_wc_last_sync_start', time()); // Reset bulk sync progress update_option('onwebchat_wc_bulk_page', 0); update_option('onwebchat_wc_bulk_done', 0); // Count the products THIS run will push, capped at the hard limit. $total = $this->count_products_in_scope($run_terms, $run_exclude); if ($total > self::MAX_SYNC_PRODUCTS) { $total = self::MAX_SYNC_PRODUCTS; } update_option('onwebchat_wc_bulk_total', $total); update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter update_option('onwebchat_wc_bulk_in_progress', true); // Process sync directly instead of using unreliable WP Cron $sync_result = $this->do_bulk_sync_all($category_ids); wp_send_json_success(array( 'message' => 'Bulk sync completed', 'total' => $total, 'result' => $sync_result )); } /** * AJAX: begin a client-driven bulk sync. * * Sets up the progress state and returns the total number of products to * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page * per request) until the run reports it is complete. Because each request is * short, the whole sync no longer rides on a single request that outran the * web server timeout and reported a false failure while products kept * syncing. */ public function ajax_start_bulk_sync() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } if (get_option('onwebchat_wc_bulk_in_progress', false)) { wp_send_json_error('A sync is already in progress. Please wait for it to complete.'); } // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. $category_ids = array(); if (isset($_POST['categories']) && $_POST['categories'] !== '') { foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) { $id = (int) trim($id); if ($id > 0) { $category_ids[] = $id; } } } // Above the hard cap a category selection is required: refuse an // unrestricted "sync all" when the catalogue is larger than the cap. $published_total = $this->count_products_in_scope(array()); if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) { wp_send_json_error(sprintf( 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.', number_format_i18n($published_total), number_format_i18n(self::MAX_SYNC_PRODUCTS) )); } // Remember the merchant's choice so ongoing auto-sync stays within it: // selected categories become the sync scope; an unrestricted "sync all" // puts the whole catalogue in scope. $this->save_sync_scope($category_ids, empty($category_ids)); $this->enable_auto_sync_for_run(); // Count total products within scope, capped at the hard limit. $total = $this->count_products_in_scope($category_ids); if ($total > self::MAX_SYNC_PRODUCTS) { $total = self::MAX_SYNC_PRODUCTS; } // Reset progress state for a fresh run. update_option('onwebchat_wc_last_sync_start', time()); update_option('onwebchat_wc_bulk_page', 0); update_option('onwebchat_wc_bulk_done', 0); update_option('onwebchat_wc_bulk_total', $total); update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0)); // Only enter the "in progress" state when there is actually something to // sync, so a 0-product start (empty scope) can't leave the store stuck at // "a sync is already in progress". update_option('onwebchat_wc_bulk_in_progress', $total > 0); wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); } /** * AJAX: process the next page of the in-progress bulk sync and report progress. */ public function ajax_sync_next_batch() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); // One page of products waits for the server, which may summarize long // descriptions with a model: that outlives a default max_execution_time. @set_time_limit(0); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } wp_send_json_success($this->sync_next_page()); } /** * Process exactly one page (batch_size products) of the in-progress bulk * sync, advancing the persisted progress. The browser calls this once per * request (via ajax_sync_next_batch) until it reports the run is complete, * so no single request has to stay open for the whole catalogue. * * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does * NOT schedule a follow-up cron event: the browser drives the loop. Stats * are accumulated in an option across pages so the completion notification * (which drives the dashboard notice) carries the full run totals. * * @return array Progress snapshot: in_progress, complete, done, total, stats. */ private function sync_next_page() { $total = (int) get_option('onwebchat_wc_bulk_total', 0); if (!get_option('onwebchat_wc_bulk_in_progress', false)) { return array( 'in_progress' => false, 'complete' => true, 'done' => (int) get_option('onwebchat_wc_bulk_done', 0), 'total' => $total, 'stats' => $this->get_bulk_stats(), ); } $page = (int) get_option('onwebchat_wc_bulk_page', 0); $done = (int) get_option('onwebchat_wc_bulk_done', 0); $stats = $this->get_bulk_stats(); $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $this->batch_size, 'paged' => $page + 1, 'orderby' => 'ID', 'order' => 'ASC', ); // Restrict to what THIS run pushes (see ajax_start_bulk_sync): the // categories being added, minus everything already synced. Falls back to // the saved scope for a run started before these options existed. $run_terms_raw = get_option('onwebchat_wc_bulk_run_terms', null); $run_terms = ($run_terms_raw === null) ? $this->get_sync_scope() : $this->parse_id_list($run_terms_raw); $run_exclude = $this->parse_id_list(get_option('onwebchat_wc_bulk_run_exclude', '')); $tax_query = $this->build_scope_tax_query($run_terms, $run_exclude); if ($tax_query !== null) { $args['tax_query'] = $tax_query; } $query = new WP_Query($args); $complete = false; if ($query->have_posts()) { $products_batch = array(); foreach ($query->posts as $post) { $product = wc_get_product($post->ID); if ($product && !$this->is_product_excluded($product)) { $products_batch[] = $this->prepare_product_data($product); } } $batch_done = 0; if (!empty($products_batch)) { // Tag with run total + running progress so the dashboard bar advances. $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch)); if ($result && isset($result['stats'])) { $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped']; $stats['created'] += (int) $result['stats']['created']; $stats['updated'] += (int) $result['stats']['updated']; $stats['skipped'] += (int) $result['stats']['skipped']; $stats['errors'] += (int) $result['stats']['errors']; } else { // Batch failed outright: count the products as errors so the // summary reflects reality rather than silently skipping them. $batch_done = count($products_batch); $stats['errors'] += count($products_batch); } } $done += $batch_done; $page += 1; update_option('onwebchat_wc_bulk_page', $page); update_option('onwebchat_wc_bulk_done', $done); update_option('onwebchat_wc_bulk_stats', $stats); // Stop once we have covered the counted total or reached the hard cap. if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) { $complete = true; } } else { // No more products in scope. $complete = true; } wp_reset_postdata(); if ($complete) { $this->send_sync_completion_notification($stats); update_option('onwebchat_wc_bulk_in_progress', false); update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); // Show 100% when the counted total was reached; otherwise leave the // real processed figure (e.g. an early empty page or the hard cap). if ($total > 0 && $done >= $total) { $done = $total; } update_option('onwebchat_wc_bulk_done', $done); } return array( 'in_progress' => !$complete, 'complete' => $complete, 'done' => $done, 'total' => $total, 'stats' => $stats, ); } /** * Read the accumulated bulk-sync stats option, normalised to the four keys. */ private function get_bulk_stats() { $stats = get_option('onwebchat_wc_bulk_stats', array()); if (!is_array($stats)) { $stats = array(); } return array( 'created' => isset($stats['created']) ? (int) $stats['created'] : 0, 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0, 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0, 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0, ); } /** * Process all products in bulk sync directly (not via cron). * * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue. */ private function do_bulk_sync_all($category_ids = array()) { $total = get_option('onwebchat_wc_bulk_total', 0); $page = 0; $total_done = 0; $considered = 0; // products fetched so far, used to enforce the hard cap $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0); $max = self::MAX_SYNC_PRODUCTS; // Process all products in batches while (true) { $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $this->batch_size, 'paged' => $page + 1, 'orderby' => 'ID', 'order' => 'ASC', ); // Restrict to the chosen categories and their subtrees, to match the // per-product scope check and the counts shown in the picker. if (!empty($category_ids)) { $args['tax_query'] = array(array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => array_map('intval', $category_ids), 'include_children' => true, )); } $query = new WP_Query($args); if (!$query->have_posts()) { break; } // Collect products in this batch, honoring the hard cap. $products_batch = array(); $reached_cap = false; foreach ($query->posts as $post) { if ($considered >= $max) { $reached_cap = true; break; } $considered++; $product = wc_get_product($post->ID); if ($product && !$this->is_product_excluded($product)) { $products_batch[] = $this->prepare_product_data($product); } } // Send batch if (!empty($products_batch)) { $result = $this->send_product_batch($products_batch); if ($result && isset($result['stats'])) { $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped']; $all_stats['created'] += $result['stats']['created']; $all_stats['updated'] += $result['stats']['updated']; $all_stats['skipped'] += $result['stats']['skipped']; $all_stats['errors'] += $result['stats']['errors']; } else { // Fallback $total_done += count($products_batch); } // Update progress after each batch so AJAX polling can see it update_option('onwebchat_wc_bulk_done', $total_done); // Breathe between batches so a long run cannot walk into the // server's product-sync rate limit (150 requests per 5 minutes // per IP). One second is plenty: each batch already costs a // synchronous HTTP call of its own, so the real cycle time is // seconds even when nothing needs summarizing. sleep(1); } wp_reset_postdata(); $page++; // Stop once the hard cap is reached. if ($reached_cap || $considered >= $max) { break; } // Safety check - don't loop forever. The cap allows up to // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard. if ($page > ($max / $this->batch_size) + 10) { break; } } // Send completion notification to Angular dashboard with total stats $this->send_sync_completion_notification($all_stats); // Mark sync as complete update_option('onwebchat_wc_bulk_in_progress', false); update_option('onwebchat_wc_bulk_done', $total_done); update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); return array( 'done' => $total_done, 'total' => $total, 'stats' => $all_stats ); } /** * Process bulk sync batch (via WP Cron) - Uses batch API endpoint */ public function process_bulk_sync_batch() { error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called'); if (!get_option('onwebchat_wc_bulk_in_progress', false)) { error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting'); return; } $page = get_option('onwebchat_wc_bulk_page', 0); $done = get_option('onwebchat_wc_bulk_done', 0); $total = get_option('onwebchat_wc_bulk_total', 0); error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total); // Get batch of products $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $this->batch_size, 'paged' => $page + 1, 'orderby' => 'ID', 'order' => 'ASC', ); // Restrict to the saved sync scope and its subcategories, consistent // with the synchronous bulk sync path. $scope = $this->get_sync_scope(); if (!empty($scope)) { $args['tax_query'] = array(array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => array_map('intval', $scope), 'include_children' => true, )); } $query = new WP_Query($args); if ($query->have_posts()) { // Collect all products in this batch $products_batch = array(); foreach ($query->posts as $post) { $product = wc_get_product($post->ID); if ($product && !$this->is_product_excluded($product)) { $products_batch[] = $this->prepare_product_data($product); } } // Send entire batch in one request $batch_done = 0; if (!empty($products_batch)) { $result = $this->send_product_batch($products_batch); error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true)); if ($result && isset($result['stats'])) { // Count created + updated + skipped as "done" $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped']; error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done); } else { // Fallback: assume all sent $batch_done = count($products_batch); error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done); } } // Update progress $new_done = $done + $batch_done; error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total); update_option('onwebchat_wc_bulk_page', $page + 1); update_option('onwebchat_wc_bulk_done', $new_done); // Check if we've processed all products if ($new_done >= $total || !$query->have_posts()) { // Sync complete error_log('onWebChat WooCommerce Sync - Marking sync as complete'); update_option('onwebchat_wc_bulk_in_progress', false); update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100% } else { // Schedule next batch error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds'); wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch'); } } else { // No more products - sync complete update_option('onwebchat_wc_bulk_in_progress', false); update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100% } wp_reset_postdata(); } /** * AJAX: Regenerate secret (fetch from server) */ public function ajax_regenerate_secret() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } // Clear cached secret - user will need to re-authenticate delete_option('onwebchat_wc_sync_secret'); // Clear any authentication error notices delete_transient('onwebchat_wc_auth_error'); wp_send_json_success(array( 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.', 'needs_reconnect' => true )); } /** * AJAX: Reset sync status */ public function ajax_reset_sync_status() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } $total = get_option('onwebchat_wc_bulk_total', 0); // Mark sync as complete update_option('onwebchat_wc_bulk_in_progress', false); update_option('onwebchat_wc_bulk_done', $total); update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); wp_send_json_success(array( 'message' => 'Sync status reset successfully' )); } /** * AJAX handler to get current sync status */ public function ajax_get_sync_status() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } $in_progress = get_option('onwebchat_wc_bulk_in_progress', false); $done = get_option('onwebchat_wc_bulk_done', 0); $total = get_option('onwebchat_wc_bulk_total', 0); wp_send_json_success(array( 'in_progress' => $in_progress, 'done' => $done, 'total' => $total )); } /** * AJAX handler to save sync enabled setting */ public function ajax_save_sync_enabled() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1'; update_option('onwebchat_wc_sync_enabled', $sync_enabled); wp_send_json_success(array( 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled', 'enabled' => $sync_enabled )); } /** * Get sync status for admin display */ public function get_sync_status() { $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0); $in_progress = get_option('onwebchat_wc_bulk_in_progress', false); $done = get_option('onwebchat_wc_bulk_done', 0); $total = get_option('onwebchat_wc_bulk_total', 0); return array( 'last_sync' => $last_sync, 'in_progress' => $in_progress, 'done' => $done, 'total' => $total, ); } /** * AJAX: start removing categories from the AI training data. * * The counterpart of the additive sync scope: unticking a category never * removes anything by itself, the merchant has to ask for it here. Posts the * categories that should REMAIN ticked; whatever the saved scope holds on top * of that is what gets removed, together with its products, unless those * products also sit in a category that stays. */ public function ajax_scope_remove_start() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } if (get_option('onwebchat_wc_bulk_in_progress', false)) { wp_send_json_error('A sync is in progress. Please wait for it to finish.'); } @set_time_limit(0); $keep = array(); if (isset($_POST['categories']) && $_POST['categories'] !== '') { $keep = $this->parse_id_list(sanitize_text_field(wp_unslash($_POST['categories']))); } $saved_scope = $this->get_sync_scope(); if (empty($saved_scope)) { wp_send_json_error('Your whole catalogue is synced, so there are no categories to remove. Select the categories you want to keep and sync again first.'); } $removed = $this->categories_removed($keep, $saved_scope); if (empty($removed)) { wp_send_json_error('No synced categories were unticked, so there is nothing to remove.'); } // Products of the dropped categories that are not also in a category the // merchant keeps: a product in both stays in the training data. $total = $this->count_products_in_scope($removed, $keep); update_option('onwebchat_wc_remove_terms', implode(',', $removed)); update_option('onwebchat_wc_remove_keep', implode(',', $keep)); update_option('onwebchat_wc_remove_total', $total); update_option('onwebchat_wc_remove_done', 0); update_option('onwebchat_wc_remove_in_progress', true); $complete = ($total === 0); if ($complete) { $this->finish_scope_removal(); } wp_send_json_success(array( 'total' => $total, 'categories' => count($removed), 'done' => 0, 'complete' => $complete, // Removing everything also switches automatic product sync off, see // finish_scope_removal(); the UI says so before the merchant confirms. 'disables_sync' => empty($keep), )); } /** * AJAX: delete one page of products of the categories being removed. * The browser calls this until it reports complete, exactly like the sync. */ public function ajax_scope_remove_batch() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } @set_time_limit(0); if (!get_option('onwebchat_wc_remove_in_progress', false)) { wp_send_json_success(array( 'complete' => true, 'done' => (int) get_option('onwebchat_wc_remove_done', 0), 'total' => (int) get_option('onwebchat_wc_remove_total', 0), )); } $removed = $this->parse_id_list(get_option('onwebchat_wc_remove_terms', '')); $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); $total = (int) get_option('onwebchat_wc_remove_total', 0); $done = (int) get_option('onwebchat_wc_remove_done', 0); // Never query without a category restriction. An empty $removed would make // build_scope_tax_query() return no clause at all, and this page would then // delete the first 200 products of the WHOLE catalogue from the training // data. That can only happen if the run state was lost half way (option // cleared, in-progress flag left behind), so treat it as "nothing to do". if (empty($removed)) { update_option('onwebchat_wc_remove_in_progress', false); delete_option('onwebchat_wc_remove_terms'); delete_option('onwebchat_wc_remove_keep'); wp_send_json_success(array( 'complete' => true, 'done' => $done, 'total' => $total, )); } $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => self::REMOVE_PAGE_SIZE, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', // Deleting on the onWebChat side never changes this query, but the // rows already handled must be skipped, hence the offset. 'offset' => $done, ); $tax_query = $this->build_scope_tax_query($removed, $keep); if ($tax_query !== null) { $args['tax_query'] = $tax_query; } $query = new WP_Query($args); $ids = $query->posts; if (empty($ids)) { $this->finish_scope_removal(); return wp_send_json_success(array( 'complete' => true, 'done' => $done, 'total' => $total, )); } $result = $this->send_products_delete_batch($ids); if (empty($result['success'])) { wp_send_json_error('Could not remove the products from onWebChat. Please try again.'); } $done += count($ids); update_option('onwebchat_wc_remove_done', $done); $complete = ($done >= $total) || (count($ids) < self::REMOVE_PAGE_SIZE); if ($complete) { $this->finish_scope_removal(); } wp_send_json_success(array( 'complete' => $complete, 'done' => min($done, max($total, $done)), 'total' => max($total, $done), )); } /** * Close a removal run: the kept categories become the new sync scope, so * ongoing auto-sync stops covering what was just removed. * * @return bool */ private function finish_scope_removal() { $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); $had_scope = (bool) $this->get_sync_scope(); // After a removal the scope is exactly what is kept, nothing implied: an // empty list here means the AI training data holds no products, not the // whole catalogue. Only when something was really removed, so a no-op // call on a site that syncs everything leaves its scope alone. if ($had_scope || !empty($keep)) { $this->save_sync_scope($keep, false); } // Nothing left ticked means the bot should hold no products at all. An // empty scope means "the whole catalogue", so leaving automatic sync on // would push every product straight back in on its next edit. // Only when a scope was actually being removed: a no-op call on a store // that already syncs its whole catalogue must never touch the toggle. if (empty($keep) && $had_scope) { update_option('onwebchat_wc_sync_enabled', false); // Note who turned it off, so the next bulk sync can turn it back on. update_option('onwebchat_wc_sync_off_by_removal', true); } update_option('onwebchat_wc_remove_in_progress', false); delete_option('onwebchat_wc_remove_terms'); delete_option('onwebchat_wc_remove_keep'); return true; } /** * AJAX: Manually process batch (for debugging) */ public function ajax_manual_process_batch() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } // Manually trigger the cron job error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX'); $this->process_bulk_sync_batch(); // Return current status $status = $this->get_sync_status(); wp_send_json_success(array( 'message' => 'Batch processed', 'status' => $status )); } } // Initialize the sync module global $onwebchat_wc_sync; $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();