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) {
?>
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 (""" 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();