= 6 && substr($host, -6) === '.local') || (strlen($host) >= 5 && substr($host, -5) === '.test') || (bool) preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/', $host); } /** * Validate API base is http(s) with a host. * * @param string $base URL. * @return bool */ public static function is_valid_api_base($base) { $base = rtrim((string) $base, '/'); if ($base === '') { return false; } $parts = wp_parse_url($base); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return false; } $scheme = strtolower((string) $parts['scheme']); return in_array($scheme, ['http', 'https'], true); } public static function request_args($extra = []) { $local = self::is_local_api_base(); $args = array_merge([ 'timeout' => 20, 'redirection' => 2, // Production: reject unsafe URLs. Local/private hosts need this off for http://127.0.0.1 etc. 'reject_unsafe_urls' => !$local, 'sslverify' => !$local, 'headers' => ['Content-Type' => 'application/json'], ], $extra); return $args; } public static function handshake() { $base = self::base_url(); if (!$base) { return new WP_Error('agenti_no_base', __('API base URL is not set', 'agentinstore')); } if (!self::is_valid_api_base($base)) { return new WP_Error('agenti_bad_base', __('API base URL must be a valid http or https URL', 'agentinstore')); } $body = [ 'store_url' => rtrim(home_url('/'), '/'), 'store_name' => get_bloginfo('name'), 'admin_email' => get_option('admin_email'), 'wordpress_version' => get_bloginfo('version'), 'woocommerce_version' => defined('WC_VERSION') ? WC_VERSION : null, 'plugin_version' => AGENTI_PLUGIN_VERSION, 'public_key' => get_option('agenti_public_key', ''), ]; $response = wp_remote_post( $base . '/v1/integrations/woocommerce/handshake', self::request_args(['body' => wp_json_encode($body)]) ); if (is_wp_error($response)) { $msg = $response->get_error_message(); $hint = self::is_local_api_base($base) ? __('For local WordPress use http://127.0.0.1:8000 (or http://host.docker.internal:8000 if WP runs in Docker). Keep AgentInStore running on port 8000.', 'agentinstore') : __('If the store is live but the API is on your PC, use a public tunnel or deploy the API.', 'agentinstore'); return new WP_Error( 'agenti_connect_failed', sprintf( /* translators: 1: API base URL, 2: hint, 3: error details */ __('Cannot reach AgentInStore at %1$s. %2$s Details: %3$s', 'agentinstore'), $base, $hint, $msg ) ); } $code = wp_remote_retrieve_response_code($response); $data = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400 || !is_array($data)) { return new WP_Error('agenti_handshake_failed', __('Handshake failed', 'agentinstore'), $data); } if (!empty($data['public_key'])) { update_option('agenti_public_key', sanitize_text_field($data['public_key']), false); } if (!empty($data['webhook_secret'])) { update_option('agenti_webhook_secret', sanitize_text_field($data['webhook_secret']), false); } if (!empty($data['connection_id'])) { update_option('agenti_connection_id', intval($data['connection_id']), false); } if (!empty($data['bot_primary_color'])) { $hex = sanitize_hex_color($data['bot_primary_color']); if ($hex) { update_option('agenti_bot_color', $hex); } } $already = (int) get_option('agenti_store_connected', 0) === 1; update_option('agenti_store_connected', 1); update_option('agenti_widget_enabled', 1); // Full catalog sync only on first connect / reconnect after disconnect , not on every Reconnect click. $synced = 0; if (!$already) { // Always background , large catalogs must not block the WP admin request. self::kick_full_sync(false); $synced = 0; $data['sync_queued'] = true; } else { $synced = (int) get_option('agenti_last_full_sync_count', 0); $data['sync_queued'] = false; } $data['synced_count'] = $synced; return $data; } /** * Push widget appearance to AgentInStore dashboard (no catalog sync). */ public static function push_settings() { $base = self::base_url(); $secret = get_option('agenti_webhook_secret', ''); if (!$base || !$secret) { return new WP_Error('agenti_not_connected', __('Not connected , connect first to sync color to the dashboard.', 'agentinstore')); } $color = sanitize_hex_color(get_option('agenti_bot_color', '#0d9488')) ?: '#0d9488'; $response = wp_remote_post( $base . '/v1/integrations/woocommerce/settings', self::request_args([ 'timeout' => 15, 'body' => wp_json_encode([ 'store_url' => rtrim(home_url('/'), '/'), 'webhook_secret' => $secret, 'bot_primary_color' => $color, 'widget_enabled' => (int) get_option('agenti_widget_enabled', 1) === 1, ]), ]) ); if (is_wp_error($response)) { return $response; } $code = wp_remote_retrieve_response_code($response); if ($code >= 400) { return new WP_Error('agenti_settings_failed', __('Could not sync settings to AgentInStore', 'agentinstore'), [ 'status' => $code, 'body' => wp_remote_retrieve_body($response), ]); } $data = json_decode(wp_remote_retrieve_body($response), true); return is_array($data) ? $data : ['ok' => true]; } /** * Sync only products matching the given SKUs (comma / whitespace separated). * * @param string $raw * @return array{ok:int,failed:int,missing:int} */ public static function sync_by_skus($raw) { if (!function_exists('wc_get_product_id_by_sku')) { return ['ok' => 0, 'failed' => 0, 'missing' => 0]; } $parts = preg_split('/[\s,;]+/', (string) $raw); if (!is_array($parts)) { return ['ok' => 0, 'failed' => 0, 'missing' => 0]; } $ok = 0; $failed = 0; $missing = 0; $seen = []; foreach ($parts as $sku) { $sku = trim((string) $sku); if ($sku === '') { continue; } $pid = (int) wc_get_product_id_by_sku($sku); if (!$pid) { $missing++; continue; } if (isset($seen[$pid])) { continue; } $seen[$pid] = true; $res = self::push_product($pid, 'upsert'); $success = ($res === true) || (is_array($res) && !empty($res['ok'])); if ($success) { $ok++; } else { $failed++; } } return ['ok' => $ok, 'failed' => $failed, 'missing' => $missing]; } /** * Start a full catalog sync in the background (Action Scheduler / WP-Cron). * Never blocks the admin request , safe for 1000+ product catalogs. * * @param bool $immediate Deprecated; ignored. Kept for callers. * @return int Always 0 (sync is async). */ public static function kick_full_sync($immediate = false) { // Prefer Action Scheduler when present , do NOT also schedule WP-Cron (double run). if (function_exists('as_enqueue_async_action')) { $already = function_exists('as_has_scheduled_action') && as_has_scheduled_action('agenti_full_catalog_sync', [], 'agentinstore'); if (!$already) { as_enqueue_async_action('agenti_full_catalog_sync', [], 'agentinstore'); } } elseif (!wp_next_scheduled('agenti_full_catalog_sync')) { wp_schedule_single_event(time() + 1, 'agenti_full_catalog_sync'); } if (function_exists('spawn_cron')) { spawn_cron(time()); } return 0; } /** * Paginate through the full WooCommerce catalog. * @param int $batch Page size (max 100 per Woo request). * @return int Catalog size reported by AgentInStore (products_used), or local accepted count. */ public static function sync_all_products($batch = 100) { if (function_exists('set_time_limit')) { @set_time_limit(0); } if (function_exists('ignore_user_abort')) { @ignore_user_abort(true); } if (!function_exists('wc_get_products')) { update_option('agenti_last_full_sync_at', gmdate('c')); update_option('agenti_last_full_sync_count', 0); update_option('agenti_last_full_sync_skipped', 0); return 0; } $batch = max(10, min((int) $batch, 100)); $page = 1; $count = 0; $failed = 0; $skipped = 0; $max_pages = 500; // safety: up to 50k products $catalog_used = null; $catalog_max = null; while ($page <= $max_pages) { $ids = wc_get_products([ 'limit' => $batch, 'page' => $page, 'status' => ['publish', 'private'], 'return' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', ]); if (!is_array($ids) || empty($ids)) { break; } foreach ($ids as $pid) { $result = self::push_product((int) $pid, 'upsert'); if (is_array($result)) { if (!empty($result['ok'])) { $count++; } elseif (!empty($result['skipped_limit'])) { $skipped++; } else { $failed++; } if (isset($result['products_used'])) { $catalog_used = (int) $result['products_used']; } if (isset($result['products_max'])) { $catalog_max = (int) $result['products_max']; } } elseif ($result === true) { $count++; } elseif ($result === 'skipped_limit') { // Do NOT stop , later products may already exist and still need updates/thumbnails. $skipped++; } else { $failed++; } } if (count($ids) < $batch) { break; } $page++; } // Source of truth: AgentInStore catalog size (matches admin panel). $stats = self::refresh_catalog_stats(); if (is_array($stats) && isset($stats['products_used'])) { $catalog_used = (int) $stats['products_used']; if (isset($stats['products_max'])) { $catalog_max = (int) $stats['products_max']; } } $display = $catalog_used !== null ? (int) $catalog_used : (int) $count; update_option('agenti_last_full_sync_at', gmdate('c')); update_option('agenti_last_full_sync_count', $display); update_option('agenti_last_full_sync_failed', $failed); update_option('agenti_last_full_sync_skipped', $skipped); if ($catalog_used !== null) { update_option('agenti_catalog_used', (int) $catalog_used); } if ($catalog_max !== null) { update_option('agenti_catalog_max', (int) $catalog_max); } return $display; } /** * Ask AgentInStore for current catalog used/max (same numbers as the admin panel). * * @return array|null */ public static function refresh_catalog_stats() { $base = self::base_url(); $secret = get_option('agenti_webhook_secret', ''); if (!$base || !$secret) { return null; } $response = wp_remote_post( $base . '/v1/integrations/woocommerce/heartbeat', self::request_args([ 'timeout' => 15, 'blocking' => true, 'body' => wp_json_encode([ 'store_url' => rtrim(home_url('/'), '/'), 'webhook_secret' => $secret, 'plugin_version' => AGENTI_PLUGIN_VERSION, ]), ]) ); if (is_wp_error($response)) { return null; } $code = (int) wp_remote_retrieve_response_code($response); $data = json_decode((string) wp_remote_retrieve_body($response), true); if ($code >= 400 || !is_array($data)) { return null; } if (isset($data['products_used'])) { update_option('agenti_catalog_used', (int) $data['products_used']); update_option('agenti_last_full_sync_count', (int) $data['products_used']); } if (isset($data['products_max'])) { update_option('agenti_catalog_max', (int) $data['products_max']); } return $data; } /** * Ping AgentInStore. If the dashboard disconnected us (404/409), clear local Linked state. * Returns true when still linked, false when cleared / not connected. */ public static function heartbeat($blocking = false) { if (!(int) get_option('agenti_store_connected', 0)) { return false; } $base = self::base_url(); $secret = get_option('agenti_webhook_secret', ''); if (!$base || !$secret) { return false; } $response = wp_remote_post( $base . '/v1/integrations/woocommerce/heartbeat', self::request_args([ 'timeout' => 15, 'blocking' => $blocking ? true : false, 'body' => wp_json_encode([ 'store_url' => rtrim(home_url('/'), '/'), 'webhook_secret' => $secret, 'plugin_version' => AGENTI_PLUGIN_VERSION, ]), ]) ); if (!$blocking) { return true; } if (is_wp_error($response)) { return true; // network blip , keep local Linked until we know otherwise } $code = (int) wp_remote_retrieve_response_code($response); // Dashboard disconnected: auth index gone or status not active. if (in_array($code, [404, 409, 401], true)) { self::clear_local_connection(); return false; } if ($code < 400) { $data = json_decode((string) wp_remote_retrieve_body($response), true); if (is_array($data)) { if (isset($data['products_used'])) { update_option('agenti_catalog_used', (int) $data['products_used']); update_option('agenti_last_full_sync_count', (int) $data['products_used']); } if (isset($data['products_max'])) { update_option('agenti_catalog_max', (int) $data['products_max']); } // Panel cannot load private *.test image URLs , re-push with cached thumbnails. if (!empty($data['needs_image_refresh'])) { $last = (int) get_option('agenti_last_image_refresh_at', 0); if ((time() - $last) > 120) { update_option('agenti_last_image_refresh_at', time(), false); self::kick_full_sync(false); } } } } return $code < 400; } /** * Clear local Linked flag without calling AgentInStore disconnect again. */ public static function clear_local_connection() { update_option('agenti_store_connected', 0); delete_option('agenti_webhook_secret'); delete_option('agenti_connection_id'); delete_option('agenti_public_key'); delete_option('agenti_api_base'); update_option('agenti_widget_enabled', 0); } public static function disconnect() { $base = self::base_url(); $secret = get_option('agenti_webhook_secret', ''); if ($base && $secret) { wp_remote_post( $base . '/v1/integrations/woocommerce/disconnect', self::request_args([ 'timeout' => 15, 'blocking' => true, 'body' => wp_json_encode([ 'store_url' => rtrim(home_url('/'), '/'), 'webhook_secret' => $secret, ]), ]) ); } self::clear_local_connection(); } public static function push_product($product_id, $action = 'upsert') { if (!(int) get_option('agenti_store_connected', 0)) { return false; } $base = self::base_url(); $secret = get_option('agenti_webhook_secret', ''); if (!$base || !$secret || !function_exists('wc_get_product')) { return false; } $product = wc_get_product($product_id); if (!$product && $action !== 'deleted') { return false; } // Always sync the parent variable product (variations nest under it). if ($product && $product->is_type('variation')) { $parent_id = (int) $product->get_parent_id(); if ($parent_id) { return self::push_product($parent_id, $action === 'deleted' ? 'upsert' : $action); } } $images = []; $categories = []; $payload = [ 'id' => (int) $product_id, 'action' => $action, ]; if ($product) { $image_id = $product->get_image_id(); if ($image_id) { $src = wp_get_attachment_url($image_id); if ($src) { $images[] = ['src' => $src]; } } foreach ($product->get_gallery_image_ids() as $gid) { $src = wp_get_attachment_url($gid); if ($src) { $images[] = ['src' => $src]; } } $terms = get_the_terms($product_id, 'product_cat'); if (is_array($terms)) { foreach ($terms as $term) { $categories[] = ['name' => $term->name, 'slug' => $term->slug]; } } $variations = self::export_variations($product); // Parent may have no featured image , seed from variation photos so chat cards aren't blank. if (empty($images) && is_array($variations)) { foreach ($variations as $var) { if (!empty($var['image'])) { $images[] = ['src' => $var['image']]; } } } $payload = [ 'id' => $product->get_id(), 'type' => $product->get_type(), 'name' => $product->get_name(), 'status' => $action === 'deleted' ? 'trash' : $product->get_status(), 'description' => $product->get_description(), 'short_description' => $product->get_short_description(), 'sku' => $product->get_sku(), 'price' => $product->get_price(), 'regular_price' => $product->get_regular_price(), 'sale_price' => $product->get_sale_price(), 'stock_status' => $product->get_stock_status(), 'stock_quantity' => $product->get_stock_quantity(), 'permalink' => get_permalink($product->get_id()), 'images' => $images, 'categories' => $categories, 'attributes' => self::export_attributes($product), 'variations' => $variations, 'reviews' => self::export_reviews($product->get_id()), 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD', 'action' => $action, ]; $thumb = self::export_thumbnail_b64($product); if (is_array($thumb) && !empty($thumb['b64'])) { $payload['thumbnail_b64'] = $thumb['b64']; $payload['thumbnail_mime'] = $thumb['mime']; } } else { $payload['status'] = 'trash'; $payload['name'] = ''; } $body = wp_json_encode($payload); $sig = hash_hmac('sha256', $body, $secret); $response = wp_remote_post( $base . '/v1/integrations/woocommerce/webhook', self::request_args([ 'timeout' => 45, 'headers' => [ 'Content-Type' => 'application/json', 'X-CA-Webhook-Secret' => $secret, 'X-CA-Signature' => $sig, 'X-WC-Webhook-Topic' => $action === 'deleted' ? 'product.deleted' : 'product.updated', ], 'body' => $body, ]) ); if (is_wp_error($response)) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('AgentInStore push_product error: ' . $response->get_error_message()); } return false; } $code = wp_remote_retrieve_response_code($response); $raw = (string) wp_remote_retrieve_body($response); $json = json_decode($raw, true); if (is_array($json)) { if (isset($json['products_used'])) { update_option('agenti_catalog_used', (int) $json['products_used']); update_option('agenti_last_full_sync_count', (int) $json['products_used']); } elseif (isset($json['used'])) { update_option('agenti_catalog_used', (int) $json['used']); update_option('agenti_last_full_sync_count', (int) $json['used']); } if (isset($json['products_max'])) { update_option('agenti_catalog_max', (int) $json['products_max']); } elseif (isset($json['limit'])) { update_option('agenti_catalog_max', (int) $json['limit']); } } if (is_array($json) && isset($json['action']) && $json['action'] === 'skipped_limit') { return [ 'ok' => false, 'skipped_limit' => true, 'products_used' => isset($json['products_used']) ? (int) $json['products_used'] : (isset($json['used']) ? (int) $json['used'] : null), 'products_max' => isset($json['products_max']) ? (int) $json['products_max'] : (isset($json['limit']) ? (int) $json['limit'] : null), ]; } if (is_array($json) && array_key_exists('ok', $json) && !$json['ok']) { return false; } if ($code >= 400) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('AgentInStore push_product HTTP ' . $code . ': ' . $raw); } // 402 = plan product limit , treated as skipped_limit above when body parses; // keep a dedicated signal for older AgentInStore builds that only return 402. if ((int) $code === 402) { return 'skipped_limit'; } return false; } return [ 'ok' => true, 'products_used' => is_array($json) && isset($json['products_used']) ? (int) $json['products_used'] : (is_array($json) && isset($json['used']) ? (int) $json['used'] : null), 'products_max' => is_array($json) && isset($json['products_max']) ? (int) $json['products_max'] : (is_array($json) && isset($json['limit']) ? (int) $json['limit'] : null), ]; } /** * Small local thumbnail as base64 so AgentInStore can show images without fetching *.test URLs. * * @param WC_Product $product * @return array{b64:string,mime:string}|null */ public static function export_thumbnail_b64($product) { if (!$product || !method_exists($product, 'get_image_id')) { return null; } $image_id = (int) $product->get_image_id(); if (!$image_id) { $gallery = method_exists($product, 'get_gallery_image_ids') ? $product->get_gallery_image_ids() : []; if (is_array($gallery) && !empty($gallery[0])) { $image_id = (int) $gallery[0]; } } if (!$image_id && method_exists($product, 'is_type') && $product->is_type('variable') && method_exists($product, 'get_children')) { foreach ($product->get_children() as $vid) { $v = wc_get_product((int) $vid); if ($v && method_exists($v, 'get_image_id') && $v->get_image_id()) { $image_id = (int) $v->get_image_id(); break; } } } if (!$image_id) { return null; } $src = wp_get_attachment_image_src($image_id, 'woocommerce_gallery_thumbnail'); if (!$src || empty($src[0])) { $src = wp_get_attachment_image_src($image_id, 'thumbnail'); } if (!$src || empty($src[0])) { $src = wp_get_attachment_image_src($image_id, 'medium'); } if (!$src || empty($src[0])) { return null; } $url = $src[0]; $uploads = wp_upload_dir(); $path = ''; if (!empty($uploads['baseurl']) && !empty($uploads['basedir']) && strpos($url, $uploads['baseurl']) === 0) { $path = $uploads['basedir'] . substr($url, strlen($uploads['baseurl'])); } if (!$path || !is_readable($path)) { $path = get_attached_file($image_id); } if (!$path || !is_readable($path)) { return null; } $max_bytes = 200000; $size = @filesize($path); $need_resize = (!$size || $size > $max_bytes); if (!$need_resize) { $bytes = file_get_contents($path); if ($bytes !== false && strlen($bytes) <= $max_bytes) { $mime = 'image/jpeg'; if (function_exists('mime_content_type')) { $detected = @mime_content_type($path); if (is_string($detected) && strpos($detected, 'image/') === 0) { $mime = $detected; } } elseif (function_exists('wp_check_filetype')) { $ft = wp_check_filetype($path); if (!empty($ft['type'])) { $mime = $ft['type']; } } return ['b64' => base64_encode($bytes), 'mime' => $mime]; } $need_resize = true; } if (!function_exists('wp_get_image_editor')) { return null; } $editor = wp_get_image_editor($path); if (is_wp_error($editor)) { return null; } $editor->resize(96, 96, true); $tmp = wp_tempnam('agenti-thumb-'); if (!$tmp) { return null; } $saved = $editor->save($tmp, 'image/jpeg'); if (is_wp_error($saved) || empty($saved['path']) || !is_readable($saved['path'])) { @unlink($tmp); return null; } $bytes = file_get_contents($saved['path']); @unlink($saved['path']); if ($tmp !== $saved['path']) { @unlink($tmp); } if ($bytes === false || strlen($bytes) > $max_bytes) { return null; } return ['b64' => base64_encode($bytes), 'mime' => 'image/jpeg']; } /** * Approved WooCommerce product reviews for the agent. * * @param int $product_id * @return array */ public static function export_reviews($product_id) { $product_id = (int) $product_id; if (!$product_id) { return []; } $comments = get_comments([ 'post_id' => $product_id, 'status' => 'approve', 'type' => 'review', 'number' => 10, 'orderby' => 'comment_date_gmt', 'order' => 'DESC', ]); if (!is_array($comments)) { return []; } $out = []; foreach ($comments as $c) { $rating = (int) get_comment_meta($c->comment_ID, 'rating', true); $text = wp_strip_all_tags($c->comment_content); if ($text === '') { continue; } $out[] = [ 'rating' => $rating ?: null, 'reviewer' => $c->comment_author ?: 'Customer', 'review' => $text, 'date' => $c->comment_date, ]; } return $out; } /** * Parent product attributes with human-readable option labels. * * @param WC_Product $product * @return array */ public static function export_attributes($product) { $attrs = []; foreach ($product->get_attributes() as $key => $attr) { if (!is_object($attr) || !method_exists($attr, 'get_name')) { continue; } $name = $attr->get_name(); $label = function_exists('wc_attribute_label') ? wc_attribute_label($name) : $name; $options = []; $raw = method_exists($attr, 'get_options') ? $attr->get_options() : []; if (!is_array($raw)) { $raw = [$raw]; } $is_taxonomy = method_exists($attr, 'is_taxonomy') && $attr->is_taxonomy(); foreach ($raw as $opt) { if ($is_taxonomy && is_numeric($opt)) { $term = get_term((int) $opt); $options[] = ($term && !is_wp_error($term)) ? $term->name : (string) $opt; } else { $options[] = (string) $opt; } } $attrs[] = [ 'name' => $label, 'slug' => $name, 'options' => $options, 'variation' => method_exists($attr, 'get_variation') ? (bool) $attr->get_variation() : false, ]; } return $attrs; } /** * Full variation rows for variable products (id, price, stock, attribute map). * * @param WC_Product $product * @return array */ public static function export_variations($product) { if (!$product->is_type('variable')) { return []; } $out = []; $children = $product->get_children(); if (!is_array($children)) { return []; } foreach ($children as $vid) { $variation = wc_get_product((int) $vid); if (!$variation || $variation->get_status() === 'trash') { continue; } $var_attrs = []; foreach ($variation->get_attributes() as $taxonomy => $value) { if ($value === '' || $value === null) { continue; } $label = function_exists('wc_attribute_label') ? wc_attribute_label($taxonomy) : $taxonomy; $display = $value; if (taxonomy_exists($taxonomy)) { $term = get_term_by('slug', $value, $taxonomy); if ($term && !is_wp_error($term)) { $display = $term->name; } } else { $display = rawurldecode(str_replace('-', ' ', (string) $value)); } $var_attrs[$label] = $display; } $img = ''; $image_id = $variation->get_image_id(); if ($image_id) { $src = wp_get_attachment_url($image_id); if ($src) { $img = $src; } } $out[] = [ 'id' => (int) $variation->get_id(), 'sku' => $variation->get_sku(), 'price' => $variation->get_price(), 'regular_price' => $variation->get_regular_price(), 'sale_price' => $variation->get_sale_price(), 'stock_status' => $variation->get_stock_status(), 'stock_quantity' => $variation->get_stock_quantity(), 'attributes' => $var_attrs, 'image' => $img, 'permalink' => $variation->get_permalink(), 'status' => $variation->get_status(), ]; } return $out; } }