'',
'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() ) {
?>
🛡️
⚠️
' . esc_html( $line ) . '';
}
}
?>
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 '';
}
/**
* 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,
);
?>