id, 'aiprocessia-assistant')) { return; } $locale_es = (0 === strpos(get_locale(), 'es')); // "AIPROCESSIA Assistant:" no se traduce (nombre del producto, decisión de Jose 17-sep) — // literal en las dos ramas a propósito, no es una cadena olvidada de traducir. printf( '

%s %s %s

', esc_html('AIPROCESSIA Assistant:'), esc_html( $locale_es ? 'activa 5 consultas gratis al día para tus visitantes — sin tarjeta, sin cuenta que crear a mano.' : 'activate 5 free AI answers a day for your visitors — no card, no account to create by hand.' ), esc_url(admin_url('options-general.php?page=aiprocessia-assistant')), esc_html($locale_es ? 'Activar ahora' : 'Activate now') ); } /** * "Daily cap reached" banner (contract §3b.3, channel 3 — the WordPress * panel). Reads ONLY the cached plan status (never calls the backend from * this hook, which fires on every wp-admin page); the cache is populated by * aipf_gratis_estado_refrescar(), called after a successful signup and from * the settings screen. Shown both on the dashboard and on the settings page * because admin_notices fires on every wp-admin screen. */ function aipf_tope_banner_html() { if ('' === (string) get_option('aipf_widget_token', '')) { return ''; } $estado = aipf_gratis_estado_cache_get(); if (!is_array($estado) || empty($estado['ok'])) { return ''; } // Fricción #16 (1.3.1): el aviso «Ver packs» es del plan gratis; a un cliente de pago no se le // vende un pack que ya tiene (sus topes son otros y no tiene «visitantes sin atender»). if ('gratis' !== (string) ($estado['plan'] ?? '')) { return ''; } $limite = isset($estado['limite_dia']) ? (int) $estado['limite_dia'] : 0; $usadas = isset($estado['usadas_hoy']) ? (int) $estado['usadas_hoy'] : 0; $sin_hoy = isset($estado['sin_atender_hoy']) ? (int) $estado['sin_atender_hoy'] : 0; $sin_7d = isset($estado['sin_atender_7d']) ? (int) $estado['sin_atender_7d'] : 0; // Fricción #4 (decisión de Jose, 1.3.1): el aviso dice la verdad sobre el periodo del que // habla. Si HOY se ha tocado el tope, cifras de hoy; si no, es un aviso de la SEMANA y se // titula como tal (antes se ponía el contador de 7 días bajo la palabra «Hoy»). $tope_hoy = ($limite > 0 && $usadas >= $limite); if (!$tope_hoy && $sin_7d <= 0) { return ''; } $locale_es = (0 === strpos(get_locale(), 'es')); $upgrade_url = !empty($estado['upgrade_url']) ? esc_url_raw($estado['upgrade_url']) : 'https://aiprocessia.com/chatbot-tienda-online/?utm_source=plugin&utm_medium=aviso&utm_campaign=gratis_tope'; // singular/plural del contador (fricción #9: «1 visitantes») if ($tope_hoy) { $texto = $locale_es ? sprintf('Hoy: %1$d/%2$d consultas usadas · %3$d %4$s sin atender', $usadas, $limite, $sin_hoy, 1 === $sin_hoy ? 'visitante' : 'visitantes') : sprintf('Today: %1$d/%2$d queries used · %3$d %4$s unattended', $usadas, $limite, $sin_hoy, 1 === $sin_hoy ? 'visitor' : 'visitors'); } else { $texto = $locale_es ? sprintf('Esta semana: %1$d %2$s sin atender', $sin_7d, 1 === $sin_7d ? 'visitante' : 'visitantes') : sprintf('This week: %1$d %2$s unattended', $sin_7d, 1 === $sin_7d ? 'visitor' : 'visitors'); } $enlace = $locale_es ? 'Ver packs desde 49 €/mes' : 'See packs from €49/month'; // "AIPROCESSIA Assistant:" no se traduce (mismo motivo que en aipf_setup_notice()). return sprintf( '

%s — %s · %s →

', esc_html('AIPROCESSIA Assistant:'), esc_html($texto), esc_url($upgrade_url), esc_html($enlace) ); } add_action('admin_notices', 'aipf_tope_notice'); function aipf_tope_notice() { if (!current_user_can('manage_options')) { return; } echo aipf_tope_banner_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every piece is escaped inside aipf_tope_banner_html(). } /* ------------------------------------------------------------ chat widget */ /** * Inject the AIPROCESSIA chat widget on the storefront. * Guideline 7 compliance: nothing is loaded from remote servers until the * site owner has explicitly configured their widget token (free plan or * paid — same mechanism either way). */ add_action('wp_enqueue_scripts', 'aipf_enqueue_widget'); function aipf_enqueue_widget() { $token = get_option('aipf_widget_token', ''); if ('' === $token) { return; } wp_enqueue_script( 'aipf-widget', 'https://aiprocessia.com/cuenta/documentos/widget/?t=' . rawurlencode($token), array(), AIPF_VERSION, array( 'strategy' => 'defer', 'in_footer' => true, ) ); } /* ------------------------------------------------------------ product feed */ add_action('rest_api_init', 'aipf_register_feed_route'); function aipf_register_feed_route() { register_rest_route( 'aipf/v1', '/feed', array( 'methods' => 'GET', 'callback' => 'aipf_feed_callback', // The endpoint is public by design (AIPROCESSIA servers fetch it); // access is controlled by the secret token checked in the callback. 'permission_callback' => '__return_true', ) ); } /** * Serve the catalog as a Google Shopping RSS feed (same contract as the * AIPROCESSIA PrestaShop module): title, description, link, image, price, * availability, units sold in custom_label_0 and the product data sheet * (attributes) as product_detail entries — when WooCommerce is active AND * the site owner has enabled the catalog toggle (paid Store plan). * * Otherwise (free plan, or WooCommerce/catalog not enabled) this same * token-gated endpoint answers a small 200 JSON instead of 404: it is what * the free-plan signup (contract §1) checks to confirm the site is * publicly reachable before issuing a token, and a stable URL AIPROCESSIA * can use to start indexing the site's own pages (no product data * involved — that indexing reuses the existing generic web crawler). */ function aipf_feed_callback(WP_REST_Request $request) { $secret = (string) get_option('aipf_feed_token', ''); $given = (string) $request->get_param('token'); if ('' === $secret || !hash_equals($secret, $given)) { $locale_es = (0 === strpos(get_locale(), 'es')); return new WP_Error( 'aipf_forbidden', $locale_es ? 'Token del feed no válido.' : 'Invalid feed token.', array('status' => 403) ); } if ($request->get_param('gratis') && aipf_woo_has_products()) { aipf_serve_gratis_feed(!empty($request->get_param('fresh'))); } if (!get_option('aipf_feed_enabled') || !class_exists('WooCommerce')) { return new WP_REST_Response(aipf_feed_ping_body(), 200); } $fresh = (bool) $request->get_param('fresh'); $xml = $fresh ? false : get_transient('aipf_feed_xml'); if (false === $xml) { $xml = aipf_build_feed_xml(); set_transient('aipf_feed_xml', $xml, AIPF_FEED_CACHE_TTL); } // Raw XML output: WP_REST_Response would JSON-encode the body. header('Content-Type: application/xml; charset=utf-8'); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML built with esc_xml in aipf_build_feed_xml(). exit; } /** Minimal reachability/verification payload (no product data). */ function aipf_feed_ping_body() { return array( 'ok' => true, 'site' => home_url('/'), 'catalog' => false, 'products' => aipf_count_published_products(), 'plugin_version' => AIPF_VERSION, ); } /** * Query-var alias of the feed/verification endpoint: contract §1 documents * /?aipf_feed=1&token=... as the example URL shape, alongside "or this * plugin's real route". Kept as a thin alternative in case the backend * probes that exact shape instead of (or in addition to) the REST route — * same token check, same responses, no REST machinery involved. */ add_action('template_redirect', 'aipf_maybe_serve_feed_alias'); function aipf_maybe_serve_feed_alias() { if (!isset($_GET['aipf_feed'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint, gated by the secret token checked below. return; } $secret = (string) get_option('aipf_feed_token', ''); $given = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. if ('' === $secret || !hash_equals($secret, $given)) { status_header(403); header('Content-Type: application/json; charset=utf-8'); echo wp_json_encode(array('ok' => false, 'error' => 'forbidden')); exit; } if (!empty($_GET['gratis']) && aipf_woo_has_products()) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint, gated by the secret token checked above. aipf_serve_gratis_feed(!empty($_GET['fresh'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. } if (get_option('aipf_feed_enabled') && class_exists('WooCommerce')) { $fresh = !empty($_GET['fresh']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. $xml = $fresh ? false : get_transient('aipf_feed_xml'); if (false === $xml) { $xml = aipf_build_feed_xml(); set_transient('aipf_feed_xml', $xml, AIPF_FEED_CACHE_TTL); } header('Content-Type: application/xml; charset=utf-8'); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML built with esc_xml in aipf_build_feed_xml(). exit; } header('Content-Type: application/json; charset=utf-8'); echo wp_json_encode(aipf_feed_ping_body()); exit; } /* ------------------------------------------------- free plan: 10 products */ /** Number of published products/services (any WooCommerce site, shop or catalog). 0 without WooCommerce. */ function aipf_count_published_products() { if (!class_exists('WooCommerce')) { return 0; } $counts = wp_count_posts('product'); return isset($counts->publish) ? (int) $counts->publish : 0; } function aipf_woo_has_products() { return aipf_count_published_products() > 0; } /** * Can this site actually sell online? A WooCommerce site used as a CATALOG of products or services * (no cart/checkout page, or a "catalog mode" plugin) must not get prices nor an add-to-cart button. * Filterable: add_filter('aipf_gratis_can_sell', ...) lets a site owner or a catalog-mode plugin say no. */ function aipf_shop_can_sell() { $ok = true; foreach (array('cart', 'checkout') as $page) { $id = function_exists('wc_get_page_id') ? (int) wc_get_page_id($page) : 0; if ($id <= 0 || 'publish' !== get_post_status($id)) { $ok = false; } } return (bool) apply_filters('aipf_gratis_can_sell', $ok); } /** * The 10 products/services of the free plan: best sellers first (total_sales > 0); the rest up to 10 are the * most recent published. Out-of-stock items are left out ONLY when stock management is on (and only if that * leaves at least one product). Hidden-from-catalog items are never included. */ function aipf_gratis_pick_products($limit = AIPF_GRATIS_PRODUCTS) { $limit = max(1, (int) $limit); $manage = ('yes' === get_option('woocommerce_manage_stock', 'yes')); $base = array('status' => 'publish', 'visibility' => 'visible', 'limit' => $limit * 4); $pick = function ($with_stock_filter) use ($base, $limit) { $args = $base; if ($with_stock_filter) { $args['stock_status'] = 'instock'; } $sold = (array) wc_get_products(array_merge($args, array('orderby' => 'popularity', 'order' => 'DESC'))); $sold = array_values(array_filter($sold, function ($p) { return (int) $p->get_total_sales() > 0; })); usort($sold, function ($a, $b) { // stable enough: ties keep the query order via the index below return (int) $b->get_total_sales() - (int) $a->get_total_sales(); }); $out = array_slice($sold, 0, $limit); if (count($out) < $limit) { $ids = array(); foreach ($out as $p) { $ids[$p->get_id()] = true; } $recent = (array) wc_get_products(array_merge($args, array('orderby' => 'date', 'order' => 'DESC'))); foreach ($recent as $p) { if (count($out) >= $limit) { break; } if (!isset($ids[$p->get_id()])) { $out[] = $p; } } } return $out; }; $items = $pick($manage); if (empty($items) && $manage) { $items = $pick(false); } return $items; } /** * Per-item display flags. price: the amount to show (0 = show none); buyable: add-to-cart button allowed. * - Shop that cannot sell (no cart/checkout, catalog mode) or price hidden by a plugin: no price, no button. * - Priced but not purchasable online (external, quote-only, services): price yes, button no. * - Only simple products get the button (the widget adds by id, variations need a choice first). */ function aipf_gratis_item_flags($product, $can_sell) { $price = (float) wc_get_price_to_display($product); $html = trim(wp_strip_all_tags((string) $product->get_price_html())); $show = $can_sell && $price > 0 && '' !== $html; $buyable = $show && $product->is_purchasable() && $product->is_type('simple') && ($product->is_in_stock() || $product->backorders_allowed()); return array('price' => $show ? $price : 0.0, 'buyable' => (bool) $buyable); } function aipf_build_gratis_feed_xml() { $can_sell = aipf_shop_can_sell(); $items = array(); foreach (aipf_gratis_pick_products() as $product) { $flags = aipf_gratis_item_flags($product, $can_sell); $availability = 'in stock'; if (!$product->is_in_stock()) { $availability = $product->backorders_allowed() ? 'backorder' : 'out of stock'; } $desc = trim(wp_strip_all_tags((string) $product->get_description())); if ('' === $desc) { $desc = trim(wp_strip_all_tags((string) $product->get_short_description())); } $desc = preg_replace('/\s+/u', ' ', $desc); if (function_exists('mb_substr')) { $desc = mb_substr($desc, 0, 1200); } $it = "\n"; $it .= '' . (int) $product->get_id() . "\n"; $it .= '' . aipf_x($product->get_name()) . "\n"; $it .= '' . aipf_x($desc) . "\n"; $it .= '' . aipf_x($product->get_permalink()) . "\n"; $image_id = $product->get_image_id(); if ($image_id) { $img = wp_get_attachment_image_url($image_id, 'large'); if ($img) { $it .= '' . aipf_x($img) . "\n"; } } $cats = get_the_terms($product->get_id(), 'product_cat'); if (is_array($cats) && !empty($cats)) { $it .= '' . aipf_x($cats[0]->name) . "\n"; } if ($flags['price'] > 0) { $it .= '' . number_format($flags['price'], 2, '.', '') . ' ' . aipf_x(get_woocommerce_currency()) . "\n"; } $it .= '' . aipf_x($availability) . "\n"; $it .= "new\n"; $sales = (int) $product->get_total_sales(); if ($sales > 0) { $it .= 'ventas:' . $sales . "\n"; } if (!$flags['buyable']) { // Not purchasable online: the platform indexes it without the id, so the card has no cart button. $it .= "compra:no\n"; } $items[] = $it . ''; } $shop = get_bloginfo('name'); return '' . "\n" . '' . "\n\n" . '' . aipf_x($shop) . "\n" . '' . aipf_x(home_url('/')) . "\n" . '' . aipf_x($shop) . " - free plan catalog sample (max " . AIPF_GRATIS_PRODUCTS . " products)\n" . implode("\n", $items) . "\n\n\n"; } function aipf_serve_gratis_feed($fresh) { $xml = $fresh ? false : get_transient('aipf_feed_gratis_xml'); if (false === $xml) { $xml = aipf_build_gratis_feed_xml(); set_transient('aipf_feed_gratis_xml', $xml, AIPF_FEED_CACHE_TTL); } header('Content-Type: application/xml; charset=utf-8'); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML built with aipf_x() in aipf_build_gratis_feed_xml(). exit; } /** XML-escape helper (feed values). */ function aipf_x($value) { return htmlspecialchars((string) $value, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } function aipf_build_feed_xml() { $items = array(); $page = 1; do { $products = wc_get_products( array( 'status' => 'publish', 'limit' => 200, 'page' => $page, 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ($products as $product) { if (count($items) >= AIPF_FEED_MAX_PRODUCTS) { break 2; } if (!$product->is_visible()) { continue; } $price = (float) wc_get_price_to_display($product); if ($price <= 0) { continue; } $availability = 'in stock'; if (!$product->is_in_stock()) { $availability = $product->backorders_allowed() ? 'backorder' : 'out of stock'; } // Prefer the long description (enriched product sheets live there). $desc = trim(wp_strip_all_tags((string) $product->get_description())); if ('' === $desc) { $desc = trim(wp_strip_all_tags((string) $product->get_short_description())); } $desc = preg_replace('/\s+/u', ' ', $desc); if (function_exists('mb_substr')) { $desc = mb_substr($desc, 0, 1200); } $it = "\n"; $it .= '' . (int) $product->get_id() . "\n"; $it .= '' . aipf_x($product->get_name()) . "\n"; $it .= '' . aipf_x($desc) . "\n"; $it .= '' . aipf_x($product->get_permalink()) . "\n"; $image_id = $product->get_image_id(); if ($image_id) { $img = wp_get_attachment_image_url($image_id, 'large'); if ($img) { $it .= '' . aipf_x($img) . "\n"; } } $cats = get_the_terms($product->get_id(), 'product_cat'); if (is_array($cats) && !empty($cats)) { $it .= '' . aipf_x($cats[0]->name) . "\n"; } $it .= '' . number_format($price, 2, '.', '') . ' ' . aipf_x(get_woocommerce_currency()) . "\n"; $it .= '' . aipf_x($availability) . "\n"; $it .= "new\n"; // Units sold — the AIPROCESSIA platform turns this into the // "best sellers" popularity signal. $sales = (int) $product->get_total_sales(); if ($sales > 0) { $it .= 'ventas:' . $sales . "\n"; } // Data sheet (product attributes) as standard product_detail pairs. foreach ($product->get_attributes() as $attribute) { if (!($attribute instanceof WC_Product_Attribute)) { continue; } $label = wc_attribute_label($attribute->get_name()); if ($attribute->is_taxonomy()) { $terms = wp_get_post_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names')); $values = is_wp_error($terms) ? array() : $terms; } else { $values = $attribute->get_options(); } $value = implode(', ', array_map('strval', (array) $values)); if ('' === $label || '' === $value) { continue; } $it .= '' . aipf_x($label) . '' . aipf_x($value) . "\n"; } $items[] = $it . ''; } $page++; } while (!empty($products)); $shop = get_bloginfo('name'); return '' . "\n" . '' . "\n\n" . '' . aipf_x($shop) . "\n" . '' . aipf_x(home_url('/')) . "\n" . '' . aipf_x($shop) . " - product catalog for the AI assistant\n" . implode("\n", $items) . "\n\n\n"; } // Keep the cached feed reasonably current when the catalog changes. add_action('woocommerce_update_product', 'aipf_flush_feed_cache'); add_action('woocommerce_delete_product', 'aipf_flush_feed_cache'); function aipf_flush_feed_cache() { delete_transient('aipf_feed_gratis_xml'); delete_transient('aipf_feed_xml'); } /* ---------------------------------------------------------------- settings */ add_action('admin_menu', 'aipf_admin_menu'); function aipf_admin_menu() { // "AIPROCESSIA Assistant" no se traduce (nombre del producto, decisión de Jose 17-sep). add_options_page( 'AIPROCESSIA Assistant', 'AIPROCESSIA Assistant', 'manage_options', 'aiprocessia-assistant', 'aipf_settings_page' ); } add_action('admin_init', 'aipf_register_settings'); function aipf_register_settings() { register_setting( 'aipf_settings', 'aipf_widget_token', array( 'type' => 'string', 'sanitize_callback' => 'aipf_sanitize_token', 'default' => '', ) ); register_setting( 'aipf_settings', 'aipf_feed_enabled', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); register_setting( 'aipf_settings', 'aipf_registro', array( 'type' => 'boolean', 'sanitize_callback' => 'aipf_sanitize_registro', 'default' => false, ) ); } /** * Re-entrancy guard for aipf_registro. The free-plan handler writes the flag * itself after having already sent the registration request; update_option() * runs the registered sanitize callback, which would otherwise fire a SECOND * request (with the admin email instead of the one just entered). */ function aipf_registro_en_curso($set = null) { static $en_curso = false; if (null !== $set) { $en_curso = (bool) $set; } return $en_curso; } /** * Optional update notices + free SEO+GEO report (opt-in). Since 1.3.1 the * usual way in is the checkbox inside the free-plan form; this settings * checkbox stays for sites that are not signed up here (paid customers * pasting their token by hand) and for the sites that already used it in * 1.2.0 — same option, same request, unchanged behaviour. * The checkbox is UNCHECKED by default; only when the site owner ticks it we * send ONE request to AIPROCESSIA with the site domain and the admin email. * AIPROCESSIA answers with a confirmation email — nothing is activated until * the owner confirms it. Unticking only stops future registration calls; the * mailing opt-out is the unsubscribe link in any email. */ function aipf_sanitize_registro($value) { $nuevo = rest_sanitize_boolean($value); $viejo = (bool) get_option('aipf_registro', false); $locale_es = (0 === strpos(get_locale(), 'es')); if ($nuevo && !$viejo && !aipf_registro_en_curso()) { if (aipf_registrar_sitio()) { update_option('aipf_registro_fecha', gmdate('Y-m-d H:i'), false); add_settings_error( 'aipf_registro', 'aipf_registro_ok', sprintf( $locale_es ? 'Te hemos enviado un correo de confirmación a %s. Ábrelo y pulsa el botón para completar el registro.' : 'We have sent a confirmation email to %s. Open it and press the button to complete the registration.', get_option('admin_email') ), 'success' ); } else { add_settings_error( 'aipf_registro', 'aipf_registro_error', $locale_es ? 'No hemos podido contactar con AIPROCESSIA ahora mismo. Inténtalo de nuevo más tarde.' : 'We could not contact AIPROCESSIA right now. Please try again later.' ); return false; } } return $nuevo; } /** * POST the registration to AIPROCESSIA. Returns true on HTTP 200 + ok:true. * $email/$idioma let the free-plan form reuse this same circuit with what the * owner just typed there; with no arguments it behaves exactly as in 1.2.0 * (admin email + site locale), which is what the settings checkbox uses. */ function aipf_registrar_sitio($email = '', $idioma = '') { $email = is_email($email) ? $email : (string) get_option('admin_email'); $idioma = in_array($idioma, array('es', 'en'), true) ? $idioma : ((0 === strpos(get_locale(), 'es')) ? 'es' : 'en'); $body = array( 'modulo' => 'aiprocessia-assistant', 'version' => AIPF_VERSION, 'dominio' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST), 'email' => $email, 'plataforma' => class_exists('WooCommerce') ? 'WordPress/WooCommerce' : 'WordPress', 'plataforma_version' => (string) get_bloginfo('version'), 'idioma' => $idioma, 'consent' => true, ); $res = wp_remote_post( 'https://aiprocessia.com/cuenta/modulos/registro/', array( 'timeout' => 12, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($body), ) ); if (is_wp_error($res) || 200 !== (int) wp_remote_retrieve_response_code($res)) { return false; } $json = json_decode((string) wp_remote_retrieve_body($res), true); return is_array($json) && !empty($json['ok']); } function aipf_sanitize_token($value) { return substr(preg_replace('/[^a-z0-9]/', '', strtolower((string) $value)), 0, 64); } /* ------------------------------------------------------------- plan gratis */ /** Cached plan status (transient, 1h). Never triggers a network request. */ function aipf_gratis_estado_cache_get() { $cache = get_transient('aipf_gratis_estado'); return is_array($cache) ? $cache : false; } /** * Poll the plan status endpoint (contract §5) and cache it for 1h. Safe to * call with no token (returns false, no request made). On a network or * format failure the PREVIOUS cache (if any) is left untouched, so the * admin still sees the last known state instead of a blank screen — this * function is only ever called from wp-admin (page load or explicit * "Refresh"/signup actions), never from a visitor-facing hook. * * Returns array('estado' => array|false, 'ok' => bool): 'ok' tells the * caller whether THIS call reached the backend successfully (fricción #28) * — as opposed to 'estado', which may still carry the last good cache even * on failure. A real backend request is throttled to at most one every * AIPF_GRATIS_POLL_LOCK_TTL (fricciones #26/#27), regardless of whether it * was triggered by the settings screen's own check or by "Refresh". */ function aipf_gratis_estado_refrescar_resultado($force = false) { $token = (string) get_option('aipf_widget_token', ''); if ('' === $token) { return array('estado' => false, 'ok' => false); } if (!$force) { $cached = aipf_gratis_estado_cache_get(); if (false !== $cached) { return array('estado' => $cached, 'ok' => true); } } if (get_transient('aipf_gratis_poll_lock')) { $cached = aipf_gratis_estado_cache_get(); return array('estado' => $cached, 'ok' => (false !== $cached)); } set_transient('aipf_gratis_poll_lock', 1, AIPF_GRATIS_POLL_LOCK_TTL); $res = wp_remote_post( AIPF_GRATIS_ESTADO_URL, array( 'timeout' => 12, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode(array('widget_token' => $token)), ) ); if (is_wp_error($res) || 200 !== (int) wp_remote_retrieve_response_code($res)) { return array('estado' => aipf_gratis_estado_cache_get(), 'ok' => false); } $json = json_decode((string) wp_remote_retrieve_body($res), true); if (!is_array($json) || empty($json['ok'])) { return array('estado' => aipf_gratis_estado_cache_get(), 'ok' => false); } set_transient('aipf_gratis_estado', $json, AIPF_GRATIS_CACHE_TTL); if (!empty($json['plan'])) { update_option('aipf_plan', sanitize_key($json['plan']), false); } return array('estado' => $json, 'ok' => true); } /** Thin wrapper kept for call sites that only care about the status array. */ function aipf_gratis_estado_refrescar($force = false) { $resultado = aipf_gratis_estado_refrescar_resultado($force); return $resultado['estado']; } /** * Status used to render the settings screen (fricciones #26/#27, 16-sep). A * cache that says "not verified" or "0 indexed pages" is likely a stale * snapshot (the alta handler caches one before crawling/verification * finish) or simply outdated (the plugin has no way to know the owner just * clicked the verification link), so re-check the backend instead of * trusting the 1h cache blindly — safe to call on every load because * aipf_gratis_estado_refrescar_resultado() throttles real requests to at * most one a minute. */ function aipf_gratis_estado_para_pantalla() { $cached = aipf_gratis_estado_cache_get(); $pendiente = (false === $cached) || empty($cached['verificado']) || 0 === (int) ($cached['paginas_indexadas'] ?? 0); if (!$pendiente) { return $cached; } $resultado = aipf_gratis_estado_refrescar_resultado(true); return is_array($resultado['estado']) ? $resultado['estado'] : $cached; } /** Fallback bilingual messages for known error codes (used only if the backend's own "mensaje" is missing). */ function aipf_gratis_error_mensaje($codigo, $locale_es) { $textos = array( 'ya_cliente' => array( 'es' => 'Este dominio ya tiene un plan de pago de AIPROCESSIA. Entra en tu cuenta para gestionarlo.', 'en' => 'This domain already has a paid AIPROCESSIA plan. Sign in to your account to manage it.', ), 'feed_inaccesible' => array( 'es' => 'No hemos podido acceder a tu web públicamente para verificarla. Comprueba que es accesible (sin modo mantenimiento ni contraseña) y vuelve a intentarlo.', 'en' => 'We could not reach this site publicly to verify it. Make sure it is publicly reachable (no maintenance mode or password) and try again.', ), 'no_disponible' => array( 'es' => 'El plan gratis no está disponible ahora mismo. Inténtalo de nuevo más tarde.', 'en' => 'The free plan is not available right now. Please try again later.', ), ); if (isset($textos[$codigo])) { return $locale_es ? $textos[$codigo]['es'] : $textos[$codigo]['en']; } return $locale_es /* translators: %s: error code from the AIPROCESSIA API */ ? sprintf('No hemos podido activar el plan gratis (código: %s). Inténtalo de nuevo en unos minutos.', $codigo) /* translators: %s: error code from the AIPROCESSIA API */ : sprintf('We could not activate the free plan (code: %s). Please try again in a few minutes.', $codigo); } /** * Free-plan form, optional checkbox (unchecked by default): "tell me about * plugin updates and send me the free SEO+GEO report of my site". Reuses the * 1.2.0 registration circuit — same endpoint, same options — with the email * and language just entered instead of the admin ones. A failure here NEVER * invalidates the free plan: it only adds a warning next to the signup notice. */ function aipf_gratis_registrar_avisos($email, $idioma, $locale_es) { if (aipf_registrar_sitio($email, $idioma)) { aipf_registro_en_curso(true); update_option('aipf_registro', true, false); aipf_registro_en_curso(false); update_option('aipf_registro_fecha', gmdate('Y-m-d H:i'), false); add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_avisos_ok', $locale_es /* translators: %s: email address entered in the free-plan form */ ? sprintf('También te apuntamos a los avisos de actualización y al informe SEO+GEO: abre el correo de confirmación que te hemos enviado a %s.', $email) /* translators: %s: email address entered in the free-plan form */ : sprintf('We also signed you up for update notices and the free SEO+GEO report: open the confirmation email we sent to %s.', $email), 'success' ); return true; } add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_avisos_error', $locale_es ? 'Tu plan gratis está activo, pero no hemos podido apuntarte a los avisos de actualización ni al informe SEO+GEO. Puedes activarlo más tarde desde esta misma pantalla.' : 'Your free plan is active, but we could not sign you up for the update notices and the free SEO+GEO report. You can enable it later from this same screen.', 'warning' ); return false; } /** * "Activate my free plan" form handler (contract §1). Fires ONLY on an * explicit admin submission (nonce-checked); never called automatically. */ add_action('admin_post_aipf_gratis_alta', 'aipf_admin_post_gratis_alta'); function aipf_admin_post_gratis_alta() { if (!current_user_can('manage_options')) { wp_die(esc_html((0 === strpos(get_locale(), 'es')) ? 'No tienes permisos para hacer esto.' : 'You are not allowed to do this.')); } check_admin_referer('aipf_gratis_alta', 'aipf_gratis_nonce'); // settings-updated=true: sin él, settings_errors() ignora el transient y el mensaje de éxito/error // del alta se perdía (fricción #11, prueba externa 8-sep). $redirect = admin_url('options-general.php?page=aiprocessia-assistant&settings-updated=true'); $locale_es = (0 === strpos(get_locale(), 'es')); $email = isset($_POST['aipf_gratis_email']) ? sanitize_email(wp_unslash($_POST['aipf_gratis_email'])) : ''; $sitio = isset($_POST['aipf_gratis_sitio']) ? esc_url_raw(wp_unslash($_POST['aipf_gratis_sitio'])) : ''; $idioma = (isset($_POST['aipf_gratis_idioma']) && 'en' === $_POST['aipf_gratis_idioma']) ? 'en' : 'es'; $consent = !empty($_POST['aipf_gratis_consent']); $avisos = !empty($_POST['aipf_gratis_avisos']); $alta_ok = false; if ('' === $sitio) { $sitio = home_url('/'); } if (!$consent) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_consent', $locale_es ? 'Debes aceptar el envío de tus datos a aiprocessia.com para activar el plan gratis.' : 'You must accept sending your data to aiprocessia.com to activate the free plan.' ); } elseif (!is_email($email)) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_email', $locale_es ? 'Introduce un correo válido.' : 'Please enter a valid email address.' ); } else { $body = array( 'email' => $email, 'sitio' => $sitio, // P5 (25-sep): nombre del sitio para que el widget y los correos usen "Mi Tienda" en // vez del dominio en crudo — declarado en la casilla de consentimiento y en el // readme (External services), tope de 80 caracteres acordado con el backend. 'nombre' => mb_substr((string) get_bloginfo('name'), 0, 80), 'idioma' => $idioma, 'feed_token' => (string) get_option('aipf_feed_token', ''), 'plugin_version' => AIPF_VERSION, 'consent' => true, ); $res = wp_remote_post( AIPF_GRATIS_ALTA_URL, array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($body), ) ); if (is_wp_error($res)) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_red', $locale_es ? 'No hemos podido contactar con AIPROCESSIA ahora mismo. Inténtalo de nuevo en un momento.' : 'We could not contact AIPROCESSIA right now. Please try again in a moment.' ); } else { $code = (int) wp_remote_retrieve_response_code($res); $json = json_decode((string) wp_remote_retrieve_body($res), true); if (200 === $code && is_array($json) && !empty($json['ok'])) { $token = isset($json['widget_token']) ? aipf_sanitize_token($json['widget_token']) : ''; if ('' !== $token) { update_option('aipf_widget_token', $token, false); update_option('aipf_plan', 'gratis', false); update_option('aipf_gratis_email', $email, false); // P3 (25-sep): fecha del alta (solo la fecha, UTC) para saber si estamos en el // "primer día" y mostrar el bloque de bienvenida en vez de la tabla a ceros. update_option('aipf_gratis_alta_fecha', gmdate('Y-m-d'), false); delete_transient('aipf_gratis_estado'); aipf_gratis_estado_refrescar(true); $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : ($locale_es ? 'Plan gratis activado. Te hemos enviado un correo para verificar tu email; tienes 48 h.' : 'Free plan activated. We sent you an email to verify your address; you have 48h.'); add_settings_error('aipf_gratis_alta', 'aipf_gratis_ok', $mensaje, 'success'); $alta_ok = true; // Fricción #6 (decisión de Jose, 1.3.1): una pantalla, un formulario. La // casilla de avisos/informe SEO+GEO dispara aquí el MISMO circuito que la // casilla suelta de 1.2.0, con el correo que se acaba de escribir. if ($avisos) { aipf_gratis_registrar_avisos($email, $idioma, $locale_es); } } else { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_bad', $locale_es ? 'Respuesta inesperada de AIPROCESSIA.' : 'Unexpected response from AIPROCESSIA.' ); } } else { $error_code = (is_array($json) && !empty($json['error'])) ? sanitize_key($json['error']) : (string) $code; $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : aipf_gratis_error_mensaje($error_code, $locale_es); add_settings_error('aipf_gratis_alta', 'aipf_gratis_error', $mensaje); } } } set_transient('settings_errors', get_settings_errors(), 30); // Si el ALTA ha fallado, que el formulario vuelva con lo que el usuario escribió (fricción // #14): sin esto tenía que reescribir el correo y volver a marcar la casilla. Un fallo del // registro de avisos (1.3.1) no cuenta: el alta es válida y el formulario ya no se muestra. if (!$alta_ok) { set_transient('aipf_gratis_form', array('email' => $email, 'sitio' => $sitio, 'idioma' => $idioma), 120); } wp_safe_redirect($redirect); exit; } /** * "Refresh" button on the status block — forces a fresh poll, bypassing the * 1h cache (still throttled to 1/minute, see aipf_gratis_estado_refrescar_resultado()). * Fricción #28 (16-sep): a failed poll (WP_Error, HTTP != 200 incl. 429, or * ok:false) must NOT redirect as if it had succeeded — it keeps the last * good status (already handled by _resultado()) and surfaces a warning * instead of silently pretending the refresh worked. */ add_action('admin_post_aipf_gratis_refrescar', 'aipf_admin_post_gratis_refrescar'); function aipf_admin_post_gratis_refrescar() { if (!current_user_can('manage_options')) { wp_die(esc_html((0 === strpos(get_locale(), 'es')) ? 'No tienes permisos para hacer esto.' : 'You are not allowed to do this.')); } check_admin_referer('aipf_gratis_refrescar', 'aipf_gratis_refrescar_nonce'); $locale_es = (0 === strpos(get_locale(), 'es')); $resultado = aipf_gratis_estado_refrescar_resultado(true); if (empty($resultado['ok'])) { add_settings_error( 'aipf_gratis_refrescar', 'aipf_gratis_refrescar_error', $locale_es ? 'No hemos podido actualizar el estado de tu plan ahora mismo; se muestran los últimos datos disponibles. Inténtalo de nuevo en unos minutos.' : 'We could not refresh your plan status right now; showing the last data available. Please try again in a few minutes.', 'warning' ); set_transient('settings_errors', get_settings_errors(), 30); } wp_safe_redirect(admin_url('options-general.php?page=aiprocessia-assistant&settings-updated=true')); exit; } /** * P1 (25-sep) — "Try it with my site" demo, no signup required (contract * fixed with the backend parcela; endpoint not live yet at the time of this * release, see ESTADO.md). Fires ONLY on an explicit admin submission * (nonce-checked, manage_options) — never automatically and never from the * public site, so it stays inside guideline 7 (the click is the consent, * declared right next to the button and in "External services"). Sends the * site's own address (never user-editable) and the question just typed; * the result (or error) is shown back on the welcome screen via a short * transient, same pattern as aipf_gratis_form. */ add_action('admin_post_aipf_gratis_demo', 'aipf_admin_post_gratis_demo'); function aipf_admin_post_gratis_demo() { if (!current_user_can('manage_options')) { wp_die(esc_html((0 === strpos(get_locale(), 'es')) ? 'No tienes permisos para hacer esto.' : 'You are not allowed to do this.')); } check_admin_referer('aipf_gratis_demo', 'aipf_gratis_demo_nonce'); $locale_es = (0 === strpos(get_locale(), 'es')); $redirect = admin_url('options-general.php?page=aiprocessia-assistant&settings-updated=true'); $pregunta = isset($_POST['aipf_demo_pregunta']) ? trim(sanitize_text_field(wp_unslash($_POST['aipf_demo_pregunta']))) : ''; if ('' === $pregunta) { add_settings_error( 'aipf_gratis_demo', 'aipf_gratis_demo_vacia', $locale_es ? 'Escribe una pregunta para probar el asistente.' : 'Type a question to try the assistant.' ); set_transient('settings_errors', get_settings_errors(), 30); wp_safe_redirect($redirect); exit; } $res = wp_remote_post( AIPF_GRATIS_DEMO_URL, array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'url' => home_url('/'), 'pregunta' => $pregunta, 'lang' => $locale_es ? 'es' : 'en', ) ), ) ); if (is_wp_error($res)) { add_settings_error( 'aipf_gratis_demo', 'aipf_gratis_demo_red', $locale_es ? 'No hemos podido contactar con AIPROCESSIA ahora mismo. Inténtalo de nuevo en un momento.' : 'We could not contact AIPROCESSIA right now. Please try again in a moment.' ); } else { $code = (int) wp_remote_retrieve_response_code($res); $json = json_decode((string) wp_remote_retrieve_body($res), true); if (200 === $code && is_array($json) && !empty($json['ok'])) { set_transient( 'aipf_gratis_demo_resultado', array( 'pregunta' => $pregunta, 'respuesta' => isset($json['respuesta']) ? sanitize_textarea_field($json['respuesta']) : '', 'fuente' => isset($json['fuente']) ? sanitize_text_field($json['fuente']) : '', 'restantes' => isset($json['restantes']) ? (int) $json['restantes'] : null, ), 10 * MINUTE_IN_SECONDS ); } elseif (429 === $code) { // error: tope (contrato P1) — límite de preguntas de prueba alcanzado. $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : ($locale_es ? 'Has agotado las preguntas de prueba por ahora.' : 'You have used up the trial questions for now.'); add_settings_error('aipf_gratis_demo', 'aipf_gratis_demo_tope', $mensaje); } elseif (502 === $code) { // error: sitio (contrato P1) — el backend no ha podido leer la web. $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : ($locale_es ? 'No hemos podido leer tu web ahora mismo. Inténtalo de nuevo en unos minutos.' : 'We could not read your site right now. Please try again in a few minutes.'); add_settings_error('aipf_gratis_demo', 'aipf_gratis_demo_sitio', $mensaje); } else { // 400 url|pregunta u otra respuesta inesperada: no debería pasar (la URL la fija el // propio plugin y la pregunta ya se ha comprobado no vacía), pero se cubre igual. add_settings_error( 'aipf_gratis_demo', 'aipf_gratis_demo_error', $locale_es ? 'No hemos podido probar tu web ahora mismo. Inténtalo de nuevo en unos minutos.' : 'We could not try your site right now. Please try again in a few minutes.' ); } } set_transient('settings_errors', get_settings_errors(), 30); wp_safe_redirect($redirect); exit; } /** * Fricción 29 (26-sep) — the "Try it with my site" answer is the model's own * text and can carry minimal Markdown (bold and italic markers, blank-line * paragraphs); printed with a plain esc_html() it showed the literal * markers (and squashed double line breaks). This turns that MINIMAL * Markdown into safe HTML: escape first, so any literal angle bracket or * ampersand in the model's own words stays inert and is never parsed as * markup, then turn the bold/italic markers and line breaks into tags, and * finally wp_kses() with only strong/em/br/p as a second gate — nothing * else can ever reach the page, in particular no raw HTML from the model. */ function aipf_gratis_formatear_respuesta($texto) { $permitidas = array( 'strong' => array(), 'em' => array(), 'br' => array(), 'p' => array(), ); $html = esc_html((string) $texto); $html = preg_replace('/\*\*(.+?)\*\*/s', '$1', $html); $html = preg_replace('/\*(.+?)\*/s', '$1', $html); $parrafos = preg_split('/\n\s*\n/', trim($html)); $parrafos = array_map( static function ($parrafo) { return '

' . nl2br(trim($parrafo)) . '

'; }, $parrafos ); return wp_kses(implode('', $parrafos), $permitidas); } function aipf_settings_page() { if (!current_user_can('manage_options')) { return; } // Sin settings_errors() aquí: en las páginas del menú Ajustes lo llama el propio WordPress // (options-head.php) y salía duplicado (fricción #13). Lo que hace visibles los avisos es el // settings-updated=true de las redirecciones. $form_previo = get_transient('aipf_gratis_form'); if (is_array($form_previo)) { delete_transient('aipf_gratis_form'); } else { $form_previo = array(); } $widget_token = get_option('aipf_widget_token', ''); $feed_enabled = (bool) get_option('aipf_feed_enabled'); $feed_url = rest_url('aipf/v1/feed'); $feed_url = add_query_arg('token', rawurlencode((string) get_option('aipf_feed_token', '')), $feed_url); $has_woo = class_exists('WooCommerce'); $locale_es = (0 === strpos(get_locale(), 'es')); // The site domain travels in the signup link so the AIPROCESSIA panel can // pre-fill the widget's authorized domains and the catalog connection. $signup_url = add_query_arg( array( 'utm_source' => 'wordpress-plugin', 'utm_medium' => 'settings', 'site' => rawurlencode(home_url('/')), ), $locale_es ? 'https://aiprocessia.com/chatbot-tienda-online/' : 'https://aiprocessia.com/en/ai-shopping-assistant/' ); ?>

AIPROCESSIA Assistant


—

$locale_es ? 'Gratis' : 'Free', 'web' => 'Web', 'tienda' => $locale_es ? 'Tienda' : 'Store', 'documental' => $locale_es ? 'Documental' : 'Knowledge', ); // P3 (25-sep, fricción #20): primer día del plan gratis -> se enseña "tu asistente ya // está aprendiéndose tu web" en vez de la tabla de contadores (que baja a segundo // plano, dentro de un
, y así no enseña 0/5 el primer día). $plan_key_previo = is_array($estado) ? (string) ($estado['plan'] ?? '') : ''; $primer_dia = ('gratis' === $plan_key_previo) && gmdate('Y-m-d') === (string) get_option('aipf_gratis_alta_fecha', ''); ?>

—

→

Plan

→ →


' . esc_html($locale_es ? 'Ajustes' : 'Settings') . '' ); return $links; }