*/ private array $script_attributes = array(); /** * Si la colocación explícita (shortcode o bloque) ya quedó agendada para el pie. * * @var bool */ private bool $placement_deferred = false; /** * Devuelve la instancia única. * * @return Injector */ public static function instance(): Injector { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * API base efectivo: el override del admin si lo hay, si no el default. * Público porque Settings lo reusa para el test de conexión — así la constante * vive en un solo lugar. * * @param array $opts Opciones ya mergeadas con los defaults. * @return string URL sin barra final. */ public static function api_base( array $opts ): string { $base = isset( $opts['api_base'] ) ? trim( (string) $opts['api_base'] ) : ''; return '' !== $base ? rtrim( $base, '/' ) : self::DEFAULT_API_BASE; } /** * Registra los hooks del frontend. * * @return void */ public function boot(): void { // Prioridad 1 en template_redirect: la cacheabilidad hay que decidirla ANTES de que // salga la primera línea de HTML. En wp_footer los headers ya se enviaron. add_action( 'template_redirect', array( $this, 'maybe_prevent_cache' ), 1 ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'script_loader_tag', array( $this, 'filter_script_tag' ), 10, 2 ); add_shortcode( 'botpar', array( $this, 'shortcode' ) ); add_action( 'init', array( $this, 'register_block' ) ); } /** * Registra el bloque del editor, equivalente visual del shortcode. * * Es un bloque dinámico: no guarda nada en el contenido y el frontend lo resuelve * PHP. `register_block_type()` acepta la ruta de un block.json desde WordPress 5.8, * que es justo el piso declarado del plugin. * * @return void */ public function register_block(): void { $dir = CHAT_BOT_WEB_PLUGIN_DIR . 'blocks/widget'; if ( ! file_exists( $dir . '/block.json' ) ) { return; } register_block_type( $dir, array( 'render_callback' => array( $this, 'render_block' ) ) ); // Deja el JS del editor listo para traducirse. WordPress.org genera los archivos // de traducción del JavaScript automáticamente para los plugins que aloja. $handle = function_exists( 'generate_block_asset_handle' ) ? generate_block_asset_handle( 'botpar/widget', 'editorScript' ) : 'botpar-widget-editor-script'; wp_set_script_translations( $handle, 'botpar' ); } /** * Render del bloque en el frontend: encola el widget y no imprime nada. * * @return string */ public function render_block(): string { return $this->place_widget( 'bloque del editor' ); } /** * Shortcode [botpar]: coloca el widget en esta página. * * Pensado para el modo de inserción manual. Como el admin lo puso a mano, gana sobre * las exclusiones por ruta y por tipo de contenido, que son reglas de la colocación * automática. Sí respeta "ocultar a usuarios logueados" (es sobre a quién se le * muestra, no sobre dónde) y el filtro chat_bot_web_skip, que es la salida de * emergencia para desarrolladores. * * Devuelve string vacío: no imprime nada en el contenido, sólo encola el widget. * Encolar desde acá funciona porque el loader va al pie, que se imprime después. * * @return string */ public function shortcode(): string { return $this->place_widget( 'shortcode [botpar]' ); } /** * Colocación explícita del widget, compartida por el shortcode y el bloque. * * @param string $source De dónde vino la colocación, sólo para el log. * @return string Siempre vacío: no imprime nada en el contenido. */ private function place_widget( string $source ): string { $opts = wp_parse_args( get_option( CHAT_BOT_WEB_OPTION, array() ), Settings::defaults() ); if ( empty( $opts['public_key'] ) ) { Logger::log( 'Placement ignored: no public key configured.', array( 'source' => $source ) ); return ''; } if ( $this->hidden_for_audience( $opts ) ) { return ''; } /** This filter is documented in includes/class-injector.php */ if ( apply_filters( 'chat_bot_web_skip', false ) ) { return ''; } Logger::log( 'Widget placed explicitly.', array( 'source' => $source ) ); $this->defer_widget( $opts ); return ''; } /** * Agenda el encolado del widget para el pie en vez de hacerlo acá mismo. * * Existe por una razón concreta. Desde WordPress 6.9, `WP_Block::render()` compara la * cola de assets antes y después de renderizar cada bloque: si el bloque no imprimió * nada, da por hecho que lo que encoló no le sirve a nadie y lo desencola. Nuestro * bloque devuelve vacío A PROPÓSITO —el widget es un botón flotante que monta el loader * al pie, no contenido dentro del artículo—, así que encolar durante su render hacía que * WordPress revirtiera el encolado y el widget no apareciera. Se veía bien en el editor * y el sitio publicado quedaba sin nada, en silencio. * * WordPress ofrece el filtro `enqueue_empty_block_content_assets` para esos casos, pero * no alcanza: la reversión también la hacen los bloques CONTENEDORES, y el filtro recibe * el nombre de cada uno. Habría que responder que sí por bloques ajenos, apagándole la * optimización a otros plugins. Encolar fuera del render evita el problema entero, en * cualquier nivel de anidamiento y sin depender de una versión de WordPress. * * Prioridad 0 en `wp_footer` porque `wp_print_footer_scripts` corre en la 20. * * @param array $opts Opciones ya mergeadas con los defaults. * @return void */ private function defer_widget( array $opts ): void { if ( $this->placement_deferred ) { return; } $this->placement_deferred = true; add_action( 'wp_footer', function () use ( $opts ) { $this->enqueue_widget( $opts ); }, 0 ); } /** * Marca la respuesta como NO cacheable cuando va a llevar la identidad del visitante. * * Sin esto, un page cache (WP Rocket, LiteSpeed, W3TC, Cloudflare APO) puede guardar el * HTML generado para el usuario A —con su email y su nombre dentro de * `window.chatBotWebVisitor`— y servírselo al usuario B: el widget de B se * auto-identificaría con la PII de A y `/api/widget/leads` terminaría creando o * vinculando el contacto equivocado en el panel. El escapado JSON de `inject()` cubre el * XSS, no esta fuga; son dos problemas distintos del mismo payload. * * A propósito es más amplio que `inject()`: acá sólo preguntamos "¿va a haber payload de * identidad?" y no evaluamos ni la exclusión por path ni el filtro `chat_bot_web_skip`, * que pueden depender de contexto que todavía no existe en template_redirect. El peor * caso es marcar como no cacheable una página donde después no inyectamos nada: cuesta * algo de performance, nunca correctitud. Ante la duda se protege la privacidad. */ public function maybe_prevent_cache(): void { if ( is_admin() ) { return; } $opts = wp_parse_args( get_option( CHAT_BOT_WEB_OPTION, array() ), Settings::defaults() ); if ( empty( $opts['public_key'] ) || empty( $opts['identify_user'] ) ) { return; } if ( ! is_user_logged_in() ) { return; } // Constante que respetan WP Rocket, W3 Total Cache, WP Super Cache y Comet Cache // cuando deciden (en shutdown) si escriben el buffer a disco. if ( ! defined( 'DONOTCACHEPAGE' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Constante estandar del ecosistema WordPress, definida por los plugins de cache. Prefijarla no tendria ningun efecto. define( 'DONOTCACHEPAGE', true ); } // LiteSpeed Cache usa su propia señal. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Hook propiedad de LiteSpeed Cache; el nombre lo define ese plugin, no este. do_action( 'litespeed_control_set_nocache', 'BotPar: HTML con identidad del visitante' ); // Y los headers HTTP, para CDNs y proxies (Cloudflare, Varnish) que no conocen // ninguna de las dos señales anteriores. if ( ! headers_sent() ) { nocache_headers(); } // Sin contexto: no se registra QUIÉN está logueado, solo que la página se marcó. Logger::log( 'Page marked as non-cacheable: it will carry visitor identity.' ); } /** * Encola el loader del widget y, si corresponde, la identidad del visitante. * * Se encola en vez de imprimir el `" o "