'POST', 'permission_callback' => [__CLASS__, 'verify'], 'callback' => [__CLASS__, 'execute'], ]); }); } public static function verify($request) { if (!class_exists('WooCommerce') || !function_exists('aismart_content_chat_api_request')) { return new WP_Error('telewhats_catalog_unavailable', 'Connect AISmart Content Chat and WooCommerce first.', ['status' => 409]); } $token = (string) $request->get_param('command'); if ($token === '' || strlen($token) > 1500000) { return new WP_Error('telewhats_catalog_invalid', 'Catalog command is invalid.', ['status' => 403]); } $verified = aismart_content_chat_api_request('POST', 'api/integrations/webchat/wordpress/commerce/catalog/verify', ['command' => $token]); if (is_wp_error($verified)) return $verified; if (!is_array($verified) || !preg_match('/^[a-f0-9-]{36}$/', (string) ($verified['id'] ?? '')) || !in_array($verified['kind'] ?? '', ['list', 'read', 'update', 'delete', 'export'], true)) { return new WP_Error('telewhats_catalog_invalid', 'Catalog command could not be verified.', ['status' => 403]); } $settings = AISmart_Content_Commerce_Plugin::settings(); $read = in_array($verified['kind'], ['list', 'read'], true); if (empty($settings[$read ? 'catalog_sync' : 'remote_catalog_management'])) { return new WP_Error('telewhats_catalog_disabled', $read ? 'Catalog synchronization is disabled.' : 'The store manager must enable remote catalog management.', ['status' => 403]); } self::$commands[$token] = $verified; return true; } private static function snapshot($product) { return AISmart_Content_Commerce_Plugin::catalog_product($product) + [ 'currency' => get_woocommerce_currency(), 'siteUrl' => home_url('/'), ]; } public static function execute($request) { $command = self::$commands[(string) $request->get_param('command')] ?? null; if (!$command) return new WP_Error('telewhats_catalog_invalid', 'Catalog command was not verified.', ['status' => 403]); if ($command['kind'] === 'list') { $page = max(1, (int) ($command['page'] ?? 1)); $result = wc_get_products(['limit' => 50, 'page' => $page, 'paginate' => true, 'orderby' => 'ID', 'order' => 'ASC', 'status' => ['publish', 'draft', 'pending', 'private', 'future']]); return ['items' => array_map([__CLASS__, 'snapshot'], $result->products), 'hasNextPage' => $page < $result->max_num_pages, 'nextPage' => $page + 1]; } $product = !empty($command['externalProductId']) ? wc_get_product((int) $command['externalProductId']) : false; if ($product && ($product->is_type('variation') || $product->get_status() === 'trash')) $product = false; if ($command['kind'] === 'read') return ['product' => $product ? self::snapshot($product) : null]; global $wpdb; $identity = $command['kind'] === 'export' ? 'export:' . $command['exportKey'] : 'product:' . $command['externalProductId']; $lock = 'twc:' . substr(hash('sha256', $wpdb->prefix . $identity), 0, 60); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- MySQL named lock; WordPress has no API for it. if ((string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock)) !== '1') { return new WP_Error('telewhats_catalog_running', 'Another command for this product is still running. Refresh shortly.', ['status' => 503]); } try { return self::execute_write($command); } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the MySQL named lock above. $released = $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); if ((string) $released !== '1') self::log('Could not confirm release of the product operation lock.'); } } private static function execute_write($command) { $key = 'aismart_content_commerce_op_' . $command['id']; $record = get_option($key, null); if (is_array($record) && ($record['status'] ?? '') === 'succeeded') return $record['result']; if ($record === null && !add_option($key, ['status' => 'running', 'started' => time()], '', false)) { return new WP_Error('telewhats_catalog_running', 'This operation was claimed by another request.', ['status' => 409]); } update_option($key, ['status' => 'running', 'started' => time()], false); $may_have_written = false; try { $product = !empty($command['externalProductId']) ? wc_get_product((int) $command['externalProductId']) : false; if ($product && ($product->is_type('variation') || $product->get_status() === 'trash')) $product = false; if ($command['kind'] === 'export') { $may_have_written = true; $result = self::export_product($command); } elseif ($command['kind'] === 'delete') { if ($product) { self::assert_state(self::snapshot($product), $command['sourceBefore'], []); $may_have_written = true; $product->delete(false); clean_post_cache($product->get_id()); $remaining = wc_get_product($product->get_id()); if ($remaining && $remaining->get_status() !== 'trash') throw new RuntimeException('WooCommerce did not confirm source deletion.'); } $result = ['deleted' => true]; } else { if (!$product) throw new DomainException('Source product is no longer available.'); self::assert_state(self::snapshot($product), $command['sourceBefore'], self::resolved_changes($command['changes'])); $may_have_written = true; self::update_product($product, $command['changes']); $result = ['product' => self::snapshot(wc_get_product($product->get_id()))]; self::assert_saved($result['product'], self::resolved_changes($command['changes'])); } $result['operationId'] = $command['id']; update_option($key, ['status' => 'succeeded', 'result' => $result], false); AISmart_Content_Commerce_Plugin::queue_catalog_sync(); return $result; } catch (Throwable $error) { update_option($key, ['status' => 'retry', 'started' => 0, 'error' => $error->getMessage()], false); self::log($error->getMessage()); return new WP_Error('telewhats_catalog_failed', $error->getMessage(), ['status' => !$may_have_written && $error instanceof DomainException ? 409 : 503]); } } // Writes to WooCommerce > Status > Logs (source "aismart-content-commerce"). private static function log($message) { if (function_exists('wc_get_logger')) { wc_get_logger()->warning($message, ['source' => 'aismart-content-commerce']); } } private static function project($product) { $variants = array_map(static fn ($variant) => ['id' => (string) $variant['id'], 'price' => (float) $variant['price'], 'stockQuantity' => isset($variant['stockQuantity']) ? (int) $variant['stockQuantity'] : null], $product['variants'] ?? []); usort($variants, static fn ($left, $right) => strcmp($left['id'], $right['id'])); return ['id' => (string) $product['id'], 'name' => $product['name'], 'price' => (float) $product['price'], 'stockQuantity' => isset($product['stockQuantity']) ? (int) $product['stockQuantity'] : null, 'imageUrl' => $product['imageUrl'] ?? '', 'imageUrls' => $product['imageUrls'] ?? array_values(array_filter([$product['imageUrl'] ?? ''])), 'variants' => $variants]; } private static function assert_state($current, $before, $changes) { $desired = $before; foreach (['name', 'price', 'stockQuantity', 'imageUrl', 'imageUrls', 'stockStatus'] as $field) { if (array_key_exists($field, $changes)) $desired[$field] = $changes[$field]; } if (!empty($changes['imageUrl']) && !array_key_exists('imageUrls', $changes)) { $desired['imageUrls'] = array_values(array_unique(array_merge([$changes['imageUrl']], $before['imageUrls'] ?? array_values(array_filter([$before['imageUrl'] ?? '']))))); } if (count($desired['variants'] ?? []) === 1) { foreach (['price', 'stockQuantity'] as $field) { if (array_key_exists($field, $changes)) $desired['variants'][0][$field] = $changes[$field]; } } foreach ($changes['variants'] ?? [] as $change) { foreach ($desired['variants'] as &$variant) { if ($variant['id'] === $change['id']) $variant = array_merge($variant, $change); } unset($variant); } if (!empty($desired['variants'])) { $desired['price'] = min(array_column($desired['variants'], 'price')); $quantities = array_column($desired['variants'], 'stockQuantity'); $desired['stockQuantity'] = count($quantities) === count($desired['variants']) && !in_array(null, $quantities, true) ? array_sum($quantities) : null; } $compatible = static function ($actual, $old, $target) use (&$compatible) { if ($actual === $old || $actual === $target) return true; if (is_array($actual)) { if (!is_array($old) || count($actual) !== count($old)) return false; foreach ($actual as $key => $value) { if (!array_key_exists($key, $old) || !$compatible($value, $old[$key], $target[$key])) return false; } return true; } return $actual === $old || $actual === $target; }; if (!$compatible(self::project($current), self::project($before), self::project($desired))) { throw new DomainException('The source product changed since import. Refresh and review it before syncing.'); } } private static function existing_image_id($url) { $existing = get_posts(['post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-row lookup by this plugin's own meta key. 'meta_key' => '_telewhats_source_image', 'meta_value' => $url, 'numberposts' => 1]); return $existing ? (int) $existing[0] : 0; } private static function resolved_changes($changes) { if (!empty($changes['imageUrl'])) { $id = self::existing_image_id($changes['imageUrl']); if ($id) $changes['imageUrl'] = wp_get_attachment_image_url($id, 'full') ?: wp_get_attachment_url($id); } return $changes; } private static function image_id($url, $product_id) { if (!wp_http_validate_url($url)) throw new DomainException('Use a public HTTP or HTTPS product image URL.'); $existing = self::existing_image_id($url); if ($existing) return $existing; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $id = media_sideload_image($url, $product_id, null, 'id'); if (is_wp_error($id)) throw new RuntimeException(esc_html($id->get_error_message())); update_post_meta($id, '_telewhats_source_image', $url); return (int) $id; } private static function update_fields($product, $changes) { if (isset($changes['sku'])) $product->set_sku($changes['sku']); if (array_key_exists('price', $changes)) { $product->set_regular_price((string) $changes['price']); $product->set_sale_price(''); $product->set_price((string) $changes['price']); } if (array_key_exists('stockQuantity', $changes)) { $product->set_manage_stock(true); $product->set_stock_quantity((int) $changes['stockQuantity']); $product->set_stock_status($changes['stockQuantity'] > 0 ? 'instock' : 'outofstock'); } if (isset($changes['stockStatus'])) $product->set_stock_status($changes['stockStatus']); } private static function update_product($product, $changes) { if ($product->is_type('variable') && (isset($changes['price']) || isset($changes['stockQuantity']))) { throw new DomainException('Edit each WooCommerce variant price and quantity individually.'); } if (isset($changes['name'])) $product->set_name($changes['name']); if (!empty($changes['imageUrl'])) { $id = self::image_id($changes['imageUrl'], $product->get_id()); $gallery = array_values(array_unique(array_filter(array_merge([$product->get_image_id()], $product->get_gallery_image_ids())))); $product->set_image_id($id); $product->set_gallery_image_ids(array_values(array_diff($gallery, [$id]))); } self::update_fields($product, $changes); foreach ($changes['variants'] ?? [] as $change) { $variant = wc_get_product((int) $change['id']); if (!$variant || !$variant->is_type('variation') || $variant->get_parent_id() !== $product->get_id()) { throw new DomainException('Variant does not belong to this WooCommerce product.'); } self::update_fields($variant, $change); $variant->save(); } $product->save(); if ($product->is_type('variable')) WC_Product_Variable::sync($product->get_id()); wc_delete_product_transients($product->get_id()); } private static function assert_saved($saved, $changes) { foreach (['name', 'price', 'stockQuantity', 'stockStatus', 'imageUrl'] as $field) { if (array_key_exists($field, $changes) && $saved[$field] != $changes[$field]) { throw new RuntimeException('WooCommerce has not confirmed the updated ' . esc_html($field) . '.'); } } foreach ($changes['variants'] ?? [] as $change) { $variant = current(array_filter($saved['variants'], static fn ($row) => $row['id'] === $change['id'])); if (!$variant) throw new RuntimeException('WooCommerce has not confirmed an updated variant.'); self::assert_saved($variant, array_diff_key($change, ['id' => true])); } } private static function export_product($command) { $native = $command['native']; if (empty($native['variants']) || count($native['variants']) > 2048) throw new DomainException('Export requires 1-2048 explicit variants.'); if ($native['currency'] !== get_woocommerce_currency()) throw new DomainException('Destination currency changed. Refresh the destination picker.'); $key = sanitize_key($command['exportKey']); $ids = get_posts(['post_type' => 'product', 'post_status' => ['publish', 'draft', 'pending', 'private', 'future', 'trash'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- two-row lookup by this plugin's own meta key. 'fields' => 'ids', 'numberposts' => 2, 'meta_key' => '_telewhats_export_key', 'meta_value' => $key]); if (count($ids) > 1) throw new DomainException('Multiple destination copies use this export identity. Review them before syncing.'); $existing = $ids ? wc_get_product($ids[0]) : false; if ($existing && $existing->get_status() === 'trash') throw new DomainException('The destination copy is in Trash. Restore it before resuming sync.'); $multiple = count($native['variants']) > 1; $hash = hash('sha256', wp_json_encode($native)); $receipt = $existing ? $existing->get_meta('_telewhats_export_operation', true) : null; if ($existing) { if ($existing->is_type('variable') !== $multiple) throw new DomainException('The native variant structure changed. Review the destination product type before syncing.'); if (is_array($receipt) && ($receipt['id'] ?? '') === $command['id'] && ($receipt['hash'] ?? '') === $hash) { if (($receipt['phase'] ?? '') === 'complete') { self::assert_state(self::snapshot($existing), $receipt['before'], []); return self::export_result($existing, $native, empty($command['sourceBefore'])); } self::assert_export_retry($existing, $receipt['before'], $native); } else { if (empty($command['sourceBefore']) || (is_array($receipt) && ($receipt['phase'] ?? '') !== 'complete')) { throw new DomainException('Reconcile the previous export operation before reusing this destination identity.'); } self::assert_state(self::snapshot($existing), $command['sourceBefore'], []); } } $receipt = ['id' => $command['id'], 'hash' => $hash, 'phase' => 'writing', 'before' => $receipt && ($receipt['id'] ?? '') === $command['id'] ? $receipt['before'] : ($command['sourceBefore'] ?? null)]; $product = $multiple ? new WC_Product_Variable($existing ? $existing->get_id() : 0) : new WC_Product_Simple($existing ? $existing->get_id() : 0); if (!$existing) $product->set_status('draft'); $product->set_name($native['name']); $product->set_description(wp_kses_post($native['description'])); $product->update_meta_data('_telewhats_export_key', $key); $product->update_meta_data('_telewhats_export_operation', $receipt); if ($multiple) { $attribute = new WC_Product_Attribute; $attribute->set_name('Variant'); $attribute->set_options(array_map(static fn ($variant) => $variant['name'] . ' [' . $variant['id'] . ']', $native['variants'])); $attribute->set_visible(true); $attribute->set_variation(true); $product->set_attributes([$attribute]); } else { self::update_fields($product, $native['variants'][0]); } $product->save(); if ($multiple) { $wanted_ids = array_map(static fn ($variant) => (string) $variant['id'], $native['variants']); foreach ($product->get_children() as $child_id) { $child = wc_get_product($child_id); if (!$child || $child->get_status() === 'trash') continue; $native_id = (string) $child->get_meta('_telewhats_stock_id', true); if ($native_id === '') throw new DomainException('An unmanaged destination variant must be reviewed before syncing.'); if (!in_array($native_id, $wanted_ids, true)) $child->delete(false); } } $images = array_map(static fn ($url) => self::image_id($url, $product->get_id()), $native['imageUrls']); $product->set_image_id($images[0] ?? 0); $product->set_gallery_image_ids(array_slice($images, 1)); foreach ($native['variants'] as $source) { if (!$multiple) { continue; } $variant_ids = get_posts(['post_type' => 'product_variation', 'post_status' => ['publish', 'private'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- variants of one product, by this plugin's own meta key. 'post_parent' => $product->get_id(), 'meta_key' => '_telewhats_stock_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- same lookup as above. 'meta_value' => (string) $source['id'], 'fields' => 'ids', 'numberposts' => 2]); if (count($variant_ids) > 1) throw new DomainException('A destination variant is mapped more than once.'); $variant = new WC_Product_Variation($variant_ids[0] ?? 0); $variant->set_parent_id($product->get_id()); $variant->set_status('publish'); $variant->set_attributes(['variant' => $source['name'] . ' [' . $source['id'] . ']']); $variant->update_meta_data('_telewhats_stock_id', (string) $source['id']); self::update_fields($variant, $source); $variant->save(); } $product->save(); if ($multiple) WC_Product_Variable::sync($product->get_id()); wc_delete_product_transients($product->get_id()); clean_post_cache($product->get_id()); $result = self::export_result(wc_get_product($product->get_id()), $native, empty($command['sourceBefore'])); $product->update_meta_data('_telewhats_export_operation', array_merge($receipt, ['phase' => 'complete', 'before' => $result['product']])); $product->save_meta_data(); return $result; } private static function assert_export_retry($product, $before, $native) { $current = self::snapshot($product); $wanted = array_column($native['variants'], null, 'id'); if (!$before) { if ($current['name'] !== $native['name'] || $current['status'] !== 'draft') { throw new DomainException('The partial draft changed outside this export operation.'); } if (!$product->is_type('variable') && ($current['price'] != $native['variants'][0]['price'] || $current['stockQuantity'] !== $native['variants'][0]['stockQuantity'])) { throw new DomainException('The partial draft price or stock changed outside this export operation.'); } $before = $current; foreach ($current['variants'] ?? [] as $variant) { $native_id = (string) get_post_meta((int) $variant['id'], '_telewhats_stock_id', true); if (!isset($wanted[$native_id]) || $variant['price'] != $wanted[$native_id]['price'] || $variant['stockQuantity'] !== $wanted[$native_id]['stockQuantity']) { throw new DomainException('A partial destination variant changed outside this export operation.'); } } $expected_image = !empty($native['imageUrls'][0]) ? self::existing_image_id($native['imageUrls'][0]) : 0; if ($product->get_image_id() && (int) $product->get_image_id() !== $expected_image) { throw new DomainException('The partial draft image changed outside this export operation.'); } $expected_images = array_map([__CLASS__, 'existing_image_id'], $native['imageUrls']); $actual_images = array_values(array_filter(array_map('intval', array_merge([$product->get_image_id()], $product->get_gallery_image_ids())))); foreach ($actual_images as $index => $image_id) { if ($image_id !== ($expected_images[$index] ?? null)) { throw new DomainException('The partial draft gallery changed outside this export operation.'); } } } $changes = ['name' => $native['name']]; if (!$product->is_type('variable')) { $changes += ['price' => $native['variants'][0]['price'], 'stockQuantity' => $native['variants'][0]['stockQuantity']]; } if (!empty($native['imageUrls'][0]) && self::existing_image_id($native['imageUrls'][0])) { $changes += self::resolved_changes(['imageUrl' => $native['imageUrls'][0]]); } $image_ids = array_map([__CLASS__, 'existing_image_id'], $native['imageUrls']); if (!in_array(0, $image_ids, true)) $changes['imageUrls'] = array_map(static fn ($id) => wp_get_attachment_image_url($id, 'full') ?: wp_get_attachment_url($id), $image_ids); $old = array_column($before['variants'] ?? [], null, 'id'); foreach ($current['variants'] ?? [] as $variant) { $native_id = (string) get_post_meta((int) $variant['id'], '_telewhats_stock_id', true); if ($native_id === '') throw new DomainException('An unmanaged destination variant must be reviewed before retrying.'); if (!isset($old[$variant['id']])) { if (!isset($wanted[$native_id]) || $variant['price'] != $wanted[$native_id]['price'] || $variant['stockQuantity'] !== $wanted[$native_id]['stockQuantity']) { throw new DomainException('The new destination variant does not match the immutable export intent.'); } $before['variants'][] = $variant; } if (isset($wanted[$native_id])) $changes['variants'][] = ['id' => $variant['id'], 'price' => $wanted[$native_id]['price'], 'stockQuantity' => $wanted[$native_id]['stockQuantity']]; } $present = array_column($current['variants'] ?? [], null, 'id'); $before['variants'] = array_values(array_filter($before['variants'] ?? [], static function ($variant) use ($present, $wanted) { if (isset($present[$variant['id']])) return true; $removed = wc_get_product((int) $variant['id']); if (!$removed || $removed->get_status() !== 'trash' || isset($wanted[(string) $removed->get_meta('_telewhats_stock_id', true)])) { throw new DomainException('An expected destination variant was removed outside this export.'); } return false; })); self::assert_state($current, $before, $changes); } private static function export_result($product, $native, $must_be_draft) { $saved = self::snapshot($product); if ($saved['name'] !== $native['name'] || ($must_be_draft && $saved['status'] !== 'draft')) { throw new RuntimeException('WooCommerce did not confirm the complete draft product.'); } $multiple = count($native['variants']) > 1; if ($multiple && count($saved['variants']) !== count($native['variants'])) { throw new RuntimeException('WooCommerce did not confirm the exact managed variant list.'); } $images = array_map([__CLASS__, 'existing_image_id'], $native['imageUrls']); $actual_images = array_values(array_unique(array_filter(array_map('intval', array_merge([$product->get_image_id()], $product->get_gallery_image_ids()))))); if (in_array(0, $images, true) || $actual_images !== $images) throw new RuntimeException('WooCommerce did not confirm every exported product image.'); $variant_map = []; foreach ($native['variants'] as $wanted) { $remote = $multiple ? current(array_filter($saved['variants'], static fn ($variant) => (string) get_post_meta((int) $variant['id'], '_telewhats_stock_id', true) === (string) $wanted['id'])) : $saved; if (!$remote || $remote['price'] != $wanted['price'] || $remote['stockQuantity'] !== $wanted['stockQuantity']) { throw new RuntimeException('WooCommerce has not confirmed every exported variant, price and quantity.'); } $variant_map[$remote['id']] = (string) $wanted['id']; } return ['product' => $saved, 'variantMap' => $variant_map]; } }