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(
'
',
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(
'
',
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', '');
?>