PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.6.0
Live Chat & AI Chatbot – onWebChat v3.6.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 1.0.6 All 48 releases
onwebchat / includes / woocommerce-sync.php

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

1,193 lines 45.2 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
696 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($response_body_raw, 0, 500));
697
698 // Handle specific HTTP status codes
699 if ($status_code === 401) {
700 return array('success' => false, 'error' => 'Invalid email or password');
701 }
702
703 if ($status_code === 403) {
704 return array('success' => false, 'error' => 'You do not have access to this site');
705 }
706
707 // Success case
708 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
709 $secret = isset($body['secret']) ? $body['secret'] : null;
710 if (empty($secret)) {
711 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
712 return array('success' => false, 'error' => 'Server response missing secret');
713 }
714 update_option('onwebchat_wc_sync_secret', $secret);
715 return array('success' => true, 'secret' => $secret);
716 }
717
718 // Extract error message from various possible response formats
719 $error_message = 'Unknown error';
720
721 if (is_array($body)) {
722 // Try different possible error fields
723 if (isset($body['error'])) {
724 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
725 } elseif (isset($body['message'])) {
726 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
727 } elseif (isset($body['errors']) && is_array($body['errors'])) {
728 $error_message = implode(', ', $body['errors']);
729 }
730 } elseif (!empty($response_body_raw)) {
731 // If body is not JSON or empty, use raw response (truncated)
732 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
733 }
734
735 // Include status code in error message if not already included
736 if ($status_code && strpos($error_message, 'HTTP') === false) {
737 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
738 }
739
740 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
741 return array('success' => false, 'error' => $error_message);
742 }
743
744 /**
745 * Send authenticated request with HMAC signature
746 */
747 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
748 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
749 $secret = $this->get_secret(false);
750
751 if (empty($secret)) {
752 if ($product_id) {
753 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
754 }
755 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
756 }
757
758 $chatId = get_option('onwebchat_plugin_option');
759 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
760
761 // Extract key part (before first slash if present) for consistency with server
762 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
763 $chatIdKey = explode('/', $chatId)[0];
764
765 // Generate authentication headers
766 $timestamp = time();
767 $nonce = base64_encode(random_bytes(16));
768 $body_json = wp_json_encode($payload);
769
770 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
771 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
772 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
773 $signature = hash_hmac('sha256', $message, $secret);
774
775 // Send request
776 $request_args = array(
777 'method' => 'POST',
778 'timeout' => 10,
779 'headers' => array(
780 'Content-Type' => 'application/json',
781 'X-OWC-SiteId' => $chatIdKey, // Use key part only
782 'X-OWC-Timestamp' => $timestamp,
783 'X-OWC-Nonce' => $nonce,
784 'X-OWC-Signature' => $signature,
785 ),
786 'body' => $body_json,
787 );
788
789 // Disable SSL verification for local dev server
790 if ($this->use_testing_mode) {
791 $request_args['sslverify'] = false;
792 }
793
794 $response = wp_remote_post($endpoint, $request_args);
795
796 // Handle response
797 if (is_wp_error($response)) {
798 $error_message = $response->get_error_message();
799 if ($product_id) {
800 $this->log_error($product_id, $error_message);
801 }
802 return array('success' => false, 'error' => $error_message);
803 }
804
805 $status_code = wp_remote_retrieve_response_code($response);
806
807 // Success
808 if ($status_code >= 200 && $status_code < 300) {
809 return array('success' => true);
810 }
811
812 // If authentication failed (401), the secret may be invalid
813 if ($status_code === 401) {
814 // Clear the invalid secret
815 delete_option('onwebchat_wc_sync_secret');
816
817 if ($product_id) {
818 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
819 }
820 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
821 }
822
823 // Error
824 $error_body = wp_remote_retrieve_body($response);
825 if ($product_id) {
826 $this->log_error($product_id, "HTTP $status_code: $error_body");
827 }
828
829 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
830 }
831
832 /**
833 * Log sync error to product meta
834 */
835 private function log_error($product_id, $error_message) {
836 update_post_meta($product_id, '_onwebchat_sync_error', array(
837 'message' => $error_message,
838 'timestamp' => current_time('timestamp')
839 ));
840 }
841
842 /**
843 * AJAX: Start bulk sync
844 */
845 public function ajax_sync_existing_products() {
846 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
847
848 if (!current_user_can('manage_options')) {
849 wp_send_json_error('Insufficient permissions');
850 }
851
852 // Check if sync is already in progress
853 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
854 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
855 }
856
857 // Rate limiting: prevent syncing more than once every 5 minutes
858 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
859 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
860 $time_since_last_sync = time() - $last_sync_time;
861
862 if ($time_since_last_sync < $cooldown_period) {
863 $wait_time = $cooldown_period - $time_since_last_sync;
864 $minutes = ceil($wait_time / 60);
865 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
866 }
867
868 // Store the current sync start time
869 update_option('onwebchat_wc_last_sync_start', time());
870
871 // Reset bulk sync progress
872 update_option('onwebchat_wc_bulk_page', 0);
873 update_option('onwebchat_wc_bulk_done', 0);
874
875 // Count total products
876 $args = array(
877 'post_type' => 'product',
878 'post_status' => 'publish',
879 'posts_per_page' => -1,
880 'fields' => 'ids',
881 );
882
883 $products = get_posts($args);
884 $total = count($products);
885
886 update_option('onwebchat_wc_bulk_total', $total);
887 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
888 update_option('onwebchat_wc_bulk_in_progress', true);
889
890 // Process sync directly instead of using unreliable WP Cron
891 $sync_result = $this->do_bulk_sync_all();
892
893 wp_send_json_success(array(
894 'message' => 'Bulk sync completed',
895 'total' => $total,
896 'result' => $sync_result
897 ));
898 }
899
900 /**
901 * Process all products in bulk sync directly (not via cron)
902 */
903 private function do_bulk_sync_all() {
904 $total = get_option('onwebchat_wc_bulk_total', 0);
905 $page = 0;
906 $total_done = 0;
907 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
908
909 // Process all products in batches
910 while (true) {
911 $args = array(
912 'post_type' => 'product',
913 'post_status' => 'publish',
914 'posts_per_page' => $this->batch_size,
915 'paged' => $page + 1,
916 'orderby' => 'ID',
917 'order' => 'ASC',
918 );
919
920 $query = new WP_Query($args);
921
922 if (!$query->have_posts()) {
923 break;
924 }
925
926 // Collect products in this batch
927 $products_batch = array();
928 foreach ($query->posts as $post) {
929 $product = wc_get_product($post->ID);
930 if ($product && !$this->is_product_excluded($product)) {
931 $products_batch[] = $this->prepare_product_data($product);
932 }
933 }
934
935 // Send batch
936 if (!empty($products_batch)) {
937 $result = $this->send_product_batch($products_batch);
938 if ($result && isset($result['stats'])) {
939 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
940 $all_stats['created'] += $result['stats']['created'];
941 $all_stats['updated'] += $result['stats']['updated'];
942 $all_stats['skipped'] += $result['stats']['skipped'];
943 $all_stats['errors'] += $result['stats']['errors'];
944 } else {
945 // Fallback
946 $total_done += count($products_batch);
947 }
948
949 // Update progress after each batch so AJAX polling can see it
950 update_option('onwebchat_wc_bulk_done', $total_done);
951
952 // Wait 4 seconds before next batch
953 sleep(4);
954 }
955
956 wp_reset_postdata();
957 $page++;
958
959 // Safety check - don't loop forever
960 if ($page > 100) {
961 break;
962 }
963 }
964
965 // Send completion notification to Angular dashboard with total stats
966 $this->send_sync_completion_notification($all_stats);
967
968 // Mark sync as complete
969 update_option('onwebchat_wc_bulk_in_progress', false);
970 update_option('onwebchat_wc_bulk_done', $total_done);
971 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
972
973 return array(
974 'done' => $total_done,
975 'total' => $total,
976 'stats' => $all_stats
977 );
978 }
979
980 /**
981 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
982 */
983 public function process_bulk_sync_batch() {
984 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
985
986 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
987 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
988 return;
989 }
990
991 $page = get_option('onwebchat_wc_bulk_page', 0);
992 $done = get_option('onwebchat_wc_bulk_done', 0);
993 $total = get_option('onwebchat_wc_bulk_total', 0);
994
995 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
996
997 // Get batch of products
998 $args = array(
999 'post_type' => 'product',
1000 'post_status' => 'publish',
1001 'posts_per_page' => $this->batch_size,
1002 'paged' => $page + 1,
1003 'orderby' => 'ID',
1004 'order' => 'ASC',
1005 );
1006
1007 $query = new WP_Query($args);
1008
1009 if ($query->have_posts()) {
1010 // Collect all products in this batch
1011 $products_batch = array();
1012
1013 foreach ($query->posts as $post) {
1014 $product = wc_get_product($post->ID);
1015 if ($product && !$this->is_product_excluded($product)) {
1016 $products_batch[] = $this->prepare_product_data($product);
1017 }
1018 }
1019
1020 // Send entire batch in one request
1021 $batch_done = 0;
1022 if (!empty($products_batch)) {
1023 $result = $this->send_product_batch($products_batch);
1024 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1025 if ($result && isset($result['stats'])) {
1026 // Count created + updated + skipped as "done"
1027 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1028 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1029 } else {
1030 // Fallback: assume all sent
1031 $batch_done = count($products_batch);
1032 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1033 }
1034 }
1035
1036 // Update progress
1037 $new_done = $done + $batch_done;
1038 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1039 update_option('onwebchat_wc_bulk_page', $page + 1);
1040 update_option('onwebchat_wc_bulk_done', $new_done);
1041
1042 // Check if we've processed all products
1043 if ($new_done >= $total || !$query->have_posts()) {
1044 // Sync complete
1045 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1046 update_option('onwebchat_wc_bulk_in_progress', false);
1047 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1048 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1049 } else {
1050 // Schedule next batch
1051 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1052 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1053 }
1054 } else {
1055 // No more products - sync complete
1056 update_option('onwebchat_wc_bulk_in_progress', false);
1057 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1058 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1059 }
1060
1061 wp_reset_postdata();
1062 }
1063
1064 /**
1065 * AJAX: Regenerate secret (fetch from server)
1066 */
1067 public function ajax_regenerate_secret() {
1068 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1069
1070 if (!current_user_can('manage_options')) {
1071 wp_send_json_error('Insufficient permissions');
1072 }
1073
1074 // Clear cached secret - user will need to re-authenticate
1075 delete_option('onwebchat_wc_sync_secret');
1076
1077 // Clear any authentication error notices
1078 delete_transient('onwebchat_wc_auth_error');
1079
1080 wp_send_json_success(array(
1081 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1082 'needs_reconnect' => true
1083 ));
1084 }
1085
1086 /**
1087 * AJAX: Reset sync status
1088 */
1089 public function ajax_reset_sync_status() {
1090 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1091
1092 if (!current_user_can('manage_options')) {
1093 wp_send_json_error('Insufficient permissions');
1094 }
1095
1096 $total = get_option('onwebchat_wc_bulk_total', 0);
1097
1098 // Mark sync as complete
1099 update_option('onwebchat_wc_bulk_in_progress', false);
1100 update_option('onwebchat_wc_bulk_done', $total);
1101 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1102
1103 wp_send_json_success(array(
1104 'message' => 'Sync status reset successfully'
1105 ));
1106 }
1107
1108 /**
1109 * AJAX handler to get current sync status
1110 */
1111 public function ajax_get_sync_status() {
1112 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1113
1114 if (!current_user_can('manage_options')) {
1115 wp_send_json_error('Insufficient permissions');
1116 }
1117
1118 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1119 $done = get_option('onwebchat_wc_bulk_done', 0);
1120 $total = get_option('onwebchat_wc_bulk_total', 0);
1121
1122 wp_send_json_success(array(
1123 'in_progress' => $in_progress,
1124 'done' => $done,
1125 'total' => $total
1126 ));
1127 }
1128
1129 /**
1130 * AJAX handler to save sync enabled setting
1131 */
1132 public function ajax_save_sync_enabled() {
1133 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1134
1135 if (!current_user_can('manage_options')) {
1136 wp_send_json_error('Insufficient permissions');
1137 }
1138
1139 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1140
1141 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1142
1143 wp_send_json_success(array(
1144 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1145 'enabled' => $sync_enabled
1146 ));
1147 }
1148
1149 /**
1150 * Get sync status for admin display
1151 */
1152 public function get_sync_status() {
1153 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1154 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1155 $done = get_option('onwebchat_wc_bulk_done', 0);
1156 $total = get_option('onwebchat_wc_bulk_total', 0);
1157
1158 return array(
1159 'last_sync' => $last_sync,
1160 'in_progress' => $in_progress,
1161 'done' => $done,
1162 'total' => $total,
1163 );
1164 }
1165
1166 /**
1167 * AJAX: Manually process batch (for debugging)
1168 */
1169 public function ajax_manual_process_batch() {
1170 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1171
1172 if (!current_user_can('manage_options')) {
1173 wp_send_json_error('Insufficient permissions');
1174 }
1175
1176 // Manually trigger the cron job
1177 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1178 $this->process_bulk_sync_batch();
1179
1180 // Return current status
1181 $status = $this->get_sync_status();
1182 wp_send_json_success(array(
1183 'message' => 'Batch processed',
1184 'status' => $status
1185 ));
1186 }
1187 }
1188
1189 // Initialize the sync module
1190 global $onwebchat_wc_sync;
1191 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1192
1193