PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.10.0
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.10.0
3.10.0 3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 All 49 releases
onwebchat / includes / woocommerce-sync.php

woocommerce-sync.php in AI Chatbot for WooCommerce & Live Chat – onWebChat 3.10.0, at includes/woocommerce-sync.php

2,390 lines 96.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Product Sync Module
4 * Syncs WooCommerce products to onWebChat for AI bot training
5 */
6
7 if (!defined('ABSPATH')) {
8 exit; // Exit if accessed directly
9 }
10
11 class OnWebChat_WooCommerce_Sync {
12
13 private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
14 private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
15 // Descriptions are sent WHOLE: the onWebChat server decides what to do with
16 // them and rewrites the ones that do not fit its training text with a small
17 // model, instead of cutting them off (the tail of a description usually
18 // holds the specs and compatibility info). These are only sanity limits
19 // against pathological descriptions (page-builder dumps), sized so a
20 // 50-product batch stays far below the server's 10MB body limit.
21 private $max_description_length = 20000;
22 private $max_description_length_combined = 20000;
23 private $batch_size = 50;
24 private $use_testing_mode;
25
26 // Large-catalogue sync scope.
27 // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
28 // category picker so the merchant can choose what to sync. The sync is
29 // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
30 // catalogue. Above the cap a category selection is required.
31 const CATEGORY_SELECT_THRESHOLD = 2000;
32 const MAX_SYNC_PRODUCTS = 15000;
33
34 // How many products one removal request deletes from the AI training data.
35 // The server accepts up to 500 product_ids per call.
36 const REMOVE_PAGE_SIZE = 200;
37
38 /**
39 * Get the API endpoint based on testing mode
40 * @return string
41 */
42 private function get_api_endpoint() {
43 return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
44 }
45
46 public function __construct() {
47 // Read testing mode from global constant (defined in onwebchat.php)
48 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
49
50 // One-time migration to the "short + full" default. The Description Mode
51 // selector was hidden in the UI before this version, so a stored
52 // 'short_fallback_full' was the hidden form field's value, never a real
53 // merchant choice. An explicit 'short_only' is left untouched.
54 if (!get_option('onwebchat_wc_desc_mode_migrated')) {
55 if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') {
56 update_option('onwebchat_wc_sync_mode', 'short_plus_full');
57 }
58 update_option('onwebchat_wc_desc_mode_migrated', 1);
59 }
60 // Initialize settings
61 add_action('admin_init', array($this, 'register_settings'));
62
63 // Show authentication error notice globally (not just on WooCommerce tab)
64 add_action('admin_notices', array($this, 'show_auth_error_notice'));
65
66 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
67 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
68 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
69
70 // Lightweight availability hook: fires whenever a product's stock STATUS flips
71 // (including order-driven stock reductions that may not trigger a full product save).
72 // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
73 // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
74 // parent product's stock status from its variations and fires this action for the
75 // parent, which is the entity synced to onWebChat.
76 add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);
77
78 // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale
79 // prices via direct meta updates, NOT through a product save, so
80 // woocommerce_update_product never fires and the AI would keep quoting the
81 // pre-sale price. These two actions receive the affected product/variation
82 // IDs right after the cron applies or removes the sale prices.
83 add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1);
84 add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1);
85
86 // Variation price edits (the Variations tab saves via AJAX without always
87 // re-saving the parent post). Collect the parent IDs and sync each parent
88 // once on shutdown, so the synced price range follows variation changes.
89 add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1);
90 add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1);
91
92 // Handle product deletion (both trash and permanent delete)
93 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
94 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
95
96 // Bulk sync via WP Cron
97 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
98
99 // Admin AJAX handlers
100 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
101 // Client-driven chunked bulk sync: the browser starts a run, then calls
102 // the batch action repeatedly (one page per request) until it completes.
103 // This replaces the single long request that timed out on large
104 // catalogues and reported a false "sync failed" while products kept
105 // syncing.
106 add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync'));
107 add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch'));
108 add_action('wp_ajax_onwebchat_wc_scope_remove_start', array($this, 'ajax_scope_remove_start'));
109 add_action('wp_ajax_onwebchat_wc_scope_remove_batch', array($this, 'ajax_scope_remove_batch'));
110 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
111 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
112 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
113 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
114 add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
115 add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
116 }
117
118 /**
119 * AJAX: Connect WooCommerce with authentication
120 */
121 public function ajax_connect_woocommerce() {
122 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
123
124 if (!current_user_can('manage_options')) {
125 wp_send_json_error('Insufficient permissions');
126 }
127
128 // The password is only forwarded to onWebChat, never stored, echoed or put in a query,
129 // so it must NOT be sanitized. WordPress slash-escapes $_POST (wp_magic_quotes), and
130 // sanitize_text_field() on top of that trims it, collapses repeated spaces, turns "<"
131 // into an entity and DELETES any %xx sequence, so a correct password containing a quote,
132 // a space or a percent sign could never authenticate. wp_unslash() alone is right here.
133 $email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
134 $password = isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : '';
135
136 if (empty($email) || empty($password)) {
137 wp_send_json_error('Email and password are required');
138 }
139
140 $result = $this->request_secret_with_auth($email, $password);
141
142 if ($result['success']) {
143 // Clear any previous authentication errors
144 delete_transient('onwebchat_wc_auth_error');
145
146 wp_send_json_success(array(
147 'message' => 'WooCommerce sync connected successfully!'
148 ));
149 } else {
150 wp_send_json_error($result['error']);
151 }
152 }
153
154 /**
155 * Show authentication error notice globally across all admin pages
156 * (Hidden when already on WooCommerce tab since it has its own error message)
157 */
158 public function show_auth_error_notice() {
159 $auth_error = get_transient('onwebchat_wc_auth_error');
160
161 // Don't show if we're already on the WooCommerce tab (it has its own error message)
162 $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings'
163 && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
164
165 if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
166 ?>
167 <div class="notice notice-error">
168 <p>
169 <strong>⚠️ onWebChat WooCommerce Sync Error:</strong>
170 <?php echo esc_html($auth_error); ?>
171 <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
172 Fix Authentication
173 </a>
174 </p>
175 </div>
176 <?php
177 }
178 }
179
180 /**
181 * Register WooCommerce sync settings
182 */
183 public function register_settings() {
184 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
185 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
186 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
187 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
188 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
189 // Persisted sync scope: comma-separated product_cat term IDs. Empty means
190 // the whole catalogue is in scope. Drives both bulk sync and ongoing
191 // per-product auto-sync.
192 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
193 }
194
195 /**
196 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
197 */
198 public function on_product_update($product_id) {
199 // Check if sync is enabled
200 if (!get_option('onwebchat_wc_sync_enabled', false)) {
201 return;
202 }
203
204 // Get product object (at this point all meta data including SKU is already saved)
205 $product = wc_get_product($product_id);
206 if (!$product) {
207 return;
208 }
209
210 // Only sync published products
211 if ($product->get_status() !== 'publish') {
212 return;
213 }
214
215 // Check if product category is excluded
216 if ($this->is_product_excluded($product)) {
217 return;
218 }
219
220 // Respect the merchant's sync scope. When a category selection is active
221 // and this product belongs to none of the scoped categories, remove it
222 // (it may have been moved out of a scoped category after being synced)
223 // and stop. Deletes always remove, regardless of scope.
224 if (!$this->product_in_scope($product)) {
225 $this->send_product_delete($product_id);
226 return;
227 }
228
229 // Prepare and send product data
230 $product_data = $this->prepare_product_data($product);
231 $this->send_product_upsert($product_data, $product_id);
232 }
233
234 /**
235 * Hook: WooCommerce's scheduled-sales cron started or ended sales on these
236 * products. IDs can be simple products OR variations; variations are mapped to
237 * their parent (the entity synced to onWebChat) and each product is re-pushed
238 * once, so the AI immediately quotes the new (sale or regular) price.
239 *
240 * @param array $product_ids
241 */
242 public function on_scheduled_sales($product_ids) {
243 if (!get_option('onwebchat_wc_sync_enabled', false)) {
244 return;
245 }
246
247 $ids = array();
248 foreach ((array) $product_ids as $product_id) {
249 $product = wc_get_product($product_id);
250 if (!$product) {
251 continue;
252 }
253
254 $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
255 if ($id > 0) {
256 $ids[$id] = $id; // de-duplicate
257 }
258 }
259
260 foreach ($ids as $id) {
261 $this->on_product_update($id);
262 }
263 }
264
265 /**
266 * Parent product IDs whose variations changed in this request; flushed once on
267 * shutdown so a save touching 30 variations pushes the parent a single time.
268 */
269 private $pending_variation_parents = array();
270
271 /**
272 * Hook: a variation was created/updated (Variations tab saves happen over AJAX
273 * and don't always re-save the parent post, so woocommerce_update_product may
274 * never fire). Queue the parent for one sync at the end of the request.
275 *
276 * @param int $variation_id
277 */
278 public function on_variation_update($variation_id) {
279 if (!get_option('onwebchat_wc_sync_enabled', false)) {
280 return;
281 }
282
283 $variation = wc_get_product($variation_id);
284 if (!$variation || !$variation->is_type('variation')) {
285 return;
286 }
287
288 $parent_id = (int) $variation->get_parent_id();
289 if ($parent_id <= 0) {
290 return;
291 }
292
293 if (empty($this->pending_variation_parents)) {
294 add_action('shutdown', array($this, 'flush_variation_parent_syncs'));
295 }
296
297 $this->pending_variation_parents[$parent_id] = $parent_id;
298 }
299
300 /**
301 * Shutdown: sync every parent whose variations changed in this request.
302 */
303 public function flush_variation_parent_syncs() {
304 $parent_ids = $this->pending_variation_parents;
305 $this->pending_variation_parents = array();
306
307 foreach ($parent_ids as $parent_id) {
308 $this->on_product_update($parent_id);
309 }
310 }
311
312 /**
313 * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
314 * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
315 * is treated as available since the store still accepts orders.
316 *
317 * @param int $product_id
318 * @param string $status 'instock' | 'outofstock' | 'onbackorder'
319 * @param WC_Product|null $product
320 */
321 public function on_stock_status_change($product_id, $status, $product = null) {
322 if (!get_option('onwebchat_wc_sync_enabled', false)) {
323 return;
324 }
325
326 if (!$product || !is_object($product)) {
327 $product = wc_get_product($product_id);
328 }
329 if (!$product) {
330 return;
331 }
332
333 // Only products that would actually be synced: published, not excluded, in scope.
334 // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
335 if ($product->get_status() !== 'publish') {
336 return;
337 }
338 if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
339 return;
340 }
341
342 $in_stock = ($status !== 'outofstock');
343 $this->send_product_stock($product_id, $in_stock);
344 }
345
346 /**
347 * Hook: Product trash (when moved to trash)
348 */
349 public function on_product_trash($post_id) {
350 // Check if it's a product
351 if (get_post_type($post_id) !== 'product') {
352 return;
353 }
354
355 if (!get_option('onwebchat_wc_sync_enabled', false)) {
356 return;
357 }
358
359 // Send delete request when product is trashed
360 $this->send_product_delete($post_id);
361 }
362
363 /**
364 * Hook: Product permanent delete
365 */
366 public function on_product_delete($post_id, $post) {
367 if ($post->post_type !== 'product') {
368 return;
369 }
370
371 if (!get_option('onwebchat_wc_sync_enabled', false)) {
372 return;
373 }
374
375 // Send delete request when product is permanently deleted
376 $this->send_product_delete($post_id);
377 }
378
379 /**
380 * Check if product is in excluded categories
381 */
382 private function is_product_excluded($product) {
383 $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
384 if (empty($excluded_categories)) {
385 return false;
386 }
387
388 $product_categories = $product->get_category_ids();
389 foreach ($product_categories as $cat_id) {
390 if (in_array($cat_id, $excluded_categories)) {
391 return true;
392 }
393 }
394
395 return false;
396 }
397
398 /**
399 * Get the saved sync scope as an array of product_cat term IDs.
400 * An empty array on its own is ambiguous, so what it means is held
401 * separately, see is_scope_all(): with no categories the scope is either the
402 * whole catalogue or nothing at all.
403 */
404 private function get_sync_scope() {
405 return $this->parse_id_list(get_option('onwebchat_wc_sync_categories', ''));
406 }
407
408 /**
409 * Comma-separated ids (as stored in options and posted by the picker) to a
410 * de-duplicated array of positive ints.
411 */
412 private function parse_id_list($raw) {
413 $raw = (string) $raw;
414 if ($raw === '') {
415 return array();
416 }
417
418 $ids = array();
419 foreach (explode(',', $raw) as $id) {
420 $id = (int) trim($id);
421 if ($id > 0) {
422 $ids[$id] = $id; // de-duplicate
423 }
424 }
425
426 return array_values($ids);
427 }
428
429 /**
430 * Is the scope the whole catalogue? An empty category list means two
431 * opposite things, so the answer is stored explicitly:
432 * '1' whole catalogue, '0' exactly the saved categories (none = nothing).
433 * Sites upgraded from an older version have no flag yet, and there an empty
434 * list always meant "the whole catalogue", which is what they keep until
435 * their next sync or removal writes the flag.
436 */
437 private function is_scope_all() {
438 $raw = (string) get_option('onwebchat_wc_sync_scope_all', '');
439
440 if ($raw === '') {
441 return !$this->get_sync_scope();
442 }
443
444 return $raw === '1';
445 }
446
447 /**
448 * Has the site ever recorded what its empty scope means? False only on a
449 * site upgraded from an older version that never picked categories, where
450 * a synced catalogue and an empty one look exactly the same.
451 */
452 private function is_scope_known() {
453 return (string) get_option('onwebchat_wc_sync_scope_all', '') !== '' || (bool) $this->get_sync_scope();
454 }
455
456 /**
457 * Running a product sync is the merchant asking for their products in the
458 * chatbot, so it also switches automatic sync on: later edits, stock changes
459 * and images then reach the bot on their own. Before 3.10.0 the Sync button
460 * left the switch alone, so a store that never ticked it synced once and
461 * went stale without anyone noticing. The "off by removal" note (set when
462 * removing every category turned the switch off) has served its purpose.
463 */
464 private function enable_auto_sync_for_run() {
465 delete_option('onwebchat_wc_sync_off_by_removal');
466
467 if (!get_option('onwebchat_wc_sync_enabled', false)) {
468 update_option('onwebchat_wc_sync_enabled', true);
469 }
470 }
471
472 /**
473 * Persist the sync scope: the categories auto-sync covers, plus whether the
474 * scope is the whole catalogue. An empty array with $all false means the AI
475 * training data holds nothing (a fresh site, or one whose products were
476 * removed), so auto-sync has nothing to cover either.
477 */
478 private function save_sync_scope($category_ids, $all) {
479 $clean = array();
480 foreach ((array) $category_ids as $id) {
481 $id = (int) $id;
482 if ($id > 0) {
483 $clean[$id] = $id;
484 }
485 }
486
487 update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
488 update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0');
489 }
490
491 /**
492 * Is the product within the current sync scope?
493 * With no categories saved it comes down to what the empty list means: the
494 * whole catalogue (any product qualifies) or nothing at all. The picker offers the whole
495 * category tree and selecting a category covers its whole subtree, so a
496 * product is in scope when any of its categories is a scoped category OR a
497 * descendant of one. This mirrors the bulk sync tax query
498 * (include_children = true).
499 */
500 private function product_in_scope($product) {
501 $scope = $this->get_sync_scope();
502 if (empty($scope)) {
503 return $this->is_scope_all();
504 }
505
506 foreach ($product->get_category_ids() as $cat_id) {
507 $cat_id = (int) $cat_id;
508 if (in_array($cat_id, $scope, true)) {
509 return true;
510 }
511 // Walk up to the root: a scoped ancestor puts the product in scope.
512 foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
513 if (in_array((int) $ancestor_id, $scope, true)) {
514 return true;
515 }
516 }
517 }
518
519 return false;
520 }
521
522 /**
523 * Is this category already covered by the given scope? A scope covers a
524 * category when it holds the category itself or any of its ancestors,
525 * because selecting a category always includes its whole subtree.
526 */
527 private function scope_covers($scope, $category_id) {
528 $category_id = (int) $category_id;
529 if (in_array($category_id, $scope, true)) {
530 return true;
531 }
532
533 foreach (get_ancestors($category_id, 'product_cat', 'taxonomy') as $ancestor_id) {
534 if (in_array((int) $ancestor_id, $scope, true)) {
535 return true;
536 }
537 }
538
539 return false;
540 }
541
542 /**
543 * Which of the submitted categories are NOT yet covered by the saved scope.
544 * These are the only ones a sync has to push: everything already in scope is
545 * in the training data already.
546 */
547 private function categories_added($submitted, $saved_scope) {
548 if (empty($saved_scope)) {
549 return array(); // whole catalogue already in scope, nothing is new
550 }
551
552 $added = array();
553 foreach ($submitted as $category_id) {
554 $category_id = (int) $category_id;
555 if ($category_id > 0 && !$this->scope_covers($saved_scope, $category_id)) {
556 $added[$category_id] = $category_id;
557 }
558 }
559
560 return array_values($added);
561 }
562
563 /**
564 * Which of the saved categories the merchant just unticked. Used to offer an
565 * explicit removal: unticking alone never drops anything (see
566 * ajax_scope_remove_start), because the saved scope only grows on sync.
567 */
568 private function categories_removed($submitted, $saved_scope) {
569 if (empty($saved_scope)) {
570 return array();
571 }
572
573 $removed = array();
574 foreach ($saved_scope as $category_id) {
575 $category_id = (int) $category_id;
576 if ($category_id > 0 && !$this->scope_covers($submitted, $category_id)) {
577 $removed[$category_id] = $category_id;
578 }
579 }
580
581 return array_values($removed);
582 }
583
584 /**
585 * tax_query for "products inside $terms but not inside $exclude", both
586 * including their subtrees. Empty $terms means the whole catalogue.
587 * Returns null when no restriction applies at all.
588 */
589 private function build_scope_tax_query($terms, $exclude = array()) {
590 $clauses = array();
591
592 if (!empty($terms)) {
593 $clauses[] = array(
594 'taxonomy' => 'product_cat',
595 'field' => 'term_id',
596 'terms' => array_map('intval', $terms),
597 'include_children' => true,
598 );
599 }
600
601 if (!empty($exclude)) {
602 $clauses[] = array(
603 'taxonomy' => 'product_cat',
604 'field' => 'term_id',
605 'terms' => array_map('intval', $exclude),
606 'include_children' => true,
607 'operator' => 'NOT IN',
608 );
609 }
610
611 if (empty($clauses)) {
612 return null;
613 }
614
615 if (count($clauses) > 1) {
616 $clauses['relation'] = 'AND';
617 }
618
619 return $clauses;
620 }
621
622 /**
623 * Count published products within the given scope (empty = whole catalogue),
624 * optionally excluding everything inside $exclude and its subtrees.
625 * Uses found_posts so we do not load every ID into memory.
626 */
627 private function count_products_in_scope($category_ids, $exclude = array()) {
628 $args = array(
629 'post_type' => 'product',
630 'post_status' => 'publish',
631 'posts_per_page' => 1,
632 'fields' => 'ids',
633 'no_found_rows' => false,
634 );
635
636 $tax_query = $this->build_scope_tax_query($category_ids, $exclude);
637 if ($tax_query !== null) {
638 $args['tax_query'] = $tax_query;
639 }
640
641 $query = new WP_Query($args);
642 return (int) $query->found_posts;
643 }
644
645 /**
646 * What the AI training data currently covers, for the settings screen:
647 * array(categories, products, whole_catalogue, nothing, known). Three
648 * states: the whole catalogue, the saved categories, or nothing synced yet.
649 * 'known' is false only on a site upgraded from an older version whose
650 * empty scope could mean either, and there the screen says nothing at all
651 * rather than something wrong.
652 */
653 public function get_scope_summary() {
654 $scope = $this->get_sync_scope();
655 $all = $this->is_scope_all();
656
657 return array(
658 'categories' => count($scope),
659 'products' => $all ? $this->count_products_in_scope(array()) : ($scope ? $this->count_products_in_scope($scope) : 0),
660 'whole_catalogue' => $all,
661 'nothing' => !$all && !$scope,
662 'known' => $this->is_scope_known(),
663 );
664 }
665
666 /**
667 * Turn HTML entities into real characters. Product text is often stored
668 * double-encoded ("&amp;quot;" for a quote), where a single pass still
669 * leaves "&quot;" in the text the bot is trained on, so decode until the
670 * string stops changing (3 passes is far more than any real content needs).
671 * Always call this AFTER strip_tags: decoding first could turn text like
672 * "price &lt; 100 and &gt; 50" into something strip_tags eats as a tag.
673 */
674 private function decode_entities($value) {
675 $value = (string) $value;
676
677 for ($i = 0; $i < 3; $i++) {
678 $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
679 if ($decoded === $value) {
680 break;
681 }
682 $value = $decoded;
683 }
684
685 // html_entity_decode turns &nbsp; into a non-breaking space; make it a
686 // plain space so the text does not carry invisible oddities.
687 return str_replace("\xC2\xA0", ' ', $value);
688 }
689
690 /**
691 * Prepare product data for sync
692 */
693 private function prepare_product_data($product) {
694 $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full');
695
696 // Get description based on sync mode
697 $description = '';
698 $short_description = $this->decode_entities(strip_tags($product->get_short_description()));
699
700 if ($sync_mode === 'short_only') {
701 $description = $short_description;
702 } else if ($sync_mode === 'short_plus_full') {
703 // Send both texts: the short description first, then the full one.
704 $full_description = $this->decode_entities(strip_tags($product->get_description()));
705 $parts = array_filter(array(trim($short_description), trim($full_description)));
706 $description = implode("\n\n", $parts);
707 } else if ($sync_mode === 'short_fallback_full') {
708 if (!empty($short_description)) {
709 $description = $short_description;
710 } else {
711 // Fallback to the first 200 words of the full description.
712 // Split with a Unicode-aware regex: str_word_count() does not
713 // recognize non-latin (e.g. Greek) words, so the old word cut
714 // was unreliable on multibyte text.
715 $full_description = $this->decode_entities(strip_tags($product->get_description()));
716 $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);
717
718 if (is_array($words) && count($words) > 200) {
719 $description = implode(' ', array_slice($words, 0, 200)) . '...';
720 } else {
721 $description = $full_description;
722 }
723 }
724 }
725
726 // Enforce max length by characters, not bytes: a byte-based substr()
727 // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
728 $max_length = ($sync_mode === 'short_plus_full')
729 ? $this->max_description_length_combined
730 : $this->max_description_length;
731 if (mb_strlen($description, 'UTF-8') > $max_length) {
732 $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...';
733 }
734
735 $sku = $product->get_sku();
736 $categories = $this->get_product_category_names($product);
737 $url = get_permalink($product->get_id());
738
739 // Structured fields. The server rebuilds the embedding text from these,
740 // so there is no need to send a pre-formatted "text" blob.
741 $data = array(
742 'product_id' => $product->get_id(),
743 // Names are stored HTML-escaped ("Bags &amp; Belts"), so decode them: the name is
744 // also the title of the product card the widget shows (3.10.0+).
745 'name' => $this->decode_entities($product->get_name()),
746 'short_description' => trim($description),
747 'url' => $url,
748 'sku' => $sku,
749 'categories' => $categories,
750 'currency' => get_woocommerce_currency(),
751 );
752
753 // Price, as the customer sees it in the shop. get_price() returns the value
754 // as entered in admin, which excludes tax on shops that enter net prices but
755 // display gross ones, so the AI would quote a price the visitor never sees.
756 // wc_get_price_to_display() applies the shop's tax display settings.
757 $raw_price = $product->get_price();
758 if ($raw_price !== '') {
759 $data['price'] = wc_get_price_to_display($product);
760
761 // When the shop displays taxed prices, also send the untaxed price so the
762 // AI can quote both.
763 if (wc_tax_enabled()) {
764 $price_excl_tax = wc_get_price_excluding_tax($product);
765 if ((float) $price_excl_tax !== (float) $data['price']) {
766 $data['price_excl_tax'] = $price_excl_tax;
767 }
768 }
769 }
770
771 if ($product->is_type('variable')) {
772 // Display min/max prices, consistent with the display price used for
773 // simple products.
774 $data['price_min'] = $product->get_variation_price('min', true);
775 $data['price_max'] = $product->get_variation_price('max', true);
776 } else {
777 $regular_price = $product->get_regular_price();
778 if ($regular_price !== '') {
779 $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price));
780 }
781 // Only advertise a sale price while the sale is actually active.
782 if ($product->is_on_sale() && $product->get_sale_price() !== '') {
783 $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price()));
784 }
785 }
786
787 // Stock availability
788 $data['in_stock'] = $product->is_in_stock();
789 if ($product->managing_stock()) {
790 $stock_qty = $product->get_stock_quantity();
791 if ($stock_qty !== null) {
792 $data['quantity'] = (int) $stock_qty;
793 }
794 }
795
796 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
797 $brand = $this->get_product_brand($product);
798 if (!empty($brand)) {
799 $data['manufacturer'] = $brand;
800 }
801
802 // Variation attributes / options (Color, Size, ...)
803 $attributes = $this->get_product_attributes($product);
804 if (!empty($attributes)) {
805 $data['attributes'] = $attributes;
806 }
807
808 // Tags
809 $tags = $this->get_product_tags($product);
810 if (!empty($tags)) {
811 $data['tags'] = $tags;
812 }
813
814 // Average rating and review count
815 $rating = (float) $product->get_average_rating();
816 if ($rating > 0) {
817 $data['rating'] = $rating;
818 $data['review_count'] = (int) $product->get_review_count();
819 }
820
821 // Product thumbnail (3.10.0+): shown as a small product card under the chatbot's reply
822 // when it recommends this product. Always sent, '' when the product has no usable
823 // image: the server then clears the thumbnail it stored for an earlier sync.
824 $data['image'] = $this->get_product_image_url($product);
825
826 return $data;
827 }
828
829 /**
830 * Thumbnail URL for the product card the chat widget shows under a chatbot reply, or ''
831 * when the product has no usable image.
832 *
833 * - The WooCommerce catalogue thumbnail size (300px by default), so the widget never
834 * loads the full-size photo. WordPress falls back to the original file when that
835 * size was never generated.
836 * - Uploaded file names keep non-Latin letters (a Greek "κούπα.jpg" stays Greek in the
837 * URL) and WordPress returns them unencoded, so every byte outside printable ASCII
838 * is percent-encoded here: the widget, the dashboard and the server then all handle
839 * one plain ASCII URL. Already encoded parts (%CE%BA...) are left as they are.
840 * - Shops served over HTTPS get an HTTPS image link, otherwise the browser would block
841 * the picture on the shop page as mixed content.
842 * - Anything that is not an absolute http(s) URL, or is longer than the 1000 characters
843 * the server stores, is dropped (the product then syncs without a picture).
844 */
845 private function get_product_image_url($product) {
846 $image_id = (int) $product->get_image_id();
847 if ($image_id <= 0) {
848 return '';
849 }
850
851 $image_url = wp_get_attachment_image_url($image_id, 'woocommerce_thumbnail');
852 if (!$image_url) {
853 $image_url = wp_get_attachment_image_url($image_id, 'thumbnail');
854 }
855 if (!is_string($image_url)) {
856 return '';
857 }
858
859 $image_url = trim($image_url);
860 if ($image_url === '') {
861 return '';
862 }
863
864 $site_is_https = is_ssl() || (stripos(home_url('/'), 'https://') === 0);
865
866 // Protocol-relative URL (some CDN plugins return "//cdn.example.com/...").
867 if (substr($image_url, 0, 2) === '//') {
868 $image_url = ($site_is_https ? 'https:' : 'http:') . $image_url;
869 }
870
871 if ($site_is_https && stripos($image_url, 'http://') === 0) {
872 $image_url = set_url_scheme($image_url, 'https');
873 }
874
875 // Percent-encode every byte outside printable ASCII (multibyte letters, spaces,
876 // control characters). No /u flag on purpose: each byte of a UTF-8 sequence is
877 // encoded separately, which is exactly the encoding a browser would apply.
878 $image_url = preg_replace_callback('/[^\x21-\x7E]/', function ($m) {
879 return rawurlencode($m[0]);
880 }, $image_url);
881
882 if (!is_string($image_url) || !preg_match('#^https?://[^\s<>"\'\\\\]+$#i', $image_url)) {
883 return '';
884 }
885
886 if (strlen($image_url) > 1000) {
887 return '';
888 }
889
890 return $image_url;
891 }
892
893 /**
894 * Get product category names
895 */
896 private function get_product_category_names($product) {
897 $categories = array();
898 $category_ids = $product->get_category_ids();
899
900 foreach ($category_ids as $cat_id) {
901 $term = get_term($cat_id, 'product_cat');
902 if ($term && !is_wp_error($term)) {
903 $categories[] = $this->decode_entities($term->name);
904 }
905 }
906
907 return $categories;
908 }
909
910 /**
911 * Get the product's brand name from whichever brand taxonomy is available.
912 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
913 */
914 private function get_product_brand($product) {
915 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
916
917 foreach ($taxonomies as $taxonomy) {
918 if (!taxonomy_exists($taxonomy)) {
919 continue;
920 }
921
922 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
923 if (!is_wp_error($terms) && !empty($terms)) {
924 return $this->decode_entities($terms[0]);
925 }
926 }
927
928 return '';
929 }
930
931 /**
932 * Get visible product attributes as an array of { name, options }.
933 * Works for both custom and taxonomy-based (global) attributes.
934 */
935 private function get_product_attributes($product) {
936 $result = array();
937
938 foreach ($product->get_attributes() as $attribute) {
939 if (!is_object($attribute) || !$attribute->get_visible()) {
940 continue;
941 }
942
943 $name = wc_attribute_label($attribute->get_name());
944
945 if ($attribute->is_taxonomy()) {
946 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
947 } else {
948 $options = $attribute->get_options();
949 }
950
951 $options = array_values(array_filter(array_map('trim', (array) $options)));
952
953 if (!empty($name) && !empty($options)) {
954 $result[] = array(
955 'name' => $name,
956 'options' => $options,
957 );
958 }
959 }
960
961 return $result;
962 }
963
964 /**
965 * Get product tag names.
966 */
967 private function get_product_tags($product) {
968 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
969
970 if (is_wp_error($tags) || empty($tags)) {
971 return array();
972 }
973
974 return array_map(array($this, 'decode_entities'), $tags);
975 }
976
977 /**
978 * Send batch of products to API (optimized)
979 * @param array $products - Array of product data
980 * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run)
981 * @param int $sync_done - Products pushed so far in the run, including this batch
982 *
983 * When $sync_total is > 0 the batch is tagged with the run total/progress so
984 * the server can relay a live progress bar to open dashboards.
985 */
986 private function send_product_batch($products, $sync_total = 0, $sync_done = 0) {
987 $chatId = get_option('onwebchat_plugin_option');
988 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
989
990 if (empty($chatId)) {
991 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
992 return false;
993 }
994
995 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
996 $secret = $this->get_secret(false);
997 if (empty($secret)) {
998 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
999 return array(
1000 'success' => false,
1001 'error' => 'No secret configured. Please connect WooCommerce integration.',
1002 'needs_reconnect' => true
1003 );
1004 }
1005
1006 // Extract key part (before first slash if present)
1007 $chatIdKey = explode('/', $chatId)[0];
1008
1009 $endpoint = $this->get_api_endpoint() . '/product/batch';
1010 $payload = array(
1011 'site_id' => $chatIdKey,
1012 'site_url' => get_site_url(),
1013 'products' => $products
1014 );
1015
1016 // Tag bulk-run batches with the run total + progress so the server can
1017 // relay a live progress bar to open dashboards. Omitted for incremental
1018 // single-product syncs (which pass no total).
1019 if ((int) $sync_total > 0) {
1020 $payload['sync_total'] = (int) $sync_total;
1021 $payload['sync_done'] = min((int) $sync_done, (int) $sync_total);
1022 }
1023
1024 // Generate authentication headers (same as send_authenticated_request)
1025 $timestamp = time();
1026 $nonce = base64_encode(random_bytes(16));
1027 $body_json = wp_json_encode($payload);
1028
1029 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
1030 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
1031 $signature = hash_hmac('sha256', $message, $secret);
1032
1033 // Send request
1034 $request_args = array(
1035 'method' => 'POST',
1036 // A batch whose descriptions are summarized for the first time costs
1037 // the server one model call per oversized product, so it needs far
1038 // more than the 30s that is plenty for every other endpoint.
1039 'timeout' => 180,
1040 'headers' => array(
1041 'Content-Type' => 'application/json',
1042 'X-OWC-SiteId' => $chatIdKey,
1043 'X-OWC-Timestamp' => $timestamp,
1044 'X-OWC-Nonce' => $nonce,
1045 'X-OWC-Signature' => $signature,
1046 ),
1047 'body' => $body_json,
1048 );
1049
1050 // Disable SSL verification for local dev server
1051 if ($this->use_testing_mode) {
1052 $request_args['sslverify'] = false;
1053 }
1054
1055 $response = wp_remote_post($endpoint, $request_args);
1056
1057 if (is_wp_error($response)) {
1058 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
1059 return array(
1060 'success' => false,
1061 'error' => 'Network error: ' . $response->get_error_message()
1062 );
1063 }
1064
1065 $response_code = wp_remote_retrieve_response_code($response);
1066 $body = json_decode(wp_remote_retrieve_body($response), true);
1067
1068 // If authentication failed (401), the secret is invalid or out of sync
1069 if ($response_code === 401) {
1070 // Clear the invalid secret
1071 delete_option('onwebchat_wc_sync_secret');
1072
1073 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
1074
1075 // Store admin notice about authentication failure
1076 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);
1077
1078 return array(
1079 'success' => false,
1080 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
1081 'needs_reconnect' => true
1082 );
1083 }
1084
1085 if ($response_code === 200 && isset($body['success']) && $body['success']) {
1086 // Clear any previous auth errors on success
1087 delete_transient('onwebchat_wc_auth_error');
1088 return $body; // Return full response with stats
1089 }
1090
1091 $error_msg = 'Batch sync failed';
1092 if (isset($body['error'])) {
1093 $error_msg .= ': ' . $body['error'];
1094 }
1095 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
1096
1097 return array(
1098 'success' => false,
1099 'error' => $error_msg
1100 );
1101 }
1102
1103 /**
1104 * Send sync completion notification to server (triggers Angular modal)
1105 */
1106 private function send_sync_completion_notification($total_stats) {
1107 $chatId = get_option('onwebchat_plugin_option');
1108 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1109
1110 if (empty($chatId)) {
1111 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
1112 return false;
1113 }
1114
1115 $secret = $this->get_secret(false);
1116 if (empty($secret)) {
1117 error_log('onWebChat WooCommerce Sync - No secret configured');
1118 return false;
1119 }
1120
1121 $chatIdKey = explode('/', $chatId)[0];
1122
1123 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
1124 $payload = array(
1125 'site_id' => $chatIdKey,
1126 'stats' => $total_stats
1127 );
1128
1129 // Generate authentication headers
1130 $timestamp = time();
1131 $nonce = base64_encode(random_bytes(16));
1132 $body_json = wp_json_encode($payload);
1133 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
1134 $signature = hash_hmac('sha256', $message, $secret);
1135
1136 $request_args = array(
1137 'method' => 'POST',
1138 'timeout' => 10,
1139 'headers' => array(
1140 'Content-Type' => 'application/json',
1141 'X-OWC-SiteId' => $chatIdKey,
1142 'X-OWC-Timestamp' => $timestamp,
1143 'X-OWC-Nonce' => $nonce,
1144 'X-OWC-Signature' => $signature,
1145 ),
1146 'body' => $body_json,
1147 );
1148
1149 if ($this->use_testing_mode) {
1150 $request_args['sslverify'] = false;
1151 }
1152
1153 $response = wp_remote_post($endpoint, $request_args);
1154
1155 if (is_wp_error($response)) {
1156 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
1157 return false;
1158 }
1159
1160 $response_code = wp_remote_retrieve_response_code($response);
1161
1162 // If authentication failed (401), the secret is invalid or out of sync
1163 if ($response_code === 401) {
1164 delete_option('onwebchat_wc_sync_secret');
1165 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
1166 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);
1167 return false;
1168 }
1169
1170 if ($response_code >= 200 && $response_code < 300) {
1171 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
1172 return true;
1173 }
1174
1175 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
1176 return false;
1177 }
1178
1179 /**
1180 * Send product upsert to server (uses batch endpoint with single product)
1181 */
1182 private function send_product_upsert($product_data, $product_id) {
1183 // Use batch endpoint with single product
1184 $result = $this->send_product_batch(array($product_data));
1185
1186 if ($result && isset($result['success']) && $result['success']) {
1187 // Clear any previous errors
1188 delete_post_meta($product_id, '_onwebchat_sync_error');
1189 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
1190 return true;
1191 } else {
1192 // Log error if batch failed
1193 $error_message = 'Failed to sync product';
1194 if ($result && isset($result['error'])) {
1195 $error_message = $result['error'];
1196 } elseif (!$result) {
1197 $error_message = 'Batch sync request failed';
1198 }
1199 $this->log_error($product_id, $error_message);
1200 return false;
1201 }
1202 }
1203
1204 /**
1205 * Send product delete to server
1206 */
1207 private function send_product_delete($product_id) {
1208 $chatId = get_option('onwebchat_plugin_option');
1209 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1210
1211 if (empty($chatId)) {
1212 return false;
1213 }
1214
1215 // Extract key part (before first slash if present)
1216 $chatIdKey = explode('/', $chatId)[0];
1217
1218 $endpoint = $this->get_api_endpoint() . '/product/delete';
1219 $payload = array(
1220 'site_id' => $chatIdKey, // Use key part only
1221 'site_url' => get_site_url(),
1222 'product_id' => $product_id
1223 );
1224
1225 $this->send_authenticated_request($endpoint, $payload, $product_id);
1226 }
1227
1228 /**
1229 * Remove many products from the AI training data in one call. Used by the
1230 * scope-removal flow: one request per product would hit onWebChat's
1231 * product-sync rate limit on any real catalogue.
1232 *
1233 * @param array $product_ids
1234 * @return array {success, deleted, errors}
1235 */
1236 private function send_products_delete_batch($product_ids) {
1237 $product_ids = array_values(array_unique(array_map('intval', (array) $product_ids)));
1238 if (empty($product_ids)) {
1239 return array('success' => true, 'deleted' => 0, 'errors' => 0);
1240 }
1241
1242 $chatId = get_option('onwebchat_plugin_option');
1243 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1244
1245 if (empty($chatId)) {
1246 return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids));
1247 }
1248
1249 $chatIdKey = explode('/', $chatId)[0];
1250
1251 $result = $this->send_authenticated_request($this->get_api_endpoint() . '/product/delete', array(
1252 'site_id' => $chatIdKey,
1253 'site_url' => get_site_url(),
1254 'product_ids' => $product_ids,
1255 ));
1256
1257 if (empty($result['success'])) {
1258 return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids));
1259 }
1260
1261 return array('success' => true, 'deleted' => count($product_ids), 'errors' => 0);
1262 }
1263
1264 /**
1265 * Send a lightweight availability update to onWebChat (no re-embed on the server).
1266 */
1267 private function send_product_stock($product_id, $in_stock) {
1268 $chatId = get_option('onwebchat_plugin_option');
1269 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1270
1271 if (empty($chatId)) {
1272 return false;
1273 }
1274
1275 // Extract key part (before first slash if present)
1276 $chatIdKey = explode('/', $chatId)[0];
1277
1278 $endpoint = $this->get_api_endpoint() . '/product/stock';
1279 $payload = array(
1280 'site_id' => $chatIdKey, // Use key part only
1281 'site_url' => get_site_url(),
1282 'product_id' => $product_id,
1283 'in_stock' => (bool) $in_stock,
1284 );
1285
1286 $this->send_authenticated_request($endpoint, $payload, $product_id);
1287 }
1288
1289 /**
1290 * Get cached secret from local options
1291 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
1292 */
1293 private function get_secret($force_refresh = false) {
1294 // Always return cached secret - never fetch automatically
1295 // Secret must be obtained via authenticated request in WooCommerce settings
1296 $secret = get_option('onwebchat_wc_sync_secret');
1297
1298 if (!empty($secret)) {
1299 return $secret;
1300 }
1301
1302 // No secret available - user must authenticate in WooCommerce settings
1303 return null;
1304 }
1305
1306 /**
1307 * Request secret from server with authentication
1308 * This is called when user clicks "Connect WooCommerce" with their password
1309 *
1310 * @param {string} email - User's onWebChat email
1311 * @param {string} password - User's onWebChat password
1312 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
1313 */
1314 public function request_secret_with_auth($email, $password) {
1315 $chatId = get_option('onwebchat_plugin_option');
1316 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1317
1318 if (empty($chatId)) {
1319 return array('success' => false, 'error' => 'No Chat ID configured');
1320 }
1321
1322 // Extract key part (before first slash if present)
1323 $key = explode('/', $chatId)[0];
1324
1325 // Request secret from server with authentication
1326 $secret_endpoint = $this->get_api_endpoint() . '/secret';
1327
1328 $response = wp_remote_post($secret_endpoint, array(
1329 'timeout' => 15,
1330 'sslverify' => !$this->use_testing_mode,
1331 'headers' => array(
1332 'Content-Type' => 'application/json',
1333 ),
1334 'body' => wp_json_encode(array(
1335 'email' => $email,
1336 'password' => $password,
1337 'site_key' => $key,
1338 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
1339 )),
1340 ));
1341
1342 if (is_wp_error($response)) {
1343 $error_message = $response->get_error_message();
1344 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
1345 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
1346 }
1347
1348 $status_code = wp_remote_retrieve_response_code($response);
1349 $response_body_raw = wp_remote_retrieve_body($response);
1350 $body = json_decode($response_body_raw, true);
1351
1352 // Log response for debugging. Redact the secret so it never lands in server/debug logs
1353 // (a successful response body contains the HMAC secret).
1354 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
1355 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
1356
1357 // Handle specific HTTP status codes
1358 if ($status_code === 401) {
1359 return array('success' => false, 'error' => 'Invalid email or password');
1360 }
1361
1362 if ($status_code === 403) {
1363 return array('success' => false, 'error' => 'You do not have access to this site');
1364 }
1365
1366 // Success case
1367 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
1368 $secret = isset($body['secret']) ? $body['secret'] : null;
1369 if (empty($secret)) {
1370 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
1371 return array('success' => false, 'error' => 'Server response missing secret');
1372 }
1373 update_option('onwebchat_wc_sync_secret', $secret);
1374
1375 // Enable AI order-status lookup by default on connect and register our
1376 // callback URL with onWebChat (best effort; the merchant can toggle it off).
1377 update_option('onwebchat_wc_order_lookup_enabled', true);
1378 global $onwebchat_wc_orders;
1379 if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
1380 $onwebchat_wc_orders->push_order_lookup_config(true);
1381 }
1382
1383 return array('success' => true, 'secret' => $secret);
1384 }
1385
1386 // Extract error message from various possible response formats
1387 $error_message = 'Unknown error';
1388
1389 if (is_array($body)) {
1390 // Try different possible error fields
1391 if (isset($body['error'])) {
1392 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
1393 } elseif (isset($body['message'])) {
1394 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
1395 } elseif (isset($body['errors']) && is_array($body['errors'])) {
1396 $error_message = implode(', ', $body['errors']);
1397 }
1398 } elseif (!empty($response_body_raw)) {
1399 // If body is not JSON or empty, use raw response (truncated)
1400 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
1401 }
1402
1403 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
1404 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
1405 $error_message = sanitize_text_field($error_message);
1406
1407 // Include status code in error message if not already included
1408 if ($status_code && strpos($error_message, 'HTTP') === false) {
1409 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
1410 }
1411
1412 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
1413 return array('success' => false, 'error' => $error_message);
1414 }
1415
1416 /**
1417 * Send authenticated request with HMAC signature
1418 */
1419 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
1420 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
1421 $secret = $this->get_secret(false);
1422
1423 if (empty($secret)) {
1424 if ($product_id) {
1425 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
1426 }
1427 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
1428 }
1429
1430 $chatId = get_option('onwebchat_plugin_option');
1431 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1432
1433 // Extract key part (before first slash if present) for consistency with server
1434 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
1435 $chatIdKey = explode('/', $chatId)[0];
1436
1437 // Generate authentication headers
1438 $timestamp = time();
1439 $nonce = base64_encode(random_bytes(16));
1440 $body_json = wp_json_encode($payload);
1441
1442 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
1443 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
1444 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
1445 $signature = hash_hmac('sha256', $message, $secret);
1446
1447 // Send request
1448 $request_args = array(
1449 'method' => 'POST',
1450 'timeout' => 10,
1451 'headers' => array(
1452 'Content-Type' => 'application/json',
1453 'X-OWC-SiteId' => $chatIdKey, // Use key part only
1454 'X-OWC-Timestamp' => $timestamp,
1455 'X-OWC-Nonce' => $nonce,
1456 'X-OWC-Signature' => $signature,
1457 ),
1458 'body' => $body_json,
1459 );
1460
1461 // Disable SSL verification for local dev server
1462 if ($this->use_testing_mode) {
1463 $request_args['sslverify'] = false;
1464 }
1465
1466 $response = wp_remote_post($endpoint, $request_args);
1467
1468 // Handle response
1469 if (is_wp_error($response)) {
1470 $error_message = $response->get_error_message();
1471 if ($product_id) {
1472 $this->log_error($product_id, $error_message);
1473 }
1474 return array('success' => false, 'error' => $error_message);
1475 }
1476
1477 $status_code = wp_remote_retrieve_response_code($response);
1478
1479 // Success
1480 if ($status_code >= 200 && $status_code < 300) {
1481 return array('success' => true);
1482 }
1483
1484 // If authentication failed (401), the secret may be invalid
1485 if ($status_code === 401) {
1486 // Clear the invalid secret
1487 delete_option('onwebchat_wc_sync_secret');
1488
1489 if ($product_id) {
1490 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
1491 }
1492 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
1493 }
1494
1495 // Error
1496 $error_body = wp_remote_retrieve_body($response);
1497 if ($product_id) {
1498 $this->log_error($product_id, "HTTP $status_code: $error_body");
1499 }
1500
1501 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
1502 }
1503
1504 /**
1505 * Log sync error to product meta
1506 */
1507 private function log_error($product_id, $error_message) {
1508 update_post_meta($product_id, '_onwebchat_sync_error', array(
1509 'message' => $error_message,
1510 'timestamp' => current_time('timestamp')
1511 ));
1512 }
1513
1514 /**
1515 * AJAX: Start bulk sync
1516 */
1517 public function ajax_sync_existing_products() {
1518 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1519
1520 // This path can walk the whole catalogue in one request, and each batch
1521 // waits for the server (which may summarize descriptions with a model).
1522 @set_time_limit(0);
1523
1524 if (!current_user_can('manage_options')) {
1525 wp_send_json_error('Insufficient permissions');
1526 }
1527
1528 // Check if sync is already in progress
1529 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1530 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1531 }
1532
1533 // Rate limiting: prevent syncing more than once every 5 minutes
1534 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
1535 $cooldown_period = 30; // seconds; keep in step with admin/tabs/woocommerce.php
1536 $time_since_last_sync = time() - $last_sync_time;
1537
1538 if ($time_since_last_sync < $cooldown_period) {
1539 $wait_time = max(1, $cooldown_period - $time_since_last_sync);
1540 wp_send_json_error('Please wait ' . $wait_time . ' second(s) before syncing again.');
1541 }
1542
1543 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1544 $category_ids = array();
1545 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1546 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1547 $id = (int) trim($id);
1548 if ($id > 0) {
1549 $category_ids[] = $id;
1550 }
1551 }
1552 }
1553
1554 // Above the hard cap a category selection is required: refuse an
1555 // unrestricted "sync all" when the catalogue is larger than the cap.
1556 $published_total = $this->count_products_in_scope(array());
1557 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1558 wp_send_json_error(sprintf(
1559 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1560 number_format_i18n($published_total),
1561 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1562 ));
1563 }
1564
1565 // The saved scope only ever GROWS on a sync. Ticking more categories adds
1566 // them to what the bot knows; unticking never silently drops products,
1567 // removal is its own explicit, confirmed action (ajax_scope_remove_*).
1568 // An empty selection means the whole catalogue, which covers everything,
1569 // so it clears the scope.
1570 //
1571 // $run_terms / $run_exclude are what THIS run pushes, which is not the
1572 // same as the scope: when categories are added to an existing scope only
1573 // the added ones are pushed, so adding one subcategory to a 10,000
1574 // product scope no longer re-sends all 10,000.
1575 $saved_scope = $this->get_sync_scope();
1576
1577 if (empty($category_ids)) {
1578 // Nothing ticked: the whole catalogue is the scope.
1579 $new_scope = array();
1580 $new_all = true;
1581 $run_terms = array(); // push everything
1582 $run_exclude = array();
1583 } elseif (empty($saved_scope)) {
1584 // Nothing picked before (a fresh site, or one whose scope was
1585 // removed, or one that used to sync everything): the ticks become
1586 // the scope, so they are still ticked after a refresh and the
1587 // summary can name them.
1588 $new_scope = $category_ids;
1589 $new_all = false;
1590 $run_terms = $category_ids;
1591 $run_exclude = array();
1592 } else {
1593 $added = $this->categories_added($category_ids, $saved_scope);
1594 $new_scope = array_values(array_unique(array_merge($saved_scope, $category_ids)));
1595 $new_all = false;
1596
1597 if (!empty($added)) {
1598 $run_terms = $added;
1599 $run_exclude = $saved_scope; // already synced, skip it
1600 } else {
1601 // Nothing new was ticked, so the click means "refresh what I have".
1602 $run_terms = $new_scope;
1603 $run_exclude = array();
1604 }
1605 }
1606
1607 $this->save_sync_scope($new_scope, $new_all);
1608 $this->enable_auto_sync_for_run();
1609 update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms)));
1610 update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude)));
1611
1612 // Store the current sync start time
1613 update_option('onwebchat_wc_last_sync_start', time());
1614
1615 // Reset bulk sync progress
1616 update_option('onwebchat_wc_bulk_page', 0);
1617 update_option('onwebchat_wc_bulk_done', 0);
1618
1619 // Count the products THIS run will push, capped at the hard limit.
1620 $total = $this->count_products_in_scope($run_terms, $run_exclude);
1621 if ($total > self::MAX_SYNC_PRODUCTS) {
1622 $total = self::MAX_SYNC_PRODUCTS;
1623 }
1624
1625 update_option('onwebchat_wc_bulk_total', $total);
1626 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
1627 update_option('onwebchat_wc_bulk_in_progress', true);
1628
1629 // Process sync directly instead of using unreliable WP Cron
1630 $sync_result = $this->do_bulk_sync_all($category_ids);
1631
1632 wp_send_json_success(array(
1633 'message' => 'Bulk sync completed',
1634 'total' => $total,
1635 'result' => $sync_result
1636 ));
1637 }
1638
1639 /**
1640 * AJAX: begin a client-driven bulk sync.
1641 *
1642 * Sets up the progress state and returns the total number of products to
1643 * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page
1644 * per request) until the run reports it is complete. Because each request is
1645 * short, the whole sync no longer rides on a single request that outran the
1646 * web server timeout and reported a false failure while products kept
1647 * syncing.
1648 */
1649 public function ajax_start_bulk_sync() {
1650 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1651
1652 if (!current_user_can('manage_options')) {
1653 wp_send_json_error('Insufficient permissions');
1654 }
1655
1656 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1657 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1658 }
1659
1660 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1661 $category_ids = array();
1662 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1663 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1664 $id = (int) trim($id);
1665 if ($id > 0) {
1666 $category_ids[] = $id;
1667 }
1668 }
1669 }
1670
1671 // Above the hard cap a category selection is required: refuse an
1672 // unrestricted "sync all" when the catalogue is larger than the cap.
1673 $published_total = $this->count_products_in_scope(array());
1674 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1675 wp_send_json_error(sprintf(
1676 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1677 number_format_i18n($published_total),
1678 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1679 ));
1680 }
1681
1682 // Remember the merchant's choice so ongoing auto-sync stays within it:
1683 // selected categories become the sync scope; an unrestricted "sync all"
1684 // puts the whole catalogue in scope.
1685 $this->save_sync_scope($category_ids, empty($category_ids));
1686 $this->enable_auto_sync_for_run();
1687
1688 // Count total products within scope, capped at the hard limit.
1689 $total = $this->count_products_in_scope($category_ids);
1690 if ($total > self::MAX_SYNC_PRODUCTS) {
1691 $total = self::MAX_SYNC_PRODUCTS;
1692 }
1693
1694 // Reset progress state for a fresh run.
1695 update_option('onwebchat_wc_last_sync_start', time());
1696 update_option('onwebchat_wc_bulk_page', 0);
1697 update_option('onwebchat_wc_bulk_done', 0);
1698 update_option('onwebchat_wc_bulk_total', $total);
1699 update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0));
1700 // Only enter the "in progress" state when there is actually something to
1701 // sync, so a 0-product start (empty scope) can't leave the store stuck at
1702 // "a sync is already in progress".
1703 update_option('onwebchat_wc_bulk_in_progress', $total > 0);
1704
1705 wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true));
1706 }
1707
1708 /**
1709 * AJAX: process the next page of the in-progress bulk sync and report progress.
1710 */
1711 public function ajax_sync_next_batch() {
1712 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1713
1714 // One page of products waits for the server, which may summarize long
1715 // descriptions with a model: that outlives a default max_execution_time.
1716 @set_time_limit(0);
1717
1718 if (!current_user_can('manage_options')) {
1719 wp_send_json_error('Insufficient permissions');
1720 }
1721
1722 wp_send_json_success($this->sync_next_page());
1723 }
1724
1725 /**
1726 * Process exactly one page (batch_size products) of the in-progress bulk
1727 * sync, advancing the persisted progress. The browser calls this once per
1728 * request (via ajax_sync_next_batch) until it reports the run is complete,
1729 * so no single request has to stay open for the whole catalogue.
1730 *
1731 * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does
1732 * NOT schedule a follow-up cron event: the browser drives the loop. Stats
1733 * are accumulated in an option across pages so the completion notification
1734 * (which drives the dashboard notice) carries the full run totals.
1735 *
1736 * @return array Progress snapshot: in_progress, complete, done, total, stats.
1737 */
1738 private function sync_next_page() {
1739 $total = (int) get_option('onwebchat_wc_bulk_total', 0);
1740
1741 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1742 return array(
1743 'in_progress' => false,
1744 'complete' => true,
1745 'done' => (int) get_option('onwebchat_wc_bulk_done', 0),
1746 'total' => $total,
1747 'stats' => $this->get_bulk_stats(),
1748 );
1749 }
1750
1751 $page = (int) get_option('onwebchat_wc_bulk_page', 0);
1752 $done = (int) get_option('onwebchat_wc_bulk_done', 0);
1753 $stats = $this->get_bulk_stats();
1754
1755 $args = array(
1756 'post_type' => 'product',
1757 'post_status' => 'publish',
1758 'posts_per_page' => $this->batch_size,
1759 'paged' => $page + 1,
1760 'orderby' => 'ID',
1761 'order' => 'ASC',
1762 );
1763
1764 // Restrict to what THIS run pushes (see ajax_start_bulk_sync): the
1765 // categories being added, minus everything already synced. Falls back to
1766 // the saved scope for a run started before these options existed.
1767 $run_terms_raw = get_option('onwebchat_wc_bulk_run_terms', null);
1768 $run_terms = ($run_terms_raw === null)
1769 ? $this->get_sync_scope()
1770 : $this->parse_id_list($run_terms_raw);
1771 $run_exclude = $this->parse_id_list(get_option('onwebchat_wc_bulk_run_exclude', ''));
1772
1773 $tax_query = $this->build_scope_tax_query($run_terms, $run_exclude);
1774 if ($tax_query !== null) {
1775 $args['tax_query'] = $tax_query;
1776 }
1777
1778 $query = new WP_Query($args);
1779 $complete = false;
1780
1781 if ($query->have_posts()) {
1782 $products_batch = array();
1783 foreach ($query->posts as $post) {
1784 $product = wc_get_product($post->ID);
1785 if ($product && !$this->is_product_excluded($product)) {
1786 $products_batch[] = $this->prepare_product_data($product);
1787 }
1788 }
1789
1790 $batch_done = 0;
1791 if (!empty($products_batch)) {
1792 // Tag with run total + running progress so the dashboard bar advances.
1793 $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch));
1794 if ($result && isset($result['stats'])) {
1795 $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped'];
1796 $stats['created'] += (int) $result['stats']['created'];
1797 $stats['updated'] += (int) $result['stats']['updated'];
1798 $stats['skipped'] += (int) $result['stats']['skipped'];
1799 $stats['errors'] += (int) $result['stats']['errors'];
1800 } else {
1801 // Batch failed outright: count the products as errors so the
1802 // summary reflects reality rather than silently skipping them.
1803 $batch_done = count($products_batch);
1804 $stats['errors'] += count($products_batch);
1805 }
1806 }
1807
1808 $done += $batch_done;
1809 $page += 1;
1810
1811 update_option('onwebchat_wc_bulk_page', $page);
1812 update_option('onwebchat_wc_bulk_done', $done);
1813 update_option('onwebchat_wc_bulk_stats', $stats);
1814
1815 // Stop once we have covered the counted total or reached the hard cap.
1816 if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) {
1817 $complete = true;
1818 }
1819 } else {
1820 // No more products in scope.
1821 $complete = true;
1822 }
1823
1824 wp_reset_postdata();
1825
1826 if ($complete) {
1827 $this->send_sync_completion_notification($stats);
1828 update_option('onwebchat_wc_bulk_in_progress', false);
1829 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1830 // Show 100% when the counted total was reached; otherwise leave the
1831 // real processed figure (e.g. an early empty page or the hard cap).
1832 if ($total > 0 && $done >= $total) {
1833 $done = $total;
1834 }
1835 update_option('onwebchat_wc_bulk_done', $done);
1836 }
1837
1838 return array(
1839 'in_progress' => !$complete,
1840 'complete' => $complete,
1841 'done' => $done,
1842 'total' => $total,
1843 'stats' => $stats,
1844 );
1845 }
1846
1847 /**
1848 * Read the accumulated bulk-sync stats option, normalised to the four keys.
1849 */
1850 private function get_bulk_stats() {
1851 $stats = get_option('onwebchat_wc_bulk_stats', array());
1852 if (!is_array($stats)) {
1853 $stats = array();
1854 }
1855 return array(
1856 'created' => isset($stats['created']) ? (int) $stats['created'] : 0,
1857 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0,
1858 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0,
1859 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0,
1860 );
1861 }
1862
1863 /**
1864 * Process all products in bulk sync directly (not via cron).
1865 *
1866 * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1867 */
1868 private function do_bulk_sync_all($category_ids = array()) {
1869 $total = get_option('onwebchat_wc_bulk_total', 0);
1870 $page = 0;
1871 $total_done = 0;
1872 $considered = 0; // products fetched so far, used to enforce the hard cap
1873 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
1874 $max = self::MAX_SYNC_PRODUCTS;
1875
1876 // Process all products in batches
1877 while (true) {
1878 $args = array(
1879 'post_type' => 'product',
1880 'post_status' => 'publish',
1881 'posts_per_page' => $this->batch_size,
1882 'paged' => $page + 1,
1883 'orderby' => 'ID',
1884 'order' => 'ASC',
1885 );
1886
1887 // Restrict to the chosen categories and their subtrees, to match the
1888 // per-product scope check and the counts shown in the picker.
1889 if (!empty($category_ids)) {
1890 $args['tax_query'] = array(array(
1891 'taxonomy' => 'product_cat',
1892 'field' => 'term_id',
1893 'terms' => array_map('intval', $category_ids),
1894 'include_children' => true,
1895 ));
1896 }
1897
1898 $query = new WP_Query($args);
1899
1900 if (!$query->have_posts()) {
1901 break;
1902 }
1903
1904 // Collect products in this batch, honoring the hard cap.
1905 $products_batch = array();
1906 $reached_cap = false;
1907 foreach ($query->posts as $post) {
1908 if ($considered >= $max) {
1909 $reached_cap = true;
1910 break;
1911 }
1912 $considered++;
1913 $product = wc_get_product($post->ID);
1914 if ($product && !$this->is_product_excluded($product)) {
1915 $products_batch[] = $this->prepare_product_data($product);
1916 }
1917 }
1918
1919 // Send batch
1920 if (!empty($products_batch)) {
1921 $result = $this->send_product_batch($products_batch);
1922 if ($result && isset($result['stats'])) {
1923 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1924 $all_stats['created'] += $result['stats']['created'];
1925 $all_stats['updated'] += $result['stats']['updated'];
1926 $all_stats['skipped'] += $result['stats']['skipped'];
1927 $all_stats['errors'] += $result['stats']['errors'];
1928 } else {
1929 // Fallback
1930 $total_done += count($products_batch);
1931 }
1932
1933 // Update progress after each batch so AJAX polling can see it
1934 update_option('onwebchat_wc_bulk_done', $total_done);
1935
1936 // Breathe between batches so a long run cannot walk into the
1937 // server's product-sync rate limit (150 requests per 5 minutes
1938 // per IP). One second is plenty: each batch already costs a
1939 // synchronous HTTP call of its own, so the real cycle time is
1940 // seconds even when nothing needs summarizing.
1941 sleep(1);
1942 }
1943
1944 wp_reset_postdata();
1945 $page++;
1946
1947 // Stop once the hard cap is reached.
1948 if ($reached_cap || $considered >= $max) {
1949 break;
1950 }
1951
1952 // Safety check - don't loop forever. The cap allows up to
1953 // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1954 if ($page > ($max / $this->batch_size) + 10) {
1955 break;
1956 }
1957 }
1958
1959 // Send completion notification to Angular dashboard with total stats
1960 $this->send_sync_completion_notification($all_stats);
1961
1962 // Mark sync as complete
1963 update_option('onwebchat_wc_bulk_in_progress', false);
1964 update_option('onwebchat_wc_bulk_done', $total_done);
1965 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1966
1967 return array(
1968 'done' => $total_done,
1969 'total' => $total,
1970 'stats' => $all_stats
1971 );
1972 }
1973
1974 /**
1975 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
1976 */
1977 public function process_bulk_sync_batch() {
1978 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
1979
1980 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1981 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
1982 return;
1983 }
1984
1985 $page = get_option('onwebchat_wc_bulk_page', 0);
1986 $done = get_option('onwebchat_wc_bulk_done', 0);
1987 $total = get_option('onwebchat_wc_bulk_total', 0);
1988
1989 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1990
1991 // Get batch of products
1992 $args = array(
1993 'post_type' => 'product',
1994 'post_status' => 'publish',
1995 'posts_per_page' => $this->batch_size,
1996 'paged' => $page + 1,
1997 'orderby' => 'ID',
1998 'order' => 'ASC',
1999 );
2000
2001 // Restrict to the saved sync scope and its subcategories, consistent
2002 // with the synchronous bulk sync path.
2003 $scope = $this->get_sync_scope();
2004 if (!empty($scope)) {
2005 $args['tax_query'] = array(array(
2006 'taxonomy' => 'product_cat',
2007 'field' => 'term_id',
2008 'terms' => array_map('intval', $scope),
2009 'include_children' => true,
2010 ));
2011 }
2012
2013 $query = new WP_Query($args);
2014
2015 if ($query->have_posts()) {
2016 // Collect all products in this batch
2017 $products_batch = array();
2018
2019 foreach ($query->posts as $post) {
2020 $product = wc_get_product($post->ID);
2021 if ($product && !$this->is_product_excluded($product)) {
2022 $products_batch[] = $this->prepare_product_data($product);
2023 }
2024 }
2025
2026 // Send entire batch in one request
2027 $batch_done = 0;
2028 if (!empty($products_batch)) {
2029 $result = $this->send_product_batch($products_batch);
2030 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
2031 if ($result && isset($result['stats'])) {
2032 // Count created + updated + skipped as "done"
2033 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
2034 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
2035 } else {
2036 // Fallback: assume all sent
2037 $batch_done = count($products_batch);
2038 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
2039 }
2040 }
2041
2042 // Update progress
2043 $new_done = $done + $batch_done;
2044 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
2045 update_option('onwebchat_wc_bulk_page', $page + 1);
2046 update_option('onwebchat_wc_bulk_done', $new_done);
2047
2048 // Check if we've processed all products
2049 if ($new_done >= $total || !$query->have_posts()) {
2050 // Sync complete
2051 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
2052 update_option('onwebchat_wc_bulk_in_progress', false);
2053 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
2054 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
2055 } else {
2056 // Schedule next batch
2057 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
2058 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
2059 }
2060 } else {
2061 // No more products - sync complete
2062 update_option('onwebchat_wc_bulk_in_progress', false);
2063 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
2064 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
2065 }
2066
2067 wp_reset_postdata();
2068 }
2069
2070 /**
2071 * AJAX: Regenerate secret (fetch from server)
2072 */
2073 public function ajax_regenerate_secret() {
2074 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2075
2076 if (!current_user_can('manage_options')) {
2077 wp_send_json_error('Insufficient permissions');
2078 }
2079
2080 // Clear cached secret - user will need to re-authenticate
2081 delete_option('onwebchat_wc_sync_secret');
2082
2083 // Clear any authentication error notices
2084 delete_transient('onwebchat_wc_auth_error');
2085
2086 wp_send_json_success(array(
2087 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
2088 'needs_reconnect' => true
2089 ));
2090 }
2091
2092 /**
2093 * AJAX: Reset sync status
2094 */
2095 public function ajax_reset_sync_status() {
2096 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2097
2098 if (!current_user_can('manage_options')) {
2099 wp_send_json_error('Insufficient permissions');
2100 }
2101
2102 $total = get_option('onwebchat_wc_bulk_total', 0);
2103
2104 // Mark sync as complete
2105 update_option('onwebchat_wc_bulk_in_progress', false);
2106 update_option('onwebchat_wc_bulk_done', $total);
2107 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
2108
2109 wp_send_json_success(array(
2110 'message' => 'Sync status reset successfully'
2111 ));
2112 }
2113
2114 /**
2115 * AJAX handler to get current sync status
2116 */
2117 public function ajax_get_sync_status() {
2118 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2119
2120 if (!current_user_can('manage_options')) {
2121 wp_send_json_error('Insufficient permissions');
2122 }
2123
2124 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
2125 $done = get_option('onwebchat_wc_bulk_done', 0);
2126 $total = get_option('onwebchat_wc_bulk_total', 0);
2127
2128 wp_send_json_success(array(
2129 'in_progress' => $in_progress,
2130 'done' => $done,
2131 'total' => $total
2132 ));
2133 }
2134
2135 /**
2136 * AJAX handler to save sync enabled setting
2137 */
2138 public function ajax_save_sync_enabled() {
2139 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2140
2141 if (!current_user_can('manage_options')) {
2142 wp_send_json_error('Insufficient permissions');
2143 }
2144
2145 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
2146
2147 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
2148
2149 wp_send_json_success(array(
2150 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
2151 'enabled' => $sync_enabled
2152 ));
2153 }
2154
2155 /**
2156 * Get sync status for admin display
2157 */
2158 public function get_sync_status() {
2159 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
2160 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
2161 $done = get_option('onwebchat_wc_bulk_done', 0);
2162 $total = get_option('onwebchat_wc_bulk_total', 0);
2163
2164 return array(
2165 'last_sync' => $last_sync,
2166 'in_progress' => $in_progress,
2167 'done' => $done,
2168 'total' => $total,
2169 );
2170 }
2171
2172 /**
2173 * AJAX: start removing categories from the AI training data.
2174 *
2175 * The counterpart of the additive sync scope: unticking a category never
2176 * removes anything by itself, the merchant has to ask for it here. Posts the
2177 * categories that should REMAIN ticked; whatever the saved scope holds on top
2178 * of that is what gets removed, together with its products, unless those
2179 * products also sit in a category that stays.
2180 */
2181 public function ajax_scope_remove_start() {
2182 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2183
2184 if (!current_user_can('manage_options')) {
2185 wp_send_json_error('Insufficient permissions');
2186 }
2187
2188 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
2189 wp_send_json_error('A sync is in progress. Please wait for it to finish.');
2190 }
2191
2192 @set_time_limit(0);
2193
2194 $keep = array();
2195 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
2196 $keep = $this->parse_id_list(sanitize_text_field(wp_unslash($_POST['categories'])));
2197 }
2198
2199 $saved_scope = $this->get_sync_scope();
2200 if (empty($saved_scope)) {
2201 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.');
2202 }
2203
2204 $removed = $this->categories_removed($keep, $saved_scope);
2205 if (empty($removed)) {
2206 wp_send_json_error('No synced categories were unticked, so there is nothing to remove.');
2207 }
2208
2209 // Products of the dropped categories that are not also in a category the
2210 // merchant keeps: a product in both stays in the training data.
2211 $total = $this->count_products_in_scope($removed, $keep);
2212
2213 update_option('onwebchat_wc_remove_terms', implode(',', $removed));
2214 update_option('onwebchat_wc_remove_keep', implode(',', $keep));
2215 update_option('onwebchat_wc_remove_total', $total);
2216 update_option('onwebchat_wc_remove_done', 0);
2217 update_option('onwebchat_wc_remove_in_progress', true);
2218
2219 $complete = ($total === 0);
2220 if ($complete) {
2221 $this->finish_scope_removal();
2222 }
2223
2224 wp_send_json_success(array(
2225 'total' => $total,
2226 'categories' => count($removed),
2227 'done' => 0,
2228 'complete' => $complete,
2229 // Removing everything also switches automatic product sync off, see
2230 // finish_scope_removal(); the UI says so before the merchant confirms.
2231 'disables_sync' => empty($keep),
2232 ));
2233 }
2234
2235 /**
2236 * AJAX: delete one page of products of the categories being removed.
2237 * The browser calls this until it reports complete, exactly like the sync.
2238 */
2239 public function ajax_scope_remove_batch() {
2240 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2241
2242 if (!current_user_can('manage_options')) {
2243 wp_send_json_error('Insufficient permissions');
2244 }
2245
2246 @set_time_limit(0);
2247
2248 if (!get_option('onwebchat_wc_remove_in_progress', false)) {
2249 wp_send_json_success(array(
2250 'complete' => true,
2251 'done' => (int) get_option('onwebchat_wc_remove_done', 0),
2252 'total' => (int) get_option('onwebchat_wc_remove_total', 0),
2253 ));
2254 }
2255
2256 $removed = $this->parse_id_list(get_option('onwebchat_wc_remove_terms', ''));
2257 $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', ''));
2258 $total = (int) get_option('onwebchat_wc_remove_total', 0);
2259 $done = (int) get_option('onwebchat_wc_remove_done', 0);
2260
2261 // Never query without a category restriction. An empty $removed would make
2262 // build_scope_tax_query() return no clause at all, and this page would then
2263 // delete the first 200 products of the WHOLE catalogue from the training
2264 // data. That can only happen if the run state was lost half way (option
2265 // cleared, in-progress flag left behind), so treat it as "nothing to do".
2266 if (empty($removed)) {
2267 update_option('onwebchat_wc_remove_in_progress', false);
2268 delete_option('onwebchat_wc_remove_terms');
2269 delete_option('onwebchat_wc_remove_keep');
2270
2271 wp_send_json_success(array(
2272 'complete' => true,
2273 'done' => $done,
2274 'total' => $total,
2275 ));
2276 }
2277
2278 $args = array(
2279 'post_type' => 'product',
2280 'post_status' => 'publish',
2281 'posts_per_page' => self::REMOVE_PAGE_SIZE,
2282 'orderby' => 'ID',
2283 'order' => 'ASC',
2284 'fields' => 'ids',
2285 // Deleting on the onWebChat side never changes this query, but the
2286 // rows already handled must be skipped, hence the offset.
2287 'offset' => $done,
2288 );
2289
2290 $tax_query = $this->build_scope_tax_query($removed, $keep);
2291 if ($tax_query !== null) {
2292 $args['tax_query'] = $tax_query;
2293 }
2294
2295 $query = new WP_Query($args);
2296 $ids = $query->posts;
2297
2298 if (empty($ids)) {
2299 $this->finish_scope_removal();
2300 return wp_send_json_success(array(
2301 'complete' => true,
2302 'done' => $done,
2303 'total' => $total,
2304 ));
2305 }
2306
2307 $result = $this->send_products_delete_batch($ids);
2308 if (empty($result['success'])) {
2309 wp_send_json_error('Could not remove the products from onWebChat. Please try again.');
2310 }
2311
2312 $done += count($ids);
2313 update_option('onwebchat_wc_remove_done', $done);
2314
2315 $complete = ($done >= $total) || (count($ids) < self::REMOVE_PAGE_SIZE);
2316 if ($complete) {
2317 $this->finish_scope_removal();
2318 }
2319
2320 wp_send_json_success(array(
2321 'complete' => $complete,
2322 'done' => min($done, max($total, $done)),
2323 'total' => max($total, $done),
2324 ));
2325 }
2326
2327 /**
2328 * Close a removal run: the kept categories become the new sync scope, so
2329 * ongoing auto-sync stops covering what was just removed.
2330 *
2331 * @return bool
2332 */
2333 private function finish_scope_removal() {
2334 $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', ''));
2335 $had_scope = (bool) $this->get_sync_scope();
2336
2337 // After a removal the scope is exactly what is kept, nothing implied: an
2338 // empty list here means the AI training data holds no products, not the
2339 // whole catalogue. Only when something was really removed, so a no-op
2340 // call on a site that syncs everything leaves its scope alone.
2341 if ($had_scope || !empty($keep)) {
2342 $this->save_sync_scope($keep, false);
2343 }
2344
2345 // Nothing left ticked means the bot should hold no products at all. An
2346 // empty scope means "the whole catalogue", so leaving automatic sync on
2347 // would push every product straight back in on its next edit.
2348 // Only when a scope was actually being removed: a no-op call on a store
2349 // that already syncs its whole catalogue must never touch the toggle.
2350 if (empty($keep) && $had_scope) {
2351 update_option('onwebchat_wc_sync_enabled', false);
2352 // Note who turned it off, so the next bulk sync can turn it back on.
2353 update_option('onwebchat_wc_sync_off_by_removal', true);
2354 }
2355
2356 update_option('onwebchat_wc_remove_in_progress', false);
2357 delete_option('onwebchat_wc_remove_terms');
2358 delete_option('onwebchat_wc_remove_keep');
2359
2360 return true;
2361 }
2362
2363 /**
2364 * AJAX: Manually process batch (for debugging)
2365 */
2366 public function ajax_manual_process_batch() {
2367 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
2368
2369 if (!current_user_can('manage_options')) {
2370 wp_send_json_error('Insufficient permissions');
2371 }
2372
2373 // Manually trigger the cron job
2374 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
2375 $this->process_bulk_sync_batch();
2376
2377 // Return current status
2378 $status = $this->get_sync_status();
2379 wp_send_json_success(array(
2380 'message' => 'Batch processed',
2381 'status' => $status
2382 ));
2383 }
2384 }
2385
2386 // Initialize the sync module
2387 global $onwebchat_wc_sync;
2388 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
2389
2390