settings = $settings; $this->logger = $logger; } /** * Clone with per-request profile overrides, e.g. a longer timeout for the * one interactive call that provisions server-side (connect/register). * * @param array $opts { timeout?: int, attempts?: int }. * @return self */ public function with_options( array $opts ) { $clone = clone $this; $clone->options = array_merge( $this->options, $opts ); return $clone; } /** * Resolve the timeout/attempts profile for a request. Pure: context in, * profile out. * * Interactive contexts (admin screens, admin-ajax) fail fast with no * retries so a slow or down platform can never hang the admin; background * contexts (cron, webhook-triggered polling, CLI) keep the patient * profile with retries. * * @param bool $interactive Whether the request serves an interactive admin context. * @param array $overrides Per-call overrides { timeout?: int, attempts?: int }. * @return array{timeout:int,attempts:int} */ public static function resolve_profile( $interactive, array $overrides = array() ) { $profile = $interactive ? array( 'timeout' => self::TIMEOUT_INTERACTIVE, 'attempts' => self::ATTEMPTS_INTERACTIVE, ) : array( 'timeout' => self::TIMEOUT, 'attempts' => self::MAX_RETRIES, ); if ( isset( $overrides['timeout'] ) && (int) $overrides['timeout'] > 0 ) { $profile['timeout'] = (int) $overrides['timeout']; } if ( isset( $overrides['attempts'] ) && (int) $overrides['attempts'] > 0 ) { $profile['attempts'] = (int) $overrides['attempts']; } return $profile; } /** * Whether the current request is an interactive admin context. * * @return bool */ private static function is_interactive_context() { $doing_cron = function_exists( 'wp_doing_cron' ) && wp_doing_cron(); if ( $doing_cron ) { return false; } if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { return true; } return function_exists( 'is_admin' ) && is_admin(); } /* --------------------------------------------------------------------- */ /* Core: connection + catalog */ /* --------------------------------------------------------------------- */ /** * POST /api/v1/plugin/register * * @param array $args { siteUrl, siteTitle, platform, wpVersion, wcVersion, pluginVersion, webhookUrl }. * @return array|\WP_Error */ public function register( array $args ) { return $this->request( 'POST', '/api/v1/plugin/register', $args ); } /** * POST /api/v1/plugin/sync * * @param string $store_id Store id. * @param array $products Serialized products (<= maxBatchSize). * @return array|\WP_Error */ public function sync_products( $store_id, array $products ) { $body = array( 'storeId' => $store_id, 'products' => array_values( $products ), ); // Store level, so it costs one field per batch rather than one per // product. Until 4.13 nothing sent it and the server defaulted every // WooCommerce catalogue to USD, so a GBP shop was published to shopping // agents priced in dollars. OMITTED rather than sent as null when it // cannot be read: the server treats the key as optional and remembers // the last one it was given, and a null would fail validation for the // whole batch. if ( function_exists( 'get_woocommerce_currency' ) ) { $currency = (string) get_woocommerce_currency(); if ( 3 === strlen( $currency ) ) { $body['currency'] = $currency; } } return $this->request( 'POST', '/api/v1/plugin/sync', $body ); } /** * POST /api/v1/plugin/heartbeat * * @param array $args { storeId, pluginVersion, wpVersion, wcVersion, productCount }. * @return array|\WP_Error */ public function heartbeat( array $args ) { return $this->request( 'POST', '/api/v1/plugin/heartbeat', $args ); } /** * GET /api/v1/plugin/bootstrap — one-call dashboard payload. * * @param string $store_id Store id. * @return array|\WP_Error */ public function bootstrap( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/bootstrap', null, array( 'storeId' => $store_id ) ); } /** * PATCH /api/v1/plugin/content-placement — where IRL cards and internal * links sit on the product page. * * Pushed rather than kept local because this one choice has three editors: * the web app, the embedded Shopify app and this site's settings screen. * The platform holds the value and the hourly heartbeat mirrors it back * down, so a local-only save would be undone within the hour. * * PARTIAL by design: send only the types being changed, and the platform * merges onto what is stored. * * @param string $store_id Rezolve Ai store id. * @param array $placement Map of content type to placement. * @return array|\WP_Error */ public function update_content_placement( $store_id, array $placement, $destinations = null ) { $body = array( 'storeId' => $store_id, 'placement' => $placement, ); // Where each content type is written (4.25.0). Sent only by a screen // that shows the choice, so an older caller never resets it. if ( is_array( $destinations ) && ! empty( $destinations ) ) { $body['destinations'] = $destinations; } return $this->request( 'PATCH', '/api/v1/plugin/content-placement', $body ); } /** * GET /api/v1/plugin/content-placement: placement, destinations, and the * store's fields each content type could be sent to (4.25.0), from the * catalogue the platform has synced. * * @param string $store_id Rezolve Ai store id. * @return array|\WP_Error */ public function content_placement( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/content-placement', null, array( 'storeId' => $store_id ) ); } /** * GET /api/v1/plugin/custom-fields — the store's custom fields, each with a * merchant-facing label, whether it is used, left out or skipped (and why), * and whether it may be rewritten. Shared with the web app and the Shopify * app, so every surface lists the same thing. * * @param string $store_id Rezolve Ai store id. * @return array|\WP_Error */ public function custom_fields( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/custom-fields', null, array( 'storeId' => $store_id ) ); } /** * PATCH /api/v1/plugin/custom-fields — partial: exclude, include, * rewriteOn and rewriteOff, each optional. * * @param string $store_id Rezolve Ai store id. * @param array $patch Change. * @return array|\WP_Error */ public function update_custom_fields( $store_id, array $patch ) { return $this->request( 'PATCH', '/api/v1/plugin/custom-fields', array_merge( array( 'storeId' => $store_id ), $patch ) ); } /** * GET /api/v1/plugin/field-formats (4.28.0): each field Rezolve Ai writes * and the format it is written in. With a target, that one field in full. * * @param string $store_id Rezolve Ai store id. * @param string $target Opaque field target, or '' for the whole list. * @return array|\WP_Error */ public function field_formats( $store_id, $target = '' ) { $query = array( 'storeId' => $store_id ); if ( '' !== $target ) { $query['target'] = $target; } return $this->request( 'GET', '/api/v1/plugin/field-formats', null, $query ); } /** * PATCH /api/v1/plugin/field-formats: one field's choice, partial * (target, mode, preset, locks, fillEmpty). * * @param string $store_id Rezolve Ai store id. * @param array $patch Change. * @return array|\WP_Error */ public function update_field_format( $store_id, array $patch ) { return $this->request( 'PATCH', '/api/v1/plugin/field-formats', array_merge( array( 'storeId' => $store_id ), $patch ) ); } /** * POST /api/v1/plugin/field-formats/preview: a product's current value laid * out in a format. Free: nothing is written. * * @param string $store_id Rezolve Ai store id. * @param array $body target, and optionally mode, preset, locks. * @return array|\WP_Error */ public function preview_field_format( $store_id, array $body ) { return $this->request( 'POST', '/api/v1/plugin/field-formats/preview', array_merge( array( 'storeId' => $store_id ), $body ) ); } /** * POST /api/v1/plugin/field-formats/relearn: learn every format again now. * * @param string $store_id Rezolve Ai store id. * @return array|\WP_Error */ public function relearn_field_formats( $store_id ) { return $this->request( 'POST', '/api/v1/plugin/field-formats/relearn', array( 'storeId' => $store_id ) ); } /** * GET /api/v1/usage — credits + rate limit + job counts. * * @return array|\WP_Error */ public function usage() { return $this->request( 'GET', '/api/v1/usage' ); } /** * GET /api/v1/plugin/content-types — enrichment + article type catalog. * * @param string $store_id Store id. * @return array|\WP_Error */ public function content_types( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/content-types', null, array( 'storeId' => $store_id ) ); } /** * GET /api/v1/plugin/brand — identity + voice summary. * * @param string $store_id Store id. * @return array|\WP_Error */ public function brand( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/brand', null, array( 'storeId' => $store_id ) ); } /* --------------------------------------------------------------------- */ /* Enrichment (durable jobs) */ /* --------------------------------------------------------------------- */ /** * POST /api/v1/enrichment/pipeline with a store source: rules + content for * synced products, as a durable job. Returns the job envelope (202). * * @param string $store_id Store id. * @param array $product_ids Product ids (strings, <= 100). * @param array $options { contentTypes?, config?, targetLanguage? }. * @return array|\WP_Error */ public function enrich( $store_id, array $product_ids, array $options = array() ) { $body = array( 'source' => array( 'type' => 'store', 'storeId' => $store_id, 'productIds' => array_values( array_map( 'strval', $product_ids ) ), ), ); if ( ! empty( $options['contentTypes'] ) ) { $body['contentTypes'] = array_values( $options['contentTypes'] ); } if ( ! empty( $options['config'] ) ) { $body['config'] = $options['config']; } if ( ! empty( $options['targetLanguage'] ) ) { $body['targetLanguage'] = $options['targetLanguage']; } return $this->request( 'POST', '/api/v1/enrichment/pipeline', $body ); } /** * GET /api/v1/jobs/{id} — poll a durable job. * * @param string $job_id Job id. * @return array|\WP_Error */ public function job( $job_id ) { return $this->request( 'GET', '/api/v1/jobs/' . rawurlencode( $job_id ) ); } /** * GET /api/v1/jobs/{id}/results — paginated per-product results. * * @param string $job_id Job id. * @param int $page Page (1-based). * @param int $size Page size. * @return array|\WP_Error */ public function job_results( $job_id, $page = 1, $size = 50 ) { return $this->request( 'GET', '/api/v1/jobs/' . rawurlencode( $job_id ) . '/results', null, array( 'page' => (string) max( 1, (int) $page ), 'pageSize' => (string) max( 1, (int) $size ), ) ); } /* --------------------------------------------------------------------- */ /* Content Hub (articles) */ /* --------------------------------------------------------------------- */ /** * POST /api/v1/plugin/articles/generate — start a durable article job. * * @param array $args { storeId, title, type?, locale?, overview?, keywords?, tone?, wordCount?, productIds?, categoryTarget? }. * @return array|\WP_Error { id (jobId), briefId, status, ... } */ public function articles_generate( array $args ) { return $this->request( 'POST', '/api/v1/plugin/articles/generate', $args ); } /** * GET /api/v1/plugin/articles — list project briefs (includes app-authored). * * @param string $store_id Store id. * @param array $query { status?, search?, page?, pageSize? }. * @return array|\WP_Error List rows; pagination under `_meta`. */ public function articles( $store_id, array $query = array() ) { $query['storeId'] = $store_id; return $this->request( 'GET', '/api/v1/plugin/articles', null, $query ); } /** * GET /api/v1/plugin/articles/{briefId} — full article payload. * * @param string $store_id Store id. * @param string $brief_id Brief id. * @return array|\WP_Error */ public function article( $store_id, $brief_id ) { return $this->request( 'GET', '/api/v1/plugin/articles/' . rawurlencode( $brief_id ), null, array( 'storeId' => $store_id ) ); } /** * POST /api/v1/plugin/articles/{briefId}/published — report the WP post. * * @param string $store_id Store id. * @param string $brief_id Brief id. * @param array $args { url, wpPostId, status }. * @return array|\WP_Error */ public function article_published( $store_id, $brief_id, array $args ) { $args['storeId'] = $store_id; return $this->request( 'POST', '/api/v1/plugin/articles/' . rawurlencode( $brief_id ) . '/published', $args ); } /* --------------------------------------------------------------------- */ /* AI Search */ /* --------------------------------------------------------------------- */ /** * GET /api/v1/plugin/aeo/config — configurator payload. * * @param string $store_id Store id. * @return array|\WP_Error */ public function aeo_config( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/aeo/config', null, array( 'storeId' => $store_id ) ); } /** * POST /api/v1/plugin/aeo/flows — create + link a research flow. * * @param array $args { storeId, name, brandName, description? }. * @return array|\WP_Error */ public function aeo_create_flow( array $args ) { return $this->request( 'POST', '/api/v1/plugin/aeo/flows', $args ); } /** * GET /api/v1/plugin/aeo/prompts — list research prompts. * * @param string $store_id Store id. * @param int $page Page. * @param int $size Page size. * @return array|\WP_Error */ public function aeo_prompts( $store_id, $page = 1, $size = 100 ) { return $this->request( 'GET', '/api/v1/plugin/aeo/prompts', null, array( 'storeId' => $store_id, 'page' => (string) max( 1, (int) $page ), 'pageSize' => (string) max( 1, (int) $size ), ) ); } /** * PUT /api/v1/plugin/aeo/prompts — append or replace the prompt set. * * @param string $store_id Store id. * @param array $prompts [{ text, tags? }]. * @param string $mode append|replace. * @return array|\WP_Error */ public function aeo_save_prompts( $store_id, array $prompts, $mode = 'append' ) { return $this->request( 'PUT', '/api/v1/plugin/aeo/prompts', array( 'storeId' => $store_id, 'prompts' => array_values( $prompts ), 'mode' => $mode, ) ); } /** * GET /api/v1/plugin/aeo/prompts/facets — category + collection pickers. * * @param string $store_id Store id. * @return array|\WP_Error { categories, collections, collectionsSynced }. */ public function aeo_prompt_facets( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/aeo/prompts/facets', null, array( 'storeId' => $store_id ) ); } /** * POST /api/v1/plugin/aeo/prompts/suggest — AI prompt ideas from the catalog. * * @param array $args { storeId, topic?, branded?, regionLabel?, languageLabel?, categories?, collections? }. * @return array|\WP_Error */ public function aeo_suggest_prompts( array $args ) { return $this->request( 'POST', '/api/v1/plugin/aeo/prompts/suggest', $args ); } /** * GET /api/v1/plugin/aeo/runs — list runs (+ snapshot presence). * * @param string $store_id Store id. * @return array|\WP_Error */ public function aeo_runs( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/aeo/runs', null, array( 'storeId' => $store_id ) ); } /** * POST /api/v1/plugin/aeo/runs — launch a run (or estimate it). * * @param array $args { storeId, models?, regionCode?, languageCode?, useFlowDefaults?, estimateOnly? }. * @return array|\WP_Error */ public function aeo_start_run( array $args ) { return $this->request( 'POST', '/api/v1/plugin/aeo/runs', $args ); } /** * GET /api/v1/plugin/aeo/runs/{runId} — snapshot detail / status. * * @param string $store_id Store id. * @param string $run_id Run id. * @return array|\WP_Error */ public function aeo_run( $store_id, $run_id ) { return $this->request( 'GET', '/api/v1/plugin/aeo/runs/' . rawurlencode( $run_id ), null, array( 'storeId' => $store_id ) ); } /** * GET /api/v1/plugin/aeo/overview — KPI band + trend from snapshots. * * @param string $store_id Store id. * @param string|null $run_id Optional run to focus. * @return array|\WP_Error */ public function aeo_overview( $store_id, $run_id = null ) { $query = array( 'storeId' => $store_id ); if ( $run_id ) { $query['runId'] = $run_id; } return $this->request( 'GET', '/api/v1/plugin/aeo/overview', null, $query ); } /* --------------------------------------------------------------------- */ /* Crawler analytics report-back */ /* --------------------------------------------------------------------- */ /** * POST /api/v1/plugin/bot-traffic/ingest — opt-in daily aggregates. * * @param string $store_id Store id. * @param array $days [{ date, bots: [...] }]. * @return array|\WP_Error */ /** * Day-level human pageview aggregates. No visitor identifiers of any kind. * * @param string $store_id Store id. * @param array $days Day payloads. * @return array|\WP_Error */ public function traffic_ingest( $store_id, array $days ) { return $this->request( 'POST', '/api/v1/plugin/traffic/ingest', array( 'storeId' => $store_id, 'days' => array_values( $days ), ) ); } /** * Day-level per-product sales aggregates. No order or customer records. * * @param string $store_id Store id. * @param array $days Day payloads. * @param array $agent Agent-sent day payloads: totals per attribution token. * @param string $currency Store currency code, for the agent totals. * @return array|\WP_Error */ public function sales_ingest( $store_id, array $days, array $agent = array(), $currency = '' ) { $body = array( 'storeId' => $store_id, 'days' => array_values( $days ), ); if ( ! empty( $agent ) ) { $body['agent'] = array( 'currency' => preg_match( '/^[A-Za-z]{3}$/', (string) $currency ) ? strtoupper( $currency ) : null, 'days' => array_values( $agent ), ); } return $this->request( 'POST', '/api/v1/plugin/sales/ingest', $body ); } /** * Which of this store's products are running an A/B test, and their configs. * * The FULL current state, not a changes feed. A product that has dropped off * this list is running no test, and that is the only signal a stopped test * produces -- there is no "experiment ended" event to listen for. * * @param string $store_id Store id. * @return array|\WP_Error */ public function experiment_configs( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/experiments', null, array( 'storeId' => $store_id ) ); } /* --------------------------------------------------------------------- */ /* Experiments */ /* */ /* SIBLING PATHS to /plugin/experiments, which is the config-reconcile */ /* feed this plugin has polled since 4.5.0. That endpoint's contract is */ /* "a product absent from this list is running no test", and it runs */ /* unattended on sites that cannot be force-upgraded, so nothing is added */ /* to its payload. */ /* --------------------------------------------------------------------- */ /** * Every experiment on this site's project. * * @param string $store_id Store id. * @return array|\WP_Error */ public function experiments_list( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/experiments/list', null, array( 'storeId' => $store_id ) ); } /** * Finished tests and what each one found. * * @param string $store_id Store id. * @param array $query Optional verdict, q, page. * @return array|\WP_Error */ public function experiment_history( $store_id, $query = array() ) { $args = array( 'storeId' => $store_id ); foreach ( array( 'verdict', 'q', 'page' ) as $key ) { if ( isset( $query[ $key ] ) && '' !== $query[ $key ] ) { $args[ $key ] = (string) $query[ $key ]; } } return $this->request( 'GET', '/api/v1/plugin/experiments/history', null, $args ); } /** * What testing has been worth: banked value, and running tests kept apart. * * @param string $store_id Store id. * @return array|\WP_Error */ public function experiment_value( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/experiments/value', null, array( 'storeId' => $store_id ) ); } /** * Write what the merchant learned onto one finished test. * * @param string $store_id Store id. * @param string $experiment_id Experiment id. * @param string $notes Their note. Empty clears it. * @return array|\WP_Error */ public function experiment_note( $store_id, $experiment_id, $notes ) { return $this->request( 'PATCH', '/api/v1/plugin/experiments/history', array( 'storeId' => $store_id, 'experimentId' => $experiment_id, 'notes' => $notes, ) ); } /** * One experiment: arms, exposures, results and the health checks. * * @param string $store_id Store id. * @param string $experiment_id Experiment id. * @return array|\WP_Error */ public function experiment( $store_id, $experiment_id ) { return $this->request( 'GET', '/api/v1/plugin/experiments/' . rawurlencode( $experiment_id ), null, array( 'storeId' => $store_id ) ); } /** * Products a test could run over, and the categories to choose between. * * Both in one call: a test needs roughly 150 products behind it, so the * wizard picks a whole category rather than products one at a time. * * @param string $store_id Store id. * @param string|null $category Optional category filter. * @return array|\WP_Error */ public function experiment_subjects( $store_id, $category = null, $include_facets = false ) { $query = array( 'storeId' => $store_id ); if ( is_string( $category ) && '' !== $category ) { $query['category'] = $category; } if ( $include_facets ) { $query['includeFacets'] = '1'; } return $this->request( 'GET', '/api/v1/plugin/experiments/subjects', null, $query ); } /** * Resolve a selection spec to a count, and optionally a page of products. * * POST rather than GET because a selection can carry five hundred * hand-picked ids, which do not fit in a query string. `countOnly` answers * a number and no ids at all: the screen only needs the number while the * merchant is still choosing, and this plugin runs on the merchant's own * server, so five thousand ids crossing the wire on every checkbox is both * slow and a payload the browser could edit. * * @param array $body { storeId, selection, type?, countOnly?, page?, pageSize? }. * @return array|\WP_Error */ public function experiment_resolve_subjects( array $body ) { return $this->request( 'POST', '/api/v1/plugin/experiments/subjects', $body ); } /** * A page of products in the platform's own id space, for hand-picking. * * NOT WordPress post ids and not WooCommerce product ids. The platform keys * experiment units and review items by `products.product_id`, and a * selection carrying anything else produces an experiment whose units match * no content at all, with nothing to say so. * * @param string $store_id Store id. * @param string $query Optional search term. * @param int $page Zero-based page. * @return array|\WP_Error */ public function experiment_products( $store_id, $query = '', $page = 0 ) { $args = array( 'storeId' => $store_id, 'page' => (int) $page, ); if ( is_string( $query ) && '' !== $query ) { $args['q'] = $query; } return $this->request( 'GET', '/api/v1/plugin/experiments/products', null, $args ); } /** * A model proposes a cohort from a sentence the merchant typed. * * ON ITS OWN, LONGER TIMEOUT. This is up to two LLM round trips and the * platform route declares maxDuration = 90. At the interactive default of * twelve seconds every normal call would come back a `WP_Error`, which is * exactly the failure the Shopify client documents for prompt suggestion: * a slow-but-successful generation, already billed, reported to the * merchant as "could not suggest". * * @param array $body { storeId, brief, arms?, holdoutFraction? }. * @return array|\WP_Error */ public function experiment_suggest_subjects( array $body ) { return $this->request( 'POST', '/api/v1/plugin/experiments/suggest-subjects', $body, null, array( 'timeout' => self::TIMEOUT_SUGGEST, 'attempts' => 1, ) ); } /** * The project's saved product selections. * * @param string $store_id Store id. * @return array|\WP_Error */ public function product_groups( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/product-groups', null, array( 'storeId' => $store_id ) ); } /** * Save a selection as a named, reusable group. * * @param array $body { storeId, name, description?, selection }. * @return array|\WP_Error */ public function save_product_group( array $body ) { return $this->request( 'POST', '/api/v1/plugin/product-groups', $body ); } /** * Delete a saved group. * * Deleting one does NOT touch a running experiment: its products were * frozen into experiment units when it started, and nothing re-resolves * them. The screen says so, or a merchant will believe otherwise. * * @param string $store_id Store id. * @param string $group_id Group id. * @return array|\WP_Error */ public function delete_product_group( $store_id, $group_id ) { return $this->request( 'DELETE', '/api/v1/plugin/product-groups/' . rawurlencode( $group_id ), null, array( 'storeId' => $store_id ) ); } /** * What the create form may offer: the content each split can test, the * versions, and the goals. * * Asked for rather than kept here. This plugin used to carry its own goal * list and test only the description, and a list on a merchant's server * changes only when they update the plugin. * * @param string $store_id Store id. * @return array|\WP_Error */ public function experiment_options( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/experiments/options', null, array( 'storeId' => $store_id ) ); } /** * The costless power check. Spends nothing and creates nothing. * * @param array $body Design and product set. * @return array|\WP_Error */ public function experiment_preflight( array $body ) { return $this->request( 'POST', '/api/v1/plugin/experiments/preflight', $body ); } /** * Create an experiment and start the run that writes its versions. * * THIS SPENDS CREDITS, which is why it is the one experiment call on the * background profile: a slow success reported as a failure would leave the * merchant with an experiment they cannot see and a retry the one-split * guard refuses. * * @param array $body Design, products, goal. * @return array|\WP_Error */ public function experiment_create( array $body ) { return $this->request( 'POST', '/api/v1/plugin/experiments/create', $body, null, array( 'timeout' => self::TIMEOUT, 'attempts' => 1, ) ); } /** * Start, pause, resume, stop, abandon or rename. * * @param string $store_id Store id. * @param string $experiment_id Experiment id. * @param string $action Lifecycle action. * @param array $extra Extra fields (rename carries a name). * @return array|\WP_Error */ public function experiment_action( $store_id, $experiment_id, $action, array $extra = array() ) { return $this->request( 'PATCH', '/api/v1/plugin/experiments/' . rawurlencode( $experiment_id ), array_merge( array( 'storeId' => $store_id, 'action' => $action, ), $extra ) ); } /** * Keep the winning version for every shopper, and finish the test. * * A separate endpoint rather than a sixth action, because it does not only * change a status: it republishes the winner's copy to this site's own * products. Rezolve Ai recomputes the verdict and refuses if the evidence no * longer supports the arm named here, so this plugin never has to decide. * * @param string $store_id Store id. * @param string $experiment_id Experiment id. * @param string $arm The winning arm key. * @return array|\WP_Error */ public function experiment_apply_winner( $store_id, $experiment_id, $arm ) { return $this->request( 'POST', '/api/v1/plugin/experiments/' . rawurlencode( $experiment_id ) . '/apply-winner', array( 'storeId' => $store_id, 'arm' => $arm, ) ); } /** * Content Rezolve Ai has approved and is waiting for this site to apply. * * The same endpoint the Shopify app pulls. Entries are per FIELD, not per * product, and the A/B config rides the same batch as the challenger copy * that makes a test possible -- which is why it can only ever be emitted * once, alongside that copy. * * @param string $store_id Store id. * @return array|\WP_Error */ public function apply_queue( $store_id ) { return $this->request( 'GET', '/api/v1/plugin/apply-queue', null, array( 'storeId' => $store_id ) ); } /** * Report what landed. * * This is the half that was missing until 4.6.0. An "applied" result is * what stamps `pushed_at` on the Rezolve Ai side, which is what removes the entry * from the next pull, what makes a version count as live for an A/B test, * and what puts the push on the merchant's Published screen. * * @param string $store_id Store id. * @param array $results [{ id, status: applied|failed, error? }]. * @return array|\WP_Error */ public function apply_queue_ack( $store_id, array $results ) { return $this->request( 'POST', '/api/v1/plugin/apply-queue/ack', array( 'storeId' => $store_id, 'results' => array_values( $results ), ) ); } /** * Report what this site's merchant decided about proposed content. * * THE DIRECTION THE ACK DOES NOT COVER. `apply_queue_ack()` above answers * "did the write land on the store" for content Rezolve Ai had already * approved. It has nothing to say about a decision made HERE: a dismissal * has no push at all, and an approval made on this plugin's Review screen was * never told to Rezolve Ai in any form. That is why the web review queue kept * listing products this site had already dealt with. * * Platform-agnostic on the server: the same endpoint serves the embedded * Shopify app, and the destination recorded for any push is derived from the * store's platform rather than assumed. * * @param string $store_id Rezolve Ai store id. * @param array $decisions List of { productId, field, status, decidedAt?, error? }. * @return array|\WP_Error */ public function review_decisions( $store_id, array $decisions ) { return $this->request( 'POST', '/api/v1/plugin/review-decisions', array( 'storeId' => $store_id, 'decisions' => array_values( $decisions ), ) ); } /** * Which of the proposals this site is about to apply belong to products a * product split is holding back (4.29.0). See {@see Held_Back}. * * POST although it changes nothing: a batch asks about up to 500 items. An * empty `heldBack` means nothing is held; Rezolve Ai answers 503 when it * could not tell, never an empty list. * * @param string $store_id Rezolve Ai store id. * @param array $items List of { productId, field } or { productId, contentType }. * @return array|\WP_Error { heldBack: [{ productId, field, contentType, experimentName, releaseAt }] }. */ public function held_back( $store_id, array $items ) { return $this->request( 'POST', '/api/v1/plugin/held-back', array( 'storeId' => $store_id, 'items' => array_values( $items ), ) ); } /** * The quality scores Rezolve Ai has measured for this site's proposals. * * POST although it changes nothing: a review page asks about up to 200 * (product, field) pairs at once, which does not fit a query string. The * response is one entry per request item, in request order, and every * number in it is a measurement or null. See {@see Quality}. * * @param string $store_id Rezolve Ai store id. * @param array $items List of { productId, field, jobId? }, at most 200. * @return array|\WP_Error { items: entry[] }. */ public function quality( $store_id, array $items ) { return $this->request( 'POST', '/api/v1/plugin/quality', array( 'storeId' => $store_id, 'items' => array_values( $items ), ) ); } /** * Ask Rezolve Ai to score content again: the Review screen's Re-score button. * * Queued, not answered inline. A score is a 10 to 20 second AI call, so the * 202 only says what was queued, and the caller reads `quality()` again until * `scoredAt` moves. Capped at 25 items by the platform, since each is a paid * call. * * @param string $store_id Rezolve Ai store id. * @param array $items List of { productId, field, jobId? }, at most 25. * @return array|\WP_Error { queued, items: entry[] }. */ public function quality_rescore( $store_id, array $items ) { return $this->request( 'POST', '/api/v1/plugin/quality/rescore', array( 'storeId' => $store_id, 'items' => array_values( $items ), ) ); } /** * Per-visitor A/B exposures. * * The ONLY payload this client sends that carries a per-visitor value, and * it is a salted hash computed on the site, not the cookie itself. Every * other ingest here is day-level aggregate. * * @param string $store_id Store id. * @param array $events Exposure and conversion events. * @return array|\WP_Error */ public function ab_events_ingest( $store_id, array $events ) { return $this->request( 'POST', '/api/v1/plugin/ab-events/ingest', array( 'storeId' => $store_id, 'events' => array_values( $events ), ) ); } /** * POST /api/v1/plugin/bot-traffic/signatures: signed agent requests, checked by Rezolve Ai. * * @param string $store_id Store id. * @param array $samples From Signed_Agents::from_server(). * @return array|\WP_Error */ public function signed_agents_ingest( $store_id, array $samples ) { return $this->request( 'POST', '/api/v1/plugin/bot-traffic/signatures', array( 'storeId' => $store_id, 'samples' => array_values( $samples ), ) ); } public function bot_traffic_ingest( $store_id, array $days ) { return $this->request( 'POST', '/api/v1/plugin/bot-traffic/ingest', array( 'storeId' => $store_id, 'days' => array_values( $days ), ) ); } /* --------------------------------------------------------------------- */ /* Transport */ /* --------------------------------------------------------------------- */ /** * Perform a request with retries and envelope unwrapping. * * @param string $method HTTP method. * @param string $path Path beginning with '/'. * @param array|null $body JSON body for POST/PUT. * @param array|null $query Query parameters. * @return array|\WP_Error */ private function request( $method, $path, $body = null, $query = null, array $overrides = array() ) { $key = $this->settings->get_api_key(); if ( '' === $key ) { return new \WP_Error( 'agencoen_no_key', __( 'No API key configured. Connect your site first.', 'agentic-commerce-engine' ) ); } $url = $this->settings->get_base_url() . $path; if ( ! empty( $query ) ) { $url = add_query_arg( array_map( 'rawurlencode', $query ), $url ); } // Per-call overrides beat the instance's, because the reason for one is // always about the specific endpoint: creating an experiment starts a // generation run over thousands of products, and the 12s interactive // default would abort a slow success -- leaving the merchant an // experiment they cannot see and a retry the one-split guard refuses. $profile = self::resolve_profile( self::is_interactive_context(), array_merge( $this->options, $overrides ) ); $args = array( 'method' => $method, 'timeout' => $profile['timeout'], 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', 'X-ACE-Plugin-Version' => AGENCOEN_VERSION, ), ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $attempt = 0; while ( true ) { ++$attempt; $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { if ( $attempt < $profile['attempts'] ) { $this->backoff( $attempt ); continue; } $this->logger->error( "Transport error calling {$path}: " . $response->get_error_message() ); return $response; } $status = (int) wp_remote_retrieve_response_code( $response ); if ( self::should_retry( $status ) && $attempt < $profile['attempts'] ) { $this->backoff( $attempt ); continue; } $raw = wp_remote_retrieve_body( $response ); $decoded = json_decode( $raw, true ); $result = self::parse_response( $status, $decoded ); if ( is_wp_error( $result ) ) { $this->logger->error( "API {$status} on {$path}: " . $result->get_error_message() ); } return $result; } } /** * Sleep with exponential backoff (0.5s, 1s, 2s...), capped at 4s. * * @param int $attempt 1-based attempt number. */ private function backoff( $attempt ) { $ms = (int) min( 4000, 500 * ( 2 ** ( $attempt - 1 ) ) ); usleep( $ms * 1000 ); } /* --------------------------------------------------------------------- */ /* Pure helpers (unit-tested directly) */ /* --------------------------------------------------------------------- */ /** * Whether a status code is worth retrying (transient). * * @param int $status HTTP status. * @return bool */ public static function should_retry( $status ) { return in_array( (int) $status, array( 429, 500, 502, 503, 504 ), true ); } /** * Whether an array is a zero-indexed list (works on every supported PHP). * * @param array $arr Array to test. * @return bool */ public static function is_list( array $arr ) { if ( array() === $arr ) { return true; } return array_keys( $arr ) === range( 0, count( $arr ) - 1 ); } /** * Unwrap the platform envelope. * * Success: returns the `data` payload. When the envelope carries `meta` * (paginated lists), it is preserved under `_meta`; list payloads are * wrapped as `{ items: [...], _meta: {...} }` so callers never index a * mixed array. * * @param int $status HTTP status. * @param mixed $decoded Decoded JSON body (associative array or null). * @return array|\WP_Error The `data` payload, or a WP_Error. */ public static function parse_response( $status, $decoded ) { if ( $status >= 200 && $status < 300 ) { if ( is_array( $decoded ) && array_key_exists( 'data', $decoded ) ) { $data = $decoded['data']; $meta = isset( $decoded['meta'] ) && is_array( $decoded['meta'] ) ? $decoded['meta'] : null; if ( ! is_array( $data ) ) { $data = array( 'value' => $data ); } if ( null !== $meta ) { if ( self::is_list( $data ) ) { return array( 'items' => $data, '_meta' => $meta, ); } $data['_meta'] = $meta; } return $data; } return is_array( $decoded ) ? $decoded : array(); } $code = 'agencoen_api_error'; $message = sprintf( /* translators: %d: HTTP status code. */ __( 'The Rezolve Ai platform returned an error (HTTP %d).', 'agentic-commerce-engine' ), (int) $status ); if ( is_array( $decoded ) && isset( $decoded['error'] ) && is_array( $decoded['error'] ) ) { if ( ! empty( $decoded['error']['code'] ) ) { $code = (string) $decoded['error']['code']; } if ( ! empty( $decoded['error']['message'] ) ) { $message = (string) $decoded['error']['message']; } } return new \WP_Error( $code, $message, array( 'status' => (int) $status ) ); } }