plugin = $plugin; } /** * Pull, apply, ack. * * A failed pull leaves everything alone: an unreachable Rezolve Ai is not evidence * that anything changed. A failed APPLY is reported as such and the entry * stays queued, because the queue selects on `pushed_at IS NULL` and only a * successful ack clears it. * * A DEFERRED entry is left out of the ack altogether, so it stays queued and * comes back on a later pull. See {@see deferred_by_edit()}. * * An applied entry is acked with `productUrl`, the product's live page, when * it has one (4.20.1): what the web review queue's "View live" opens for * content approved there. An older Rezolve Ai ignores the key. * * @return array{applied:int,failed:int,skipped:int,deferred:int} */ public function drain() { $settings = $this->plugin->settings(); $empty = array( 'applied' => 0, 'failed' => 0, 'skipped' => 0, 'deferred' => 0, ); if ( ! $settings->is_connected() || ! $this->plugin->has_woo() ) { return $empty; } $store_id = $settings->get_store_id(); $response = $this->plugin->api()->apply_queue( $store_id ); if ( is_wp_error( $response ) ) { $this->plugin->logger()->log( 'apply-queue pull failed: ' . $response->get_error_message(), 'warning' ); return $empty; } // Where each content type is written, as of this pull (4.25.0). The // heartbeat also delivers it, hourly; taking it here means content // approved minutes after the merchant changed a destination goes to // the new one. An older Rezolve Ai omits the key, which changes nothing. if ( isset( $response['contentDestinations'] ) && is_array( $response['contentDestinations'] ) ) { Content_Destinations::save( $response['contentDestinations'] ); } $items = isset( $response['items'] ) && is_array( $response['items'] ) ? $response['items'] : array(); if ( empty( $items ) ) { return $empty; } $items = array_slice( $items, 0, self::BATCH ); $results = array(); $counts = $empty; foreach ( $items as $item ) { $id = isset( $item['id'] ) ? (string) $item['id'] : ''; if ( '' === $id ) { continue; } $post_id = 0; $outcome = $this->apply_entry( $item, $post_id ); if ( self::DEFERRED === $outcome ) { // Not in the ack: an entry reported neither applied nor failed // keeps its place in the queue and is delivered again, by then // carrying the value the merchant typed here. $counts['deferred']++; continue; } if ( true === $outcome ) { $result = array( 'id' => $id, 'status' => 'applied', ); $live_url = Review::live_url( $post_id ); if ( '' !== $live_url ) { $result['productUrl'] = $live_url; } $results[] = $result; $counts['applied']++; continue; } $results[] = array( 'id' => $id, 'status' => 'failed', // Bounded: the ack schema caps this, and a stack trace in a // remote error field helps nobody. 'error' => substr( (string) $outcome, 0, 500 ), ); $counts['failed']++; } if ( empty( $results ) ) { return $counts; } $ack = $this->plugin->api()->apply_queue_ack( $store_id, $results ); if ( is_wp_error( $ack ) ) { // The content IS live; only the report failed. Saying so matters: // the next pull will return the same entries, this will apply them // again, and applying the same value twice is harmless. Silently // swallowing it would leave a merchant's Published screen empty // with no account of why. $this->plugin->logger()->error( 'apply-queue ack failed, content is live but Rezolve Ai was not told: ' . $ack->get_error_message() ); return $counts; } if ( isset( $ack['skipped'] ) && is_array( $ack['skipped'] ) ) { $counts['skipped'] = count( $ack['skipped'] ); } $this->plugin->repository()->bust_counts_cache(); Feeds::touch(); return $counts; } /** * Apply one entry. * * @param array $item Queue entry. * @param int $post_id Set to the product the entry resolved to, 0 when none. * @return true|string True, self::DEFERRED, or a reason. */ private function apply_entry( array $item, &$post_id = 0 ) { $product_ref = isset( $item['productId'] ) ? (string) $item['productId'] : ''; $field = isset( $item['field'] ) ? (string) $item['field'] : ''; $value = isset( $item['value'] ) ? $item['value'] : null; if ( '' === $product_ref || '' === $field ) { return 'malformed entry'; } // ONE resolution rule, shared with Ab_Test. A queue entry names a // product the way Rezolve Ai knows it -- usually the merchant's own SKU -- and // a second SKU-to-post resolver here would drift from the one the A/B // config already uses, so a test could be configured on one post and // its content written to another. $post_id = (int) $this->plugin->ab_test()->post_id_for_product( $product_ref ); if ( ! $post_id ) { return 'no product matches ' . $product_ref; } // The A/B config is not product content and does not touch the product. if ( 'abTestConfig' === $field ) { $config = is_string( $value ) ? json_decode( $value, true ) : $value; if ( ! is_array( $config ) ) { return 'unreadable A/B config'; } $this->plugin->ab_test()->set_config( $post_id, $config ); return true; } if ( ! is_string( $value ) || '' === $value ) { return 'empty value for ' . $field; } if ( self::deferred_by_edit( $post_id, $field ) ) { return self::DEFERRED; } switch ( $field ) { // The adapter writes nothing when no SEO plugin is installed, and // its boolean used to be discarded. Returning true regardless made // drain() ack the row as applied, which stamps pushed_at and drops // it out of the queue for good, so the copy could never land even // after the merchant installed an SEO plugin. Failing is not a // worse outcome than that: the queue selects `pushed_at IS NULL`, // so the row simply waits and applies on the first tick after one // is present. case 'seoTitle': return $this->plugin->seo()->write_seo_title( $post_id, $value ) ? true : $this->plugin->seo()->write_failure_reason( 'meta title' ); case 'seoDescription': return $this->plugin->seo()->write_seo_description( $post_id, $value ) ? true : $this->plugin->seo()->write_failure_reason( 'meta description' ); case 'title': case 'descriptionHtml': $product = wc_get_product( $post_id ); if ( ! $product ) { return 'product not loadable'; } if ( 'title' === $field ) { $product->set_name( sanitize_text_field( $value ) ); } else { // Sections the merchant sends into the description (4.25.0) // were approved on their own and stay. $product->set_description( wp_kses_post( Content_Destinations::description_for( $product, $value ) ) ); } $product->save(); return true; // The `woocommerce` content type's two additive fields. Both arrive // as JSON inside the string value; Native_Fields owns the parsing // and the sanitising so the local flow cannot end up with a // different idea of what a usable tag is. // // A payload that sanitises down to nothing returns a REASON, not // true. Reporting "applied" for a write that wrote nothing is // exactly the lie this class exists to prevent. case 'productTags': $tags = Native_Fields::normalize_tags( $value ); if ( empty( $tags ) ) { return 'no usable tags in payload'; } return Native_Fields::write_tags( $post_id, $tags ) ? true : 'product already carries every tag in the payload'; case 'productAttributes': $attributes = Native_Fields::normalize_attributes( $value ); if ( empty( $attributes ) ) { return 'no usable attributes in payload'; } $product = wc_get_product( $post_id ); if ( ! $product ) { return 'product not loadable'; } // 4.18.0: an attribute this plugin added may be REPLACED, so an // edit approved in Rezolve Ai lands on a product that already // carries the first version. Attributes the merchant added // themselves are still never touched. Nothing is removed here: // this one field serves two content types, so a payload that // does not mention a key is not asking for it to go. $written = Native_Fields::write_owned_attributes( $product, $attributes ); if ( ! empty( $written['added'] ) || ! empty( $written['replaced'] ) ) { $product->save(); return true; } // Every attribute already reads exactly what was approved, and // this plugin put it there: the product says what was approved. if ( ! empty( $written['unchanged'] ) ) { return true; } return 'product already carries every attribute in the payload'; /* * The three additive content types (4.15.0). * * WHY THEY WERE MISSING. Rezolve Ai's apply queue deliberately did * not send them, on the stated grounds that they were applied by * the plugin's own enrichment flow -- which is true only for * content this site requested. Content a merchant approved in the * Rezolve Ai web app never travelled at all, so approving a schema * graph or a set of IRL cards there did nothing here, for ever. * * They write the SAME meta as `Apply::apply_content_types`, so the * frontend renderers and the feed serializers need no change and * `Apply::revert()` already knows how to take them down. */ case 'jsonLd': $graph = json_decode( $value, true ); if ( ! is_array( $graph ) || empty( $graph ) ) { return 'unreadable JSON-LD payload'; } update_post_meta( $post_id, Meta::JSONLD, $graph ); return true; case 'irlCards': $decoded = json_decode( $value, true ); // Where the merchant sends it (4.25.0); null keeps the block. $routed = Content_Destinations::route( $this->plugin, $post_id, 'irl-cards', $decoded ); if ( null !== $routed ) { return $routed; } // `{ cards, selectedIndex }` is the wire shape; a bare array is // accepted too, matching apply_content_types' tolerance. $scenarios = ( is_array( $decoded ) && isset( $decoded['cards'] ) && is_array( $decoded['cards'] ) ) ? $decoded['cards'] : $decoded; if ( ! is_array( $scenarios ) || empty( $scenarios ) ) { return 'no usable IRL scenarios in payload'; } update_post_meta( $post_id, Meta::IRL, $scenarios ); return true; case 'internalLinks': $decoded = json_decode( $value, true ); $routed = Content_Destinations::route( $this->plugin, $post_id, 'internal-links', $decoded ); if ( null !== $routed ) { return $routed; } $links = ( is_array( $decoded ) && isset( $decoded['links'] ) && is_array( $decoded['links'] ) ) ? $decoded['links'] : $decoded; if ( ! is_array( $links ) || empty( $links ) ) { return 'no usable internal links in payload'; } update_post_meta( $post_id, Meta::LINKS, $links ); return true; /* * Highlights, product Q&A and image alt text (4.16.0). Rezolve Ai only * sends these to a plugin reporting 4.16.0 or later. Pdp_Content does * the reading, shared with Apply, so the queue and the local flow * write the same thing. Product attributes need no case of their own: * they arrive as `productAttributes`, above. */ case 'highlights': $routed = Content_Destinations::route( $this->plugin, $post_id, 'highlights', $value ); if ( null !== $routed ) { return $routed; } $highlights = Pdp_Content::highlights( $value ); if ( empty( $highlights ) ) { return 'no usable highlights in payload'; } update_post_meta( $post_id, Meta::HIGHLIGHTS, $highlights ); return true; case 'productQa': $routed = Content_Destinations::route( $this->plugin, $post_id, 'product-qa', $value ); if ( null !== $routed ) { return $routed; } $qa = Pdp_Content::qa( $value ); if ( empty( $qa ) ) { return 'no usable questions and answers in payload'; } update_post_meta( $post_id, Meta::QA, $qa ); return true; /* * Generated specifications (4.25.0). Rezolve Ai sends them apart from * the feed's `productAttributes` to a plugin that routes content, so * specs can go where the merchant chose without feed attributes * following them. Their default home is unchanged: native * attributes, through the same owned-attribute writer. */ case 'productSpecs': $routed = Content_Destinations::route( $this->plugin, $post_id, 'product-attributes', $value ); if ( null !== $routed ) { return $routed; } $decoded = json_decode( $value, true ); $attributes = Edit_Values::attributes_for_write( Edit_Values::attribute_pairs( is_array( $decoded ) ? $decoded : array() ) ); if ( empty( $attributes ) ) { return 'no usable specifications in payload'; } $product = wc_get_product( $post_id ); if ( ! $product ) { return 'product not loadable'; } $written = Native_Fields::write_owned_attributes( $product, $attributes ); if ( ! empty( $written['added'] ) || ! empty( $written['replaced'] ) ) { $product->save(); return true; } if ( ! empty( $written['unchanged'] ) ) { return true; } return 'product already carries every attribute in the payload'; case 'imageAlt': $entries = Pdp_Content::image_alts( $value ); if ( empty( $entries ) ) { return 'no usable alt text in payload'; } $product = wc_get_product( $post_id ); if ( ! $product ) { return 'product not loadable'; } $match = Pdp_Content::match_image_alts( $entries, Native_Fields::image_candidates( $product ) ); if ( empty( $match['matches'] ) ) { return 'no image in payload is on this product any more'; } // Every matched image already carrying the proposed text is a // success, not a failure: the product says what was approved. Native_Fields::write_image_alts( $post_id, $match['matches'] ); return true; case 'customFields': // The merchant's own custom fields, rewritten (4.24.0). A field // changed on this site since the suggestion was written is left // alone; the entry is applied when at least one field landed. $fields = is_string( $value ) ? json_decode( $value, true ) : $value; if ( ! is_array( $fields ) || empty( $fields ) ) { return 'no usable custom fields in payload'; } $result = Custom_Fields::write_rewrites( $post_id, $fields ); if ( $result['written'] > 0 ) { update_post_meta( $post_id, Meta::APPLIED_AT, gmdate( 'c' ) ); return true; } return implode( ' ', $result['skipped'] ); } // An unknown field is not a failure to retry forever. A newer Rezolve Ai may // send a field this version cannot write, and reporting it applied // would be a lie; reporting it failed would re-deliver it on every // tick until the site upgrades. Failed is the honest answer, and the // message says which field so the log names the version gap. // // THIS IS ALSO THE CATEGORY GUARD. There is deliberately no `categories` // case above, and no writer for one in Native_Fields, so a future Rezolve Ai // that started sending product categories would land here and be // refused rather than restructuring a merchant's storefront navigation. // Same for `productType`: WooCommerce's product_type is // simple|variable|grouped|external, which selects a WC_Product subclass // and changes how the product is sold, so Shopify's merchandising // product_type has no destination here at all. return 'unsupported field: ' . $field; } /** * Whether an entry would overwrite an edit Rezolve Ai has not heard of yet. * * THE RACE THIS CLOSES (4.18.0). A merchant approves a highlight list in * the Rezolve Ai web app, then corrects one bullet on this site's Review * screen before the next pull. Without this, the pull writes the approved * original straight over the correction, and the merchant's edit is gone * with nothing to say why. * * The edit reaches Rezolve Ai with the next review-decisions report, which * writes the edited value into the review row. So an entry for an edited * field is held back until a report made AFTER the edit has succeeded; * the entry delivered after that carries the merchant's value. * * `Meta::REVIEW_SYNCED_AT` is written by {@see Review_Sync::drain()} only * for a product whose whole state went out in that report (its dirty flag * did not change while the report was in flight), and it is the time the * report was BUILT, so an edit that lands mid-flight still counts as unsent. * * @param int $post_id Product post id. * @param string $field Wire field. * @return bool */ public static function deferred_by_edit( $post_id, $field ) { if ( ! isset( Edit_Values::WIRE_FIELDS[ $field ] ) ) { return false; } $edited = get_post_meta( (int) $post_id, Meta::EDITED, true ); $type = Edit_Values::WIRE_FIELDS[ $field ]; if ( ! is_array( $edited ) || empty( $edited[ $type ] ) ) { return false; } $edited_at = strtotime( (string) $edited[ $type ] ); $synced_at = strtotime( (string) get_post_meta( (int) $post_id, Meta::REVIEW_SYNCED_AT, true ) ); if ( false === $edited_at ) { return false; } // BOUNDED. A deferred entry is left out of the ack, so it is delivered // again until the edit has been reported. If the report can never // succeed -- a revoked key, a site whose drain fails every attempt -- // that wait never ends, the entry is never acked, and it squats the // hundred-row window that every other product shares. A day covers // every real path back (hourly drain, a merchant reconnecting) and // stops one unreportable edit from freezing the whole queue. After it // the approved value wins: one overwritten edit a merchant can see and // redo beats silent, indefinite starvation. if ( ( time() - $edited_at ) > self::DEFER_MAX_SECONDS ) { return false; } return false === $synced_at || $edited_at > $synced_at; } }