PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.7.0
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.7.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.7.0, at includes/woocommerce-sync.php

1,199 lines 45.7 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 private $max_description_length = 1000;
16 private $batch_size = 50;
17 private $use_testing_mode;
18
19 /**
20 * Get the API endpoint based on testing mode
21 * @return string
22 */
23 private function get_api_endpoint() {
24 return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
25 }
26
27 public function __construct() {
28 // Read testing mode from global constant (defined in onwebchat.php)
29 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
30 // Initialize settings
31 add_action('admin_init', array($this, 'register_settings'));
32
33 // Show authentication error notice globally (not just on WooCommerce tab)
34 add_action('admin_notices', array($this, 'show_auth_error_notice'));
35
36 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
37 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
38 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
39
40 // Handle product deletion (both trash and permanent delete)
41 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
42 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
43
44 // Bulk sync via WP Cron
45 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
46
47 // Admin AJAX handlers
48 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
49 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
50 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
51 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
52 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
53 add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
54 add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
55 }
56
57 /**
58 * AJAX: Connect WooCommerce with authentication
59 */
60 public function ajax_connect_woocommerce() {
61 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
62
63 if (!current_user_can('manage_options')) {
64 wp_send_json_error('Insufficient permissions');
65 }
66
67 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
68 $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
69
70 if (empty($email) || empty($password)) {
71 wp_send_json_error('Email and password are required');
72 }
73
74 $result = $this->request_secret_with_auth($email, $password);
75
76 if ($result['success']) {
77 // Clear any previous authentication errors
78 delete_transient('onwebchat_wc_auth_error');
79
80 wp_send_json_success(array(
81 'message' => 'WooCommerce sync connected successfully!'
82 ));
83 } else {
84 wp_send_json_error($result['error']);
85 }
86 }
87
88 /**
89 * Show authentication error notice globally across all admin pages
90 * (Hidden when already on WooCommerce tab since it has its own error message)
91 */
92 public function show_auth_error_notice() {
93 $auth_error = get_transient('onwebchat_wc_auth_error');
94
95 // Don't show if we're already on the WooCommerce tab (it has its own error message)
96 $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings'
97 && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
98
99 if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
100 ?>
101 <div class="notice notice-error">
102 <p>
103 <strong>⚠️ onWebChat WooCommerce Sync Error:</strong>
104 <?php echo esc_html($auth_error); ?>
105 <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
106 Fix Authentication
107 </a>
108 </p>
109 </div>
110 <?php
111 }
112 }
113
114 /**
115 * Register WooCommerce sync settings
116 */
117 public function register_settings() {
118 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
119 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
120 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
121 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
122 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
123 }
124
125 /**
126 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
127 */
128 public function on_product_update($product_id) {
129 // Check if sync is enabled
130 if (!get_option('onwebchat_wc_sync_enabled', false)) {
131 return;
132 }
133
134 // Get product object (at this point all meta data including SKU is already saved)
135 $product = wc_get_product($product_id);
136 if (!$product) {
137 return;
138 }
139
140 // Only sync published products
141 if ($product->get_status() !== 'publish') {
142 return;
143 }
144
145 // Check if product category is excluded
146 if ($this->is_product_excluded($product)) {
147 return;
148 }
149
150 // Prepare and send product data
151 $product_data = $this->prepare_product_data($product);
152 $this->send_product_upsert($product_data, $product_id);
153 }
154
155 /**
156 * Hook: Product trash (when moved to trash)
157 */
158 public function on_product_trash($post_id) {
159 // Check if it's a product
160 if (get_post_type($post_id) !== 'product') {
161 return;
162 }
163
164 if (!get_option('onwebchat_wc_sync_enabled', false)) {
165 return;
166 }
167
168 // Send delete request when product is trashed
169 $this->send_product_delete($post_id);
170 }
171
172 /**
173 * Hook: Product permanent delete
174 */
175 public function on_product_delete($post_id, $post) {
176 if ($post->post_type !== 'product') {
177 return;
178 }
179
180 if (!get_option('onwebchat_wc_sync_enabled', false)) {
181 return;
182 }
183
184 // Send delete request when product is permanently deleted
185 $this->send_product_delete($post_id);
186 }
187
188 /**
189 * Check if product is in excluded categories
190 */
191 private function is_product_excluded($product) {
192 $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
193 if (empty($excluded_categories)) {
194 return false;
195 }
196
197 $product_categories = $product->get_category_ids();
198 foreach ($product_categories as $cat_id) {
199 if (in_array($cat_id, $excluded_categories)) {
200 return true;
201 }
202 }
203
204 return false;
205 }
206
207 /**
208 * Prepare product data for sync
209 */
210 private function prepare_product_data($product) {
211 $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full');
212
213 // Get description based on sync mode
214 $description = '';
215 $short_description = strip_tags($product->get_short_description());
216
217 if ($sync_mode === 'short_only') {
218 $description = $short_description;
219 } else if ($sync_mode === 'short_fallback_full') {
220 if (!empty($short_description)) {
221 $description = $short_description;
222 } else {
223 // Fallback to first 60-80 words of full description
224 $full_description = strip_tags($product->get_description());
225 $words = str_word_count($full_description, 2);
226 $word_array = array_keys($words);
227
228 if (count($word_array) > 80) {
229 $end_pos = $word_array[79];
230 $description = substr($full_description, 0, $end_pos) . '...';
231 } else {
232 $description = $full_description;
233 }
234 }
235 }
236
237 // Enforce max length
238 if (strlen($description) > $this->max_description_length) {
239 $description = substr($description, 0, $this->max_description_length) . '...';
240 }
241
242 $sku = $product->get_sku();
243 $categories = $this->get_product_category_names($product);
244 $url = get_permalink($product->get_id());
245
246 // Structured fields. The server rebuilds the embedding text from these,
247 // so there is no need to send a pre-formatted "text" blob.
248 $data = array(
249 'product_id' => $product->get_id(),
250 'name' => $product->get_name(),
251 'short_description' => trim($description),
252 'url' => $url,
253 'sku' => $sku,
254 'categories' => $categories,
255 'currency' => get_woocommerce_currency(),
256 );
257
258 // Price (always sent). Variable products carry a min/max range.
259 $data['price'] = $product->get_price();
260
261 if ($product->is_type('variable')) {
262 // Raw min/max prices, consistent with get_price() used for simple products.
263 $data['price_min'] = $product->get_variation_price('min', false);
264 $data['price_max'] = $product->get_variation_price('max', false);
265 } else {
266 $regular_price = $product->get_regular_price();
267 if ($regular_price !== '') {
268 $data['regular_price'] = $regular_price;
269 }
270 // Only advertise a sale price while the sale is actually active.
271 if ($product->is_on_sale()) {
272 $data['sale_price'] = $product->get_sale_price();
273 }
274 }
275
276 // Stock availability
277 $data['in_stock'] = $product->is_in_stock();
278 if ($product->managing_stock()) {
279 $stock_qty = $product->get_stock_quantity();
280 if ($stock_qty !== null) {
281 $data['quantity'] = (int) $stock_qty;
282 }
283 }
284
285 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
286 $brand = $this->get_product_brand($product);
287 if (!empty($brand)) {
288 $data['manufacturer'] = $brand;
289 }
290
291 // Variation attributes / options (Color, Size, ...)
292 $attributes = $this->get_product_attributes($product);
293 if (!empty($attributes)) {
294 $data['attributes'] = $attributes;
295 }
296
297 // Tags
298 $tags = $this->get_product_tags($product);
299 if (!empty($tags)) {
300 $data['tags'] = $tags;
301 }
302
303 // Average rating and review count
304 $rating = (float) $product->get_average_rating();
305 if ($rating > 0) {
306 $data['rating'] = $rating;
307 $data['review_count'] = (int) $product->get_review_count();
308 }
309
310 return $data;
311 }
312
313 /**
314 * Get product category names
315 */
316 private function get_product_category_names($product) {
317 $categories = array();
318 $category_ids = $product->get_category_ids();
319
320 foreach ($category_ids as $cat_id) {
321 $term = get_term($cat_id, 'product_cat');
322 if ($term && !is_wp_error($term)) {
323 $categories[] = $term->name;
324 }
325 }
326
327 return $categories;
328 }
329
330 /**
331 * Get the product's brand name from whichever brand taxonomy is available.
332 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
333 */
334 private function get_product_brand($product) {
335 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
336
337 foreach ($taxonomies as $taxonomy) {
338 if (!taxonomy_exists($taxonomy)) {
339 continue;
340 }
341
342 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
343 if (!is_wp_error($terms) && !empty($terms)) {
344 return $terms[0];
345 }
346 }
347
348 return '';
349 }
350
351 /**
352 * Get visible product attributes as an array of { name, options }.
353 * Works for both custom and taxonomy-based (global) attributes.
354 */
355 private function get_product_attributes($product) {
356 $result = array();
357
358 foreach ($product->get_attributes() as $attribute) {
359 if (!is_object($attribute) || !$attribute->get_visible()) {
360 continue;
361 }
362
363 $name = wc_attribute_label($attribute->get_name());
364
365 if ($attribute->is_taxonomy()) {
366 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
367 } else {
368 $options = $attribute->get_options();
369 }
370
371 $options = array_values(array_filter(array_map('trim', (array) $options)));
372
373 if (!empty($name) && !empty($options)) {
374 $result[] = array(
375 'name' => $name,
376 'options' => $options,
377 );
378 }
379 }
380
381 return $result;
382 }
383
384 /**
385 * Get product tag names.
386 */
387 private function get_product_tags($product) {
388 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
389
390 if (is_wp_error($tags) || empty($tags)) {
391 return array();
392 }
393
394 return $tags;
395 }
396
397 /**
398 * Send batch of products to API (optimized)
399 * @param array $products - Array of product data
400 */
401 private function send_product_batch($products) {
402 $chatId = get_option('onwebchat_plugin_option');
403 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
404
405 if (empty($chatId)) {
406 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
407 return false;
408 }
409
410 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
411 $secret = $this->get_secret(false);
412 if (empty($secret)) {
413 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
414 return array(
415 'success' => false,
416 'error' => 'No secret configured. Please connect WooCommerce integration.',
417 'needs_reconnect' => true
418 );
419 }
420
421 // Extract key part (before first slash if present)
422 $chatIdKey = explode('/', $chatId)[0];
423
424 $endpoint = $this->get_api_endpoint() . '/product/batch';
425 $payload = array(
426 'site_id' => $chatIdKey,
427 'site_url' => get_site_url(),
428 'products' => $products
429 );
430
431 // Generate authentication headers (same as send_authenticated_request)
432 $timestamp = time();
433 $nonce = base64_encode(random_bytes(16));
434 $body_json = wp_json_encode($payload);
435
436 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
437 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
438 $signature = hash_hmac('sha256', $message, $secret);
439
440 // Send request
441 $request_args = array(
442 'method' => 'POST',
443 'timeout' => 30, // Longer timeout for batch operations
444 'headers' => array(
445 'Content-Type' => 'application/json',
446 'X-OWC-SiteId' => $chatIdKey,
447 'X-OWC-Timestamp' => $timestamp,
448 'X-OWC-Nonce' => $nonce,
449 'X-OWC-Signature' => $signature,
450 ),
451 'body' => $body_json,
452 );
453
454 // Disable SSL verification for local dev server
455 if ($this->use_testing_mode) {
456 $request_args['sslverify'] = false;
457 }
458
459 $response = wp_remote_post($endpoint, $request_args);
460
461 if (is_wp_error($response)) {
462 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
463 return array(
464 'success' => false,
465 'error' => 'Network error: ' . $response->get_error_message()
466 );
467 }
468
469 $response_code = wp_remote_retrieve_response_code($response);
470 $body = json_decode(wp_remote_retrieve_body($response), true);
471
472 // If authentication failed (401), the secret is invalid or out of sync
473 if ($response_code === 401) {
474 // Clear the invalid secret
475 delete_option('onwebchat_wc_sync_secret');
476
477 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
478
479 // Store admin notice about authentication failure
480 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);
481
482 return array(
483 'success' => false,
484 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
485 'needs_reconnect' => true
486 );
487 }
488
489 if ($response_code === 200 && isset($body['success']) && $body['success']) {
490 // Clear any previous auth errors on success
491 delete_transient('onwebchat_wc_auth_error');
492 return $body; // Return full response with stats
493 }
494
495 $error_msg = 'Batch sync failed';
496 if (isset($body['error'])) {
497 $error_msg .= ': ' . $body['error'];
498 }
499 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
500
501 return array(
502 'success' => false,
503 'error' => $error_msg
504 );
505 }
506
507 /**
508 * Send sync completion notification to server (triggers Angular modal)
509 */
510 private function send_sync_completion_notification($total_stats) {
511 $chatId = get_option('onwebchat_plugin_option');
512 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
513
514 if (empty($chatId)) {
515 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
516 return false;
517 }
518
519 $secret = $this->get_secret(false);
520 if (empty($secret)) {
521 error_log('onWebChat WooCommerce Sync - No secret configured');
522 return false;
523 }
524
525 $chatIdKey = explode('/', $chatId)[0];
526
527 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
528 $payload = array(
529 'site_id' => $chatIdKey,
530 'stats' => $total_stats
531 );
532
533 // Generate authentication headers
534 $timestamp = time();
535 $nonce = base64_encode(random_bytes(16));
536 $body_json = wp_json_encode($payload);
537 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
538 $signature = hash_hmac('sha256', $message, $secret);
539
540 $request_args = array(
541 'method' => 'POST',
542 'timeout' => 10,
543 'headers' => array(
544 'Content-Type' => 'application/json',
545 'X-OWC-SiteId' => $chatIdKey,
546 'X-OWC-Timestamp' => $timestamp,
547 'X-OWC-Nonce' => $nonce,
548 'X-OWC-Signature' => $signature,
549 ),
550 'body' => $body_json,
551 );
552
553 if ($this->use_testing_mode) {
554 $request_args['sslverify'] = false;
555 }
556
557 $response = wp_remote_post($endpoint, $request_args);
558
559 if (is_wp_error($response)) {
560 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
561 return false;
562 }
563
564 $response_code = wp_remote_retrieve_response_code($response);
565
566 // If authentication failed (401), the secret is invalid or out of sync
567 if ($response_code === 401) {
568 delete_option('onwebchat_wc_sync_secret');
569 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
570 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);
571 return false;
572 }
573
574 if ($response_code >= 200 && $response_code < 300) {
575 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
576 return true;
577 }
578
579 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
580 return false;
581 }
582
583 /**
584 * Send product upsert to server (uses batch endpoint with single product)
585 */
586 private function send_product_upsert($product_data, $product_id) {
587 // Use batch endpoint with single product
588 $result = $this->send_product_batch(array($product_data));
589
590 if ($result && isset($result['success']) && $result['success']) {
591 // Clear any previous errors
592 delete_post_meta($product_id, '_onwebchat_sync_error');
593 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
594 return true;
595 } else {
596 // Log error if batch failed
597 $error_message = 'Failed to sync product';
598 if ($result && isset($result['error'])) {
599 $error_message = $result['error'];
600 } elseif (!$result) {
601 $error_message = 'Batch sync request failed';
602 }
603 $this->log_error($product_id, $error_message);
604 return false;
605 }
606 }
607
608 /**
609 * Send product delete to server
610 */
611 private function send_product_delete($product_id) {
612 $chatId = get_option('onwebchat_plugin_option');
613 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
614
615 if (empty($chatId)) {
616 return false;
617 }
618
619 // Extract key part (before first slash if present)
620 $chatIdKey = explode('/', $chatId)[0];
621
622 $endpoint = $this->get_api_endpoint() . '/product/delete';
623 $payload = array(
624 'site_id' => $chatIdKey, // Use key part only
625 'site_url' => get_site_url(),
626 'product_id' => $product_id
627 );
628
629 $this->send_authenticated_request($endpoint, $payload, $product_id);
630 }
631
632 /**
633 * Get cached secret from local options
634 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
635 */
636 private function get_secret($force_refresh = false) {
637 // Always return cached secret - never fetch automatically
638 // Secret must be obtained via authenticated request in WooCommerce settings
639 $secret = get_option('onwebchat_wc_sync_secret');
640
641 if (!empty($secret)) {
642 return $secret;
643 }
644
645 // No secret available - user must authenticate in WooCommerce settings
646 return null;
647 }
648
649 /**
650 * Request secret from server with authentication
651 * This is called when user clicks "Connect WooCommerce" with their password
652 *
653 * @param {string} email - User's onWebChat email
654 * @param {string} password - User's onWebChat password
655 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
656 */
657 public function request_secret_with_auth($email, $password) {
658 $chatId = get_option('onwebchat_plugin_option');
659 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
660
661 if (empty($chatId)) {
662 return array('success' => false, 'error' => 'No Chat ID configured');
663 }
664
665 // Extract key part (before first slash if present)
666 $key = explode('/', $chatId)[0];
667
668 // Request secret from server with authentication
669 $secret_endpoint = $this->get_api_endpoint() . '/secret';
670
671 $response = wp_remote_post($secret_endpoint, array(
672 'timeout' => 15,
673 'sslverify' => !$this->use_testing_mode,
674 'headers' => array(
675 'Content-Type' => 'application/json',
676 ),
677 'body' => wp_json_encode(array(
678 'email' => $email,
679 'password' => $password,
680 'site_key' => $key,
681 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
682 )),
683 ));
684
685 if (is_wp_error($response)) {
686 $error_message = $response->get_error_message();
687 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
688 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
689 }
690
691 $status_code = wp_remote_retrieve_response_code($response);
692 $response_body_raw = wp_remote_retrieve_body($response);
693 $body = json_decode($response_body_raw, true);
694
695 // Log response for debugging. Redact the secret so it never lands in server/debug logs
696 // (a successful response body contains the HMAC secret).
697 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
698 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
699
700 // Handle specific HTTP status codes
701 if ($status_code === 401) {
702 return array('success' => false, 'error' => 'Invalid email or password');
703 }
704
705 if ($status_code === 403) {
706 return array('success' => false, 'error' => 'You do not have access to this site');
707 }
708
709 // Success case
710 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
711 $secret = isset($body['secret']) ? $body['secret'] : null;
712 if (empty($secret)) {
713 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
714 return array('success' => false, 'error' => 'Server response missing secret');
715 }
716 update_option('onwebchat_wc_sync_secret', $secret);
717 return array('success' => true, 'secret' => $secret);
718 }
719
720 // Extract error message from various possible response formats
721 $error_message = 'Unknown error';
722
723 if (is_array($body)) {
724 // Try different possible error fields
725 if (isset($body['error'])) {
726 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
727 } elseif (isset($body['message'])) {
728 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
729 } elseif (isset($body['errors']) && is_array($body['errors'])) {
730 $error_message = implode(', ', $body['errors']);
731 }
732 } elseif (!empty($response_body_raw)) {
733 // If body is not JSON or empty, use raw response (truncated)
734 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
735 }
736
737 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
738 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
739 $error_message = sanitize_text_field($error_message);
740
741 // Include status code in error message if not already included
742 if ($status_code && strpos($error_message, 'HTTP') === false) {
743 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
744 }
745
746 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
747 return array('success' => false, 'error' => $error_message);
748 }
749
750 /**
751 * Send authenticated request with HMAC signature
752 */
753 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
754 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
755 $secret = $this->get_secret(false);
756
757 if (empty($secret)) {
758 if ($product_id) {
759 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
760 }
761 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
762 }
763
764 $chatId = get_option('onwebchat_plugin_option');
765 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
766
767 // Extract key part (before first slash if present) for consistency with server
768 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
769 $chatIdKey = explode('/', $chatId)[0];
770
771 // Generate authentication headers
772 $timestamp = time();
773 $nonce = base64_encode(random_bytes(16));
774 $body_json = wp_json_encode($payload);
775
776 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
777 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
778 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
779 $signature = hash_hmac('sha256', $message, $secret);
780
781 // Send request
782 $request_args = array(
783 'method' => 'POST',
784 'timeout' => 10,
785 'headers' => array(
786 'Content-Type' => 'application/json',
787 'X-OWC-SiteId' => $chatIdKey, // Use key part only
788 'X-OWC-Timestamp' => $timestamp,
789 'X-OWC-Nonce' => $nonce,
790 'X-OWC-Signature' => $signature,
791 ),
792 'body' => $body_json,
793 );
794
795 // Disable SSL verification for local dev server
796 if ($this->use_testing_mode) {
797 $request_args['sslverify'] = false;
798 }
799
800 $response = wp_remote_post($endpoint, $request_args);
801
802 // Handle response
803 if (is_wp_error($response)) {
804 $error_message = $response->get_error_message();
805 if ($product_id) {
806 $this->log_error($product_id, $error_message);
807 }
808 return array('success' => false, 'error' => $error_message);
809 }
810
811 $status_code = wp_remote_retrieve_response_code($response);
812
813 // Success
814 if ($status_code >= 200 && $status_code < 300) {
815 return array('success' => true);
816 }
817
818 // If authentication failed (401), the secret may be invalid
819 if ($status_code === 401) {
820 // Clear the invalid secret
821 delete_option('onwebchat_wc_sync_secret');
822
823 if ($product_id) {
824 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
825 }
826 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
827 }
828
829 // Error
830 $error_body = wp_remote_retrieve_body($response);
831 if ($product_id) {
832 $this->log_error($product_id, "HTTP $status_code: $error_body");
833 }
834
835 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
836 }
837
838 /**
839 * Log sync error to product meta
840 */
841 private function log_error($product_id, $error_message) {
842 update_post_meta($product_id, '_onwebchat_sync_error', array(
843 'message' => $error_message,
844 'timestamp' => current_time('timestamp')
845 ));
846 }
847
848 /**
849 * AJAX: Start bulk sync
850 */
851 public function ajax_sync_existing_products() {
852 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
853
854 if (!current_user_can('manage_options')) {
855 wp_send_json_error('Insufficient permissions');
856 }
857
858 // Check if sync is already in progress
859 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
860 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
861 }
862
863 // Rate limiting: prevent syncing more than once every 5 minutes
864 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
865 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
866 $time_since_last_sync = time() - $last_sync_time;
867
868 if ($time_since_last_sync < $cooldown_period) {
869 $wait_time = $cooldown_period - $time_since_last_sync;
870 $minutes = ceil($wait_time / 60);
871 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
872 }
873
874 // Store the current sync start time
875 update_option('onwebchat_wc_last_sync_start', time());
876
877 // Reset bulk sync progress
878 update_option('onwebchat_wc_bulk_page', 0);
879 update_option('onwebchat_wc_bulk_done', 0);
880
881 // Count total products
882 $args = array(
883 'post_type' => 'product',
884 'post_status' => 'publish',
885 'posts_per_page' => -1,
886 'fields' => 'ids',
887 );
888
889 $products = get_posts($args);
890 $total = count($products);
891
892 update_option('onwebchat_wc_bulk_total', $total);
893 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
894 update_option('onwebchat_wc_bulk_in_progress', true);
895
896 // Process sync directly instead of using unreliable WP Cron
897 $sync_result = $this->do_bulk_sync_all();
898
899 wp_send_json_success(array(
900 'message' => 'Bulk sync completed',
901 'total' => $total,
902 'result' => $sync_result
903 ));
904 }
905
906 /**
907 * Process all products in bulk sync directly (not via cron)
908 */
909 private function do_bulk_sync_all() {
910 $total = get_option('onwebchat_wc_bulk_total', 0);
911 $page = 0;
912 $total_done = 0;
913 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
914
915 // Process all products in batches
916 while (true) {
917 $args = array(
918 'post_type' => 'product',
919 'post_status' => 'publish',
920 'posts_per_page' => $this->batch_size,
921 'paged' => $page + 1,
922 'orderby' => 'ID',
923 'order' => 'ASC',
924 );
925
926 $query = new WP_Query($args);
927
928 if (!$query->have_posts()) {
929 break;
930 }
931
932 // Collect products in this batch
933 $products_batch = array();
934 foreach ($query->posts as $post) {
935 $product = wc_get_product($post->ID);
936 if ($product && !$this->is_product_excluded($product)) {
937 $products_batch[] = $this->prepare_product_data($product);
938 }
939 }
940
941 // Send batch
942 if (!empty($products_batch)) {
943 $result = $this->send_product_batch($products_batch);
944 if ($result && isset($result['stats'])) {
945 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
946 $all_stats['created'] += $result['stats']['created'];
947 $all_stats['updated'] += $result['stats']['updated'];
948 $all_stats['skipped'] += $result['stats']['skipped'];
949 $all_stats['errors'] += $result['stats']['errors'];
950 } else {
951 // Fallback
952 $total_done += count($products_batch);
953 }
954
955 // Update progress after each batch so AJAX polling can see it
956 update_option('onwebchat_wc_bulk_done', $total_done);
957
958 // Wait 4 seconds before next batch
959 sleep(4);
960 }
961
962 wp_reset_postdata();
963 $page++;
964
965 // Safety check - don't loop forever
966 if ($page > 100) {
967 break;
968 }
969 }
970
971 // Send completion notification to Angular dashboard with total stats
972 $this->send_sync_completion_notification($all_stats);
973
974 // Mark sync as complete
975 update_option('onwebchat_wc_bulk_in_progress', false);
976 update_option('onwebchat_wc_bulk_done', $total_done);
977 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
978
979 return array(
980 'done' => $total_done,
981 'total' => $total,
982 'stats' => $all_stats
983 );
984 }
985
986 /**
987 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
988 */
989 public function process_bulk_sync_batch() {
990 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
991
992 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
993 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
994 return;
995 }
996
997 $page = get_option('onwebchat_wc_bulk_page', 0);
998 $done = get_option('onwebchat_wc_bulk_done', 0);
999 $total = get_option('onwebchat_wc_bulk_total', 0);
1000
1001 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1002
1003 // Get batch of products
1004 $args = array(
1005 'post_type' => 'product',
1006 'post_status' => 'publish',
1007 'posts_per_page' => $this->batch_size,
1008 'paged' => $page + 1,
1009 'orderby' => 'ID',
1010 'order' => 'ASC',
1011 );
1012
1013 $query = new WP_Query($args);
1014
1015 if ($query->have_posts()) {
1016 // Collect all products in this batch
1017 $products_batch = array();
1018
1019 foreach ($query->posts as $post) {
1020 $product = wc_get_product($post->ID);
1021 if ($product && !$this->is_product_excluded($product)) {
1022 $products_batch[] = $this->prepare_product_data($product);
1023 }
1024 }
1025
1026 // Send entire batch in one request
1027 $batch_done = 0;
1028 if (!empty($products_batch)) {
1029 $result = $this->send_product_batch($products_batch);
1030 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1031 if ($result && isset($result['stats'])) {
1032 // Count created + updated + skipped as "done"
1033 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1034 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1035 } else {
1036 // Fallback: assume all sent
1037 $batch_done = count($products_batch);
1038 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1039 }
1040 }
1041
1042 // Update progress
1043 $new_done = $done + $batch_done;
1044 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1045 update_option('onwebchat_wc_bulk_page', $page + 1);
1046 update_option('onwebchat_wc_bulk_done', $new_done);
1047
1048 // Check if we've processed all products
1049 if ($new_done >= $total || !$query->have_posts()) {
1050 // Sync complete
1051 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1052 update_option('onwebchat_wc_bulk_in_progress', false);
1053 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1054 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1055 } else {
1056 // Schedule next batch
1057 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1058 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1059 }
1060 } else {
1061 // No more products - sync complete
1062 update_option('onwebchat_wc_bulk_in_progress', false);
1063 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1064 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1065 }
1066
1067 wp_reset_postdata();
1068 }
1069
1070 /**
1071 * AJAX: Regenerate secret (fetch from server)
1072 */
1073 public function ajax_regenerate_secret() {
1074 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1075
1076 if (!current_user_can('manage_options')) {
1077 wp_send_json_error('Insufficient permissions');
1078 }
1079
1080 // Clear cached secret - user will need to re-authenticate
1081 delete_option('onwebchat_wc_sync_secret');
1082
1083 // Clear any authentication error notices
1084 delete_transient('onwebchat_wc_auth_error');
1085
1086 wp_send_json_success(array(
1087 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1088 'needs_reconnect' => true
1089 ));
1090 }
1091
1092 /**
1093 * AJAX: Reset sync status
1094 */
1095 public function ajax_reset_sync_status() {
1096 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1097
1098 if (!current_user_can('manage_options')) {
1099 wp_send_json_error('Insufficient permissions');
1100 }
1101
1102 $total = get_option('onwebchat_wc_bulk_total', 0);
1103
1104 // Mark sync as complete
1105 update_option('onwebchat_wc_bulk_in_progress', false);
1106 update_option('onwebchat_wc_bulk_done', $total);
1107 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1108
1109 wp_send_json_success(array(
1110 'message' => 'Sync status reset successfully'
1111 ));
1112 }
1113
1114 /**
1115 * AJAX handler to get current sync status
1116 */
1117 public function ajax_get_sync_status() {
1118 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1119
1120 if (!current_user_can('manage_options')) {
1121 wp_send_json_error('Insufficient permissions');
1122 }
1123
1124 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1125 $done = get_option('onwebchat_wc_bulk_done', 0);
1126 $total = get_option('onwebchat_wc_bulk_total', 0);
1127
1128 wp_send_json_success(array(
1129 'in_progress' => $in_progress,
1130 'done' => $done,
1131 'total' => $total
1132 ));
1133 }
1134
1135 /**
1136 * AJAX handler to save sync enabled setting
1137 */
1138 public function ajax_save_sync_enabled() {
1139 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1140
1141 if (!current_user_can('manage_options')) {
1142 wp_send_json_error('Insufficient permissions');
1143 }
1144
1145 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1146
1147 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1148
1149 wp_send_json_success(array(
1150 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1151 'enabled' => $sync_enabled
1152 ));
1153 }
1154
1155 /**
1156 * Get sync status for admin display
1157 */
1158 public function get_sync_status() {
1159 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1160 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1161 $done = get_option('onwebchat_wc_bulk_done', 0);
1162 $total = get_option('onwebchat_wc_bulk_total', 0);
1163
1164 return array(
1165 'last_sync' => $last_sync,
1166 'in_progress' => $in_progress,
1167 'done' => $done,
1168 'total' => $total,
1169 );
1170 }
1171
1172 /**
1173 * AJAX: Manually process batch (for debugging)
1174 */
1175 public function ajax_manual_process_batch() {
1176 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1177
1178 if (!current_user_can('manage_options')) {
1179 wp_send_json_error('Insufficient permissions');
1180 }
1181
1182 // Manually trigger the cron job
1183 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1184 $this->process_bulk_sync_batch();
1185
1186 // Return current status
1187 $status = $this->get_sync_status();
1188 wp_send_json_success(array(
1189 'message' => 'Batch processed',
1190 'status' => $status
1191 ));
1192 }
1193 }
1194
1195 // Initialize the sync module
1196 global $onwebchat_wc_sync;
1197 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1198
1199