'', 'api_base' => '', 'identify_user' => 0, 'insertion' => 'auto', 'excluded_paths' => '', 'excluded_post_types' => array(), 'hide_on_mobile' => 0, 'hide_for_logged_in' => 0, 'debug_mode' => 0, ); } /** * Registra los hooks del panel de administración. * * @return void */ public function boot(): void { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_post_chat_bot_web_test', array( $this, 'handle_test' ) ); add_filter( 'plugin_action_links_' . plugin_basename( CHAT_BOT_WEB_PLUGIN_FILE ), array( $this, 'add_settings_link' ) ); } /** * Agrega la página bajo Ajustes. * * @return void */ public function register_menu(): void { add_options_page( __( 'BotPar', 'botpar' ), __( 'BotPar', 'botpar' ), 'manage_options', 'botpar', array( $this, 'render_page' ) ); } /** * Registra la opción con su callback de saneamiento. * * @return void */ public function register_settings(): void { register_setting( 'chat_bot_web_group', CHAT_BOT_WEB_OPTION, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize' ), 'default' => self::defaults(), ) ); } /** * Sanea y valida todo lo que llega del formulario. * * @param mixed $input Valor crudo enviado por el usuario. * @return array Opciones saneadas. */ public function sanitize( $input ): array { $defaults = self::defaults(); if ( ! is_array( $input ) ) { return $defaults; } $out = array(); $public_key = isset( $input['public_key'] ) ? trim( (string) $input['public_key'] ) : ''; if ( '' !== $public_key && ! preg_match( '/^widget_pk_[a-f0-9]{20,}$/', $public_key ) ) { add_settings_error( 'chat_bot_web', 'invalid_public_key', __( 'That public key does not look valid. It has to start with widget_pk_.', 'botpar' ) ); $public_key = ''; } $out['public_key'] = $public_key; $api_base = isset( $input['api_base'] ) ? trim( (string) $input['api_base'] ) : ''; if ( '' !== $api_base ) { // Sólo https: por acá viajan la conversación y la PII del visitante. Con http // el navegador además bloquearía la request por mixed content en cualquier // sitio servido por TLS. `esc_url_raw` con allowlist de protocolos devuelve '' // si el esquema no está permitido. $https_only = esc_url_raw( $api_base, array( 'https' ) ); if ( '' === $https_only ) { add_settings_error( 'chat_bot_web', 'invalid_api_base', __( 'The API base has to be a valid https:// URL. It was left empty so the default is used.', 'botpar' ) ); } $api_base = $https_only; } $out['api_base'] = $api_base; $out['identify_user'] = ! empty( $input['identify_user'] ) ? 1 : 0; $out['debug_mode'] = ! empty( $input['debug_mode'] ) ? 1 : 0; $out['hide_on_mobile'] = ! empty( $input['hide_on_mobile'] ) ? 1 : 0; $out['hide_for_logged_in'] = ! empty( $input['hide_for_logged_in'] ) ? 1 : 0; $insertion = isset( $input['insertion'] ) ? sanitize_key( (string) $input['insertion'] ) : 'auto'; $out['insertion'] = in_array( $insertion, array( 'auto', 'manual' ), true ) ? $insertion : 'auto'; // Se aceptan sólo tipos de contenido que existan y sean públicos: si un plugin que // registraba un tipo se desactiva, su valor guardado deja de tener efecto en vez de // quedar excluyendo algo que ya no existe. $types = isset( $input['excluded_post_types'] ) && is_array( $input['excluded_post_types'] ) ? array_map( 'sanitize_key', $input['excluded_post_types'] ) : array(); $out['excluded_post_types'] = array_values( array_intersect( $types, self::public_post_types() ) ); $paths = isset( $input['excluded_paths'] ) ? (string) $input['excluded_paths'] : ''; $paths = sanitize_textarea_field( $paths ); $out['excluded_paths'] = $paths; return $out; } /** * Nombres de los tipos de contenido públicos registrados en el sitio. * * @return array */ public static function public_post_types(): array { return array_values( get_post_types( array( 'public' => true ), 'names' ) ); } /** * Agrega "Settings" a las acciones del plugin en la lista de plugins. * * @param array $links Links existentes. * @return array */ public function add_settings_link( array $links ): array { $url = admin_url( 'options-general.php?page=botpar' ); $settings_link = '' . esc_html__( 'Settings', 'botpar' ) . ''; array_unshift( $links, $settings_link ); return $links; } /** * Handler del botón "Test connection". Guarda el resultado y vuelve a la página. * * @return void */ public function handle_test(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'botpar' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'chat_bot_web_test' ); $opts = wp_parse_args( get_option( CHAT_BOT_WEB_OPTION, array() ), self::defaults() ); $result = self::run_connection_test( $opts ); set_transient( self::TEST_TRANSIENT, $result, HOUR_IN_SECONDS ); Logger::log( 'Connection test executed.', array( 'result' => $result['status'], 'origin' => $result['origin'], ) ); wp_safe_redirect( admin_url( 'options-general.php?page=botpar' ) ); exit; } /** * Origin del sitio (esquema + host + puerto, sin path). * * `home_url()` puede traer subdirectorio (instalaciones en /blog); un header Origin * con path es inválido. * * Público porque Diagnostics lo muestra en Salud del sitio: es el dato que hay que * comparar contra la lista de dominios permitidos del panel. * * @return string */ public static function site_origin(): string { $parts = wp_parse_url( home_url() ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return home_url(); } $origin = $parts['scheme'] . '://' . $parts['host']; if ( ! empty( $parts['port'] ) ) { $origin .= ':' . $parts['port']; } return $origin; } /** * Consulta el endpoint de config del widget reproduciendo la request del navegador. * * Es el único momento en que el plugin habla con el backend desde PHP: el tráfico del * chat va siempre browser → BotPar, sin pasar por WordPress. * * @param array $opts Opciones ya mergeadas con los defaults. * @return array{status:string,message:string,origin:string,checked_at:int} */ private static function run_connection_test( array $opts ): array { $origin = self::site_origin(); $result = array( 'status' => 'error', 'message' => '', 'origin' => $origin, 'checked_at' => time(), ); $public_key = isset( $opts['public_key'] ) ? (string) $opts['public_key'] : ''; if ( '' === $public_key ) { $result['message'] = __( 'You have not entered the public key yet.', 'botpar' ); return $result; } /** * Timeout (en segundos) de las llamadas del plugin al backend de BotPar. * * @param int $timeout Timeout por default. */ $timeout = (int) apply_filters( 'chat_bot_web_request_timeout', 10 ); $response = wp_remote_get( Injector::api_base( $opts ) . '/api/widgets/' . rawurlencode( $public_key ) . '/config', array( 'timeout' => max( 1, $timeout ), 'redirection' => 2, 'headers' => array( // El backend valida la allowlist de dominios contra el header Origin. // Mandamos el del sitio para reproducir exactamente la request del // navegador: sin este header la validación responde 403 siempre y el // diagnóstico no serviría para nada. 'Origin' => $origin, ), 'user-agent' => 'BotPar-WP/' . CHAT_BOT_WEB_VERSION . '; ' . $origin, ) ); if ( is_wp_error( $response ) ) { $result['message'] = sprintf( /* translators: %s: network error message returned by WordPress. */ __( 'Could not reach BotPar: %s. This is usually a firewall, a proxy, or an outbound restriction on your host.', 'botpar' ), $response->get_error_message() ); return $result; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code ) { $result['status'] = 'ok'; $result['message'] = sprintf( /* translators: %s: site origin, e.g. https://mysite.com */ __( 'Connection OK. The widget responds and %s is on the allowed domains list.', 'botpar' ), $origin ); return $result; } if ( 403 === $code ) { $result['status'] = 'warning'; $result['message'] = sprintf( /* translators: %s: site origin, e.g. https://mysite.com */ __( '%s is not on the allowed domains list of the widget. Add it in your panel (Widgets → your widget → General → Allowed domains) and test again.', 'botpar' ), $origin ); return $result; } if ( 404 === $code ) { $result['message'] = __( 'Widget not found or inactive. The key may have been rotated: copy it again from Widgets → Installation.', 'botpar' ); return $result; } $result['message'] = sprintf( /* translators: %d: HTTP status code. */ __( 'The backend responded with HTTP %d.', 'botpar' ), $code ); return $result; } /** * Pinta el resultado del último test, si lo hay. * * @return void */ private function render_test_result(): void { $result = get_transient( self::TEST_TRANSIENT ); if ( ! is_array( $result ) || empty( $result['status'] ) || empty( $result['message'] ) ) { echo '

' . esc_html__( 'You have not tested the connection yet. It is the fastest way to know whether the widget will show up on your site.', 'botpar' ) . '

'; return; } $classes = array( 'ok' => 'notice-success', 'warning' => 'notice-warning', 'error' => 'notice-error', ); $status = isset( $classes[ $result['status'] ] ) ? $result['status'] : 'error'; printf( '

%2$s

', esc_attr( $classes[ $status ] ), esc_html( (string) $result['message'] ) ); if ( ! empty( $result['checked_at'] ) ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: date and time of the last check. */ __( 'Last checked: %s', 'botpar' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $result['checked_at'] ) ) ) ); } } /** * Pinta la página de configuración. * * @return void */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $opts = wp_parse_args( get_option( CHAT_BOT_WEB_OPTION, array() ), self::defaults() ); ?>

public key of the widget you created in your BotPar panel (Widgets → Installation). The widget then appears automatically on the front end.', 'botpar' ), array( 'strong' => array(), 'em' => array(), ) ); ?>


render_test_result(); ?>