'', 'server_url' => VSAI_DEFAULT_SERVER, 'brain' => 'n8n', 'guardian_on' => false, ), $saved ); } /** * Cerebro elegido en los ajustes: 'n8n' (el de siempre) o 'flow' * (Vidal Flow). Cualquier valor guardado que no sea 'flow' cae a 'n8n': * la lista de cerebros es cerrada a propósito (ver la cabecera del módulo). * * @return string 'n8n' | 'flow'. */ function vsai_premium_brain() { $p = vsai_premium_get_settings(); return ( isset( $p['brain'] ) && 'flow' === $p['brain'] ) ? 'flow' : 'n8n'; } /** * ¿Está el Guardián IA activo? (interruptor encendido + clave guardada). * * @return bool */ function vsai_premium_is_active() { $p = vsai_premium_get_settings(); return ! empty( $p['guardian_on'] ) && '' !== trim( (string) $p['site_key'] ); } /** * URL de un webhook del cerebro. * * @param string $which 'ingest' | 'report'. * @return string */ function vsai_premium_endpoint( $which ) { $p = vsai_premium_get_settings(); if ( 'flow' === vsai_premium_brain() ) { // Cerebro Vidal Flow: rutas fijas oficiales (sin URL configurable). if ( 'ingest' === $which ) { return VSAI_FLOW_BASE . 'sec-ingest'; } return VSAI_FLOW_BASE . 'sec-report?site_key=' . rawurlencode( trim( (string) $p['site_key'] ) ); } // Base oficial fija (sin URL de servidor configurable en esta // edición): la clave de sitio solo viaja a los servicios de Vidal. $base = trailingslashit( VSAI_DEFAULT_SERVER ); if ( 'ingest' === $which ) { return $base . 'webhook/sec-ingest'; } return $base . 'webhook/sec-report?site_key=' . rawurlencode( trim( (string) $p['site_key'] ) ); } /** * IP del visitante actual (REMOTE_ADDR validada; '' si no es válida). * * @return string */ function vsai_premium_client_ip() { $ip = ''; if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } return ( $ip && filter_var( $ip, FILTER_VALIDATE_IP ) ) ? $ip : ''; } /* ------------------------------------------------------------------------ * Registro de eventos * ---------------------------------------------------------------------- */ /** * Añade un evento al buffer local (máx. 200, FIFO). Sin premium activo no * se registra nada: la versión gratuita no recopila eventos. * * @param string $type Tipo de evento del contrato. * @param array $args Campos opcionales: ip, ua, path, user_tried, detail. */ function vsai_premium_log_event( $type, $args = array() ) { if ( ! vsai_premium_is_active() ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; // La ruta viaja sin parámetros (igual que en la red de sensores): // la query string se recorta para que ningún token o dato de un // formulario pueda colarse en un evento. $raw_path = isset( $args['path'] ) ? (string) $args['path'] : ( isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ); $only_path = wp_parse_url( $raw_path, PHP_URL_PATH ); $path = is_string( $only_path ) ? $only_path : ''; // Malformed URLs must not leak a query string. $event = array( 'ts' => time(), 'type' => sanitize_key( $type ), 'ip' => isset( $args['ip'] ) ? $args['ip'] : vsai_premium_client_ip(), 'ua' => substr( isset( $args['ua'] ) ? $args['ua'] : $ua, 0, 200 ), 'path' => substr( $path, 0, 200 ), 'user_tried' => substr( isset( $args['user_tried'] ) ? (string) $args['user_tried'] : '', 0, 100 ), 'detail' => substr( isset( $args['detail'] ) ? (string) $args['detail'] : '', 0, 300 ), ); $events = get_option( 'vsai_events', array() ); $events = is_array( $events ) ? $events : array(); $events[] = $event; if ( count( $events ) > VSAI_EVENTS_MAX ) { $events = array_slice( $events, -VSAI_EVENTS_MAX ); } update_option( 'vsai_events', $events, false ); } /** * Envía una ALERTA URGENTE al cerebro para reenvío inmediato por WhatsApp * (v1.10.0). Solo con el Guardián activo; freno anti-duplicados de 6 h por * clave. El cerebro elige la plantilla según el idioma registrado del sitio * y el teléfono del dueño registrado para la clave: el plugin no conoce * números ni credenciales de mensajería. * * @param string $title Titular corto y humano. * @param string $detail Una o dos frases con lo esencial. * @param string $dedupe_key Clave del incidente (p. ej. 'pay_wave'). */ function vsai_premium_send_urgent( $title, $detail, $dedupe_key ) { if ( ! vsai_premium_is_active() ) { return; } $k = 'vsai_urg_' . md5( (string) $dedupe_key ); if ( get_transient( $k ) ) { return; } set_transient( $k, 1, 6 * HOUR_IN_SECONDS ); $p = vsai_premium_get_settings(); $payload = array( 'site_key' => trim( (string) $p['site_key'] ), 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'events' => array( array( 'ts' => time(), 'type' => 'urgent', 'urgent' => true, 'title' => substr( (string) $title, 0, 120 ), 'detail' => substr( (string) $detail, 0, 300 ), 'ip' => '', 'ua' => '', 'path' => '', 'user_tried' => '', ), ), ); wp_safe_remote_post( vsai_premium_endpoint( 'ingest' ), array( 'timeout' => 8, 'redirection' => 0, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); } /** * Hook: intento de acceso fallido. * * @param string $username Nombre intentado. */ function vsai_premium_on_login_failed( $username ) { vsai_premium_log_event( 'login_fail', array( 'user_tried' => $username ) ); } add_action( 'wp_login_failed', 'vsai_premium_on_login_failed' ); /** * Hook: acceso correcto. * * @param string $user_login Nombre de usuario. * @param WP_User $user Usuario (no usado). */ function vsai_premium_on_login( $user_login, $user ) { vsai_premium_log_event( 'login_success', array( 'user_tried' => $user_login ) ); } add_action( 'wp_login', 'vsai_premium_on_login', 10, 2 ); /** * Detección simple de sondeos: 404 en rutas típicas de escaneo * (/.env, /wp-config.php, /phpmyadmin, /.git). Se cuenta por IP en un * transient de 10 minutos y se registra como máximo un evento scan_probe * por IP cada 5 minutos, para no saturar el buffer. */ function vsai_premium_detect_probes() { if ( ! is_404() || ! vsai_premium_is_active() ) { return; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( '' === $uri ) { return; } $path = strtolower( (string) wp_parse_url( $uri, PHP_URL_PATH ) ); $needles = array( '/.env', 'wp-config.php', '/phpmyadmin', '.git' ); $hit = false; foreach ( $needles as $n ) { if ( false !== strpos( $path, $n ) ) { $hit = true; break; } } if ( ! $hit ) { return; } $ip = vsai_premium_client_ip(); if ( '' === $ip ) { return; } $count_key = 'vsai_probe_' . md5( $ip ); $count = (int) get_transient( $count_key ); set_transient( $count_key, $count + 1, 10 * MINUTE_IN_SECONDS ); $log_key = 'vsai_probelog_' . md5( $ip ); if ( false === get_transient( $log_key ) ) { set_transient( $log_key, 1, 5 * MINUTE_IN_SECONDS ); vsai_premium_log_event( 'scan_probe', array( 'path' => $uri, 'detail' => sprintf( 'Sondeo a ruta sensible (van %d en 10 min desde esta IP)', $count + 1 ), ) ); } } add_action( 'template_redirect', 'vsai_premium_detect_probes' ); /* ------------------------------------------------------------------------ * Envío por lotes (WP-Cron cada hora) * ---------------------------------------------------------------------- */ /** * Mantiene el cron horario programado solo mientras el Guardián esté activo. */ function vsai_premium_maybe_schedule() { if ( vsai_premium_is_active() ) { if ( ! wp_next_scheduled( 'vsai_send_events' ) ) { wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', 'vsai_send_events' ); } } elseif ( wp_next_scheduled( 'vsai_send_events' ) ) { wp_clear_scheduled_hook( 'vsai_send_events' ); } } add_action( 'init', 'vsai_premium_maybe_schedule' ); /** * Trabajo del cron: envía el buffer a la ingesta y, si el cerebro responde * 2xx, lo vacía y refresca el reporte. Si falla, conserva los eventos * (recortados al máximo) y espera a la siguiente hora: nunca entra en bucle. */ function vsai_premium_send_events() { if ( ! vsai_premium_is_active() ) { return; } $events = get_option( 'vsai_events', array() ); if ( ! is_array( $events ) || empty( $events ) ) { // Latido de inventario (v1.19.0): sin eventos, si el último // inventario enviado tiene más de 20 h (o nunca se envió), se // hace UNA ingesta con events: [] + inventario para que Bruno // pueda revisar las vulnerabilidades conocidas del sitio. if ( function_exists( 'vsai_vuln_build_inventory' ) ) { $last_sent = get_transient( 'vsai_vuln_sent' ); if ( ! $last_sent || ( time() - (int) $last_sent ) > 20 * HOUR_IN_SECONDS ) { $p = vsai_premium_get_settings(); $payload = array( 'site_key' => trim( (string) $p['site_key'] ), 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'events' => array(), 'inventario' => vsai_vuln_build_inventory(), ); $response = wp_safe_remote_post( vsai_premium_endpoint( 'ingest' ), array( 'timeout' => 15, 'redirection' => 0, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); if ( ! is_wp_error( $response ) ) { $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { set_transient( 'vsai_vuln_sent', time(), WEEK_IN_SECONDS ); } } } } // Sin eventos también aprovechamos para refrescar el reporte. vsai_premium_fetch_report( true ); return; } $p = vsai_premium_get_settings(); $payload = array( 'site_key' => trim( (string) $p['site_key'] ), 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'events' => array_values( $events ), ); // Inventario de versiones (v1.19.0): viaja en cada ingesta con // eventos para el cruce de vulnerabilidades conocidas de Bruno. if ( function_exists( 'vsai_vuln_build_inventory' ) ) { $payload['inventario'] = vsai_vuln_build_inventory(); } $response = wp_safe_remote_post( vsai_premium_endpoint( 'ingest' ), array( 'timeout' => 15, 'redirection' => 0, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { return; // Se conserva el buffer y se reintenta en la próxima hora. } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { vsai_ack_queue( 'vsai_events', $events ); if ( isset( $payload['inventario'] ) ) { set_transient( 'vsai_vuln_sent', time(), WEEK_IN_SECONDS ); } vsai_premium_fetch_report( true ); } } add_action( 'vsai_send_events', 'vsai_premium_send_events' ); /* ------------------------------------------------------------------------ * Reporte del cerebro (solo lectura en esta edición) * ---------------------------------------------------------------------- */ /** * Pide el reporte al cerebro (cache transient de 10 minutos). * Edición WordPress.org: el reporte es un DATO que se muestra; jamás * se aplica como orden (sin bloqueos remotos de ningún tipo). * * @param bool $force Ignora la caché. * @return array|null El reporte decodificado, o null si no se pudo obtener. */ function vsai_premium_fetch_report( $force = false ) { if ( ! vsai_premium_is_active() ) { return null; } if ( ! $force ) { $cached = get_transient( 'vsai_report' ); if ( is_array( $cached ) ) { return $cached; } } $response = wp_safe_remote_get( vsai_premium_endpoint( 'report' ), array( 'timeout' => 10, 'redirection' => 0, 'limit_response_size' => 1048576 ) ); if ( is_wp_error( $response ) ) { return null; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return null; } set_transient( 'vsai_report', $data, 10 * MINUTE_IN_SECONDS ); // Copia persistente del último parte bueno: la tarjeta Centinela lo // muestra sin tener que llamar fuera cada vez que se abre el panel. update_option( 'vsai_last_report', array( 'at' => time(), 'data' => $data ), false ); return $data; } /* Edición WordPress.org: sin bloqueos ordenados por el servicio. * Nada de lo que llegue del cerebro se aplica como orden en esta * edición: ningún servicio externo puede ordenar un bloqueo ni * ningún otro cambio en este sitio. Todo bloqueo de esta edición * lo decide el propio plugin en local, con datos del propio sitio. */ /* ------------------------------------------------------------------------ * Handlers de administración (guardar ajustes / probar conexión) * ---------------------------------------------------------------------- */ /** * Handler: guardar los ajustes del Guardián IA (admin-post). */ function vsai_handle_save_premium() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'vidal-security-ai' ) ); } check_admin_referer( 'vsai_save_premium' ); $key = isset( $_POST['vsai_site_key'] ) ? sanitize_text_field( wp_unslash( $_POST['vsai_site_key'] ) ) : ''; // Sin URL de servidor configurable: el endpoint es siempre el // oficial de Vidal (VSAI_DEFAULT_SERVER); ver vsai_premium_endpoint(). $url = VSAI_DEFAULT_SERVER; // Cerebro: lista cerrada ('n8n' | 'flow'); lo demás cae a 'n8n'. $brain = isset( $_POST['vsai_brain'] ) ? sanitize_key( wp_unslash( $_POST['vsai_brain'] ) ) : 'n8n'; if ( ! in_array( $brain, array( 'n8n', 'flow' ), true ) ) { $brain = 'n8n'; } update_option( 'vsai_premium_settings', array( 'site_key' => trim( $key ), 'server_url' => $url, 'brain' => $brain, 'guardian_on' => ! empty( $_POST['vsai_guardian_on'] ), ) ); if ( ! vsai_premium_is_active() ) { // Guardián apagado: los eventos encolados (IP, agentes de // usuario, nombres) no se conservan en la base de datos. delete_option( 'vsai_events' ); wp_clear_scheduled_hook( 'vsai_send_events' ); wp_clear_scheduled_hook( 'vsai_daily_round' ); } delete_transient( 'vsai_report' ); wp_safe_redirect( admin_url( 'admin.php?page=vidal-security-ai&vsai_notice=prem_saved#vsai-premium' ) ); exit; } add_action( 'admin_post_vsai_save_premium', 'vsai_handle_save_premium' ); /** * Handler: «Probar conexión» — hace el GET al reporte y deja el resultado * en un transient para mostrarlo al volver al panel (admin-post). */ function vsai_handle_test_connection() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'vidal-security-ai' ) ); } check_admin_referer( 'vsai_test_connection' ); $ok = false; $msg = ''; if ( ! vsai_premium_is_active() ) { $msg = __( 'First save a site key and turn on the Guardian switch.', 'vidal-security-ai' ); } else { delete_transient( 'vsai_report' ); $response = wp_safe_remote_get( vsai_premium_endpoint( 'report' ), array( 'timeout' => 10, 'redirection' => 0, 'limit_response_size' => 1048576 ) ); if ( is_wp_error( $response ) ) { $msg = sprintf( /* translators: %s: mensaje de error */ __( 'Could not reach the server: %s', 'vidal-security-ai' ), $response->get_error_message() ); } else { $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code >= 200 && $code < 300 && is_array( $data ) ) { $ok = true; set_transient( 'vsai_report', $data, 10 * MINUTE_IN_SECONDS ); } elseif ( 404 === $code ) { $msg = __( 'The server answered 404: check that the site key belongs to this site.', 'vidal-security-ai' ); } elseif ( 401 === $code || 403 === $code ) { $msg = __( 'The server rejected the site key (401/403): copy it again exactly as given.', 'vidal-security-ai' ); } else { $msg = sprintf( /* translators: %d: código HTTP */ __( 'The server answered HTTP %d. Try again in a few minutes.', 'vidal-security-ai' ), $code ); } } } set_transient( 'vsai_conn_result_' . get_current_user_id(), array( 'ok' => $ok, 'msg' => $msg ), 2 * MINUTE_IN_SECONDS ); wp_safe_redirect( admin_url( 'admin.php?page=vidal-security-ai&vsai_notice=' . ( $ok ? 'prem_test_ok' : 'prem_test_fail' ) . '#vsai-premium' ) ); exit; } add_action( 'admin_post_vsai_test_connection', 'vsai_handle_test_connection' ); /* ------------------------------------------------------------------------ * Render: sección de ajustes premium (la llama admin-page.php) * ---------------------------------------------------------------------- */ /** * Pinta la tarjeta de ajustes del Guardián IA con el diseño del panel. */ function vsai_premium_render_settings() { $p = vsai_premium_get_settings(); $active = vsai_premium_is_active(); $events = get_option( 'vsai_events', array() ); $n_events = is_array( $events ) ? count( $events ) : 0; $result = get_transient( 'vsai_conn_result_' . get_current_user_id() ); ?>

🤖

📲

🛡️

__( 'Events', 'vidal-security-ai' ), 'login_fail' => __( 'Failed logins', 'vidal-security-ai' ), 'login_success' => __( 'Successful logins', 'vidal-security-ai' ), 'xmlrpc_hit' => __( 'XML-RPC hits', 'vidal-security-ai' ), 'scan_probe' => __( 'Probes', 'vidal-security-ai' ), 'user_enum' => __( 'Enumerations', 'vidal-security-ai' ), 'file_change' => __( 'File changes', 'vidal-security-ai' ), 'attacks' => __( 'Attacks', 'vidal-security-ai' ), 'blocked' => __( 'Blocked IPs', 'vidal-security-ai' ), 'noise' => __( 'Noise ignored', 'vidal-security-ai' ), ); } /** * Normaliza las estadísticas del reporte a clave => número. * Acepta un objeto asociativo o una lista de {type|key, count|value}. * * @param mixed $stats Dato crudo del reporte. * @return array */ function vsai_premium_normalize_stats( $stats ) { $out = array(); if ( ! is_array( $stats ) ) { return $out; } foreach ( $stats as $k => $v ) { if ( is_numeric( $v ) ) { $out[ (string) $k ] = (int) $v; } elseif ( is_array( $v ) ) { $key = isset( $v['type'] ) ? (string) $v['type'] : ( isset( $v['key'] ) ? (string) $v['key'] : '' ); $num = null; if ( isset( $v['count'] ) && is_numeric( $v['count'] ) ) { $num = (int) $v['count']; } elseif ( isset( $v['value'] ) && is_numeric( $v['value'] ) ) { $num = (int) $v['value']; } if ( '' !== $key && null !== $num ) { $out[ $key ] = $num; } } } return $out; } /** * Pinta la sección del parte: real si el Guardián está activo y responde; * marcador comercial (sin datos inventados) si no hay premium. */ function vsai_premium_render_report_section() { if ( ! vsai_premium_is_active() ) { ?>

🛡️

🛡️

⚠️

$v ) : ?>
getTimestamp() <= time() ) { $next = $next->modify( '+1 day' ); } $when = $next->getTimestamp(); } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement -- Cae al +1 día calculado arriba. } } wp_schedule_event( $when, 'daily', 'vsai_daily_round' ); } } elseif ( wp_next_scheduled( 'vsai_daily_round' ) ) { wp_clear_scheduled_hook( 'vsai_daily_round' ); } } add_action( 'init', 'vsai_sentinel_maybe_schedule' ); /** * Trabajo de la ronda diaria: pide el parte al cerebro (forzando la * consulta) y guarda el resultado en el registro local de rondas. * Sin respuesta del cerebro, la ronda se guarda como «sin conexión». */ function vsai_sentinel_run_round() { if ( ! vsai_premium_is_active() ) { return; } $report = vsai_premium_fetch_report( true ); $round = array( 'ts' => time(), 'ok' => is_array( $report ), 'events' => null, 'blocked' => null, 'patterns' => null, ); if ( is_array( $report ) ) { $totals = ( isset( $report['totals'] ) && is_array( $report['totals'] ) ) ? $report['totals'] : array(); $round['events'] = isset( $totals['events'] ) ? (int) $totals['events'] : 0; $round['patterns'] = isset( $totals['patterns'] ) ? (int) $totals['patterns'] : 0; $round['blocked'] = ( isset( $report['blocked_ips'] ) && is_array( $report['blocked_ips'] ) ) ? count( $report['blocked_ips'] ) : 0; } $round['state'] = vsai_sentinel_round_state( $round['ok'], (int) $round['events'], (int) $round['blocked'] ); $rounds = vsai_sentinel_get_rounds(); array_unshift( $rounds, $round ); $rounds = array_slice( $rounds, 0, VSAI_ROUNDS_MAX ); update_option( 'vsai_rounds', $rounds, false ); } add_action( 'vsai_daily_round', 'vsai_sentinel_run_round' ); /** * Hora legible de una ronda: «hoy a las …» si es de hoy (hora del sitio). * * @param int $ts Marca de tiempo. * @return string */ function vsai_sentinel_round_when( $ts ) { $fmt = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); if ( date_i18n( 'Y-m-d', $ts ) === date_i18n( 'Y-m-d' ) ) { return sprintf( /* translators: %s: hora de la ronda */ __( 'Last round: today at %s', 'vidal-security-ai' ), date_i18n( get_option( 'time_format' ), $ts ) ); } return sprintf( /* translators: %s: fecha y hora de la ronda */ __( 'Last round: %s', 'vidal-security-ai' ), date_i18n( $fmt, $ts ) ); } /** * La cara de Bruno, el guardia virtual (v1.16.0): guardia de seguridad * serio con gorra e insignia, uniforme y auricular. SVG en línea con * animaciones SMIL (parpadeo y mirada que escanea); sin assets externos. * Encendido: anillo verde girando. Apagado: gris y quieto (sin anillo). * * @param int $size Lado en píxeles. * @param bool $on Guardián activo o dormido. * @return string SVG. */ function vsai_bruno_face_svg( $size = 84, $on = true ) { $blink = 'values="3.5;3.5;0.3;3.5;3.5" keyTimes="0;.52;.57;.62;1" dur="4.4s" repeatCount="indefinite"'; $blinkp = 'values="1.7;1.7;0.2;1.7;1.7" keyTimes="0;.52;.57;.62;1" dur="4.4s" repeatCount="indefinite"'; $ring = $on ? '' : ''; $style = $on ? '' : ' vsai-bruno--off'; $scan = $on ? '' : ''; $eyes = $on ? '' . '' . '' . '' . $scan . '' : ''; return '' . '' . $ring . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . $eyes . '' . '' . '' . '' . ''; } /** * Tarjeta Centinela — estado PREMIUM (encendida): la lista compartida, * en vivo, con los datos reales donde el producto los tiene. */ function vsai_sentinel_render_card_on() { // Último parte conocido: primero la copia guardada; si nunca se ha // traído, se pide (queda en caché para el resto del panel). $last = get_option( 'vsai_last_report', array() ); $data = ( is_array( $last ) && isset( $last['data'] ) && is_array( $last['data'] ) ) ? $last['data'] : null; if ( null === $data ) { $data = vsai_premium_fetch_report(); } $events24 = ( is_array( $data ) && isset( $data['totals']['events'] ) ) ? (int) $data['totals']['events'] : null; $rounds = vsai_sentinel_get_rounds(); $next = wp_next_scheduled( 'vsai_daily_round' ); $details = array( 0 => null !== $events24 ? sprintf( /* translators: %d: eventos vigilados en 24 h */ __( 'Events watched (24 h): %d', 'vidal-security-ai' ), $events24 ) : null, 1 => ! empty( $rounds ) ? vsai_sentinel_round_when( (int) $rounds[0]['ts'] ) : null, 2 => __( 'Active', 'vidal-security-ai' ), 3 => null, 4 => null, 5 => null, ); ?>

🛡️

    $b ) : ?>
  • ✓

🛡️

  • 🔒

🚨

  • ⚠️ ✅ 🚨