= 10 in a day is the published hygiene threshold) * - an account created outside 07:00-21:00 site time, or on a weekend * - a role change to administrator * - a plugin or theme installed or activated * - more than N users created in one day (a bulk-registration signature) * * The summary layer uses the WordPress AI Client when explicitly enabled * and a provider is configured. Without one, the flags and a deterministic * events-overview sentence still ship in the digest -- the feature * degrades to factual, never disappears. */ final class AiSummary { /** Failed logins in one day that constitute a burst. */ public const FAILED_LOGIN_BURST = 10; /** New users in one day above which a flag reads as bulk registration. */ public const BULK_USER_THRESHOLD = 10; /** The window (site-local hour) inside which user creation is routine. */ public const BUSINESS_HOURS = array( 7, 21 ); /** Explicit consent to send Activity Log data to the configured AI provider. */ public const OPTION_AI_ENABLED = 'ops_activity_log_ai_enabled'; /** Whether an earlier AI connection needs the administrator's attention. */ public static function needs_migration_notice(): bool { if ( null !== get_option( self::OPTION_AI_ENABLED, null ) ) { return false; } $legacy = get_option( 'ops404_ai_settings', array() ); $entry = is_array( $legacy ) ? ( $legacy['plugins']['404ops-activity-log'] ?? null ) : null; return is_array( $entry ) && ( true === ( $entry['enabled'] ?? false ) || is_array( $entry['override'] ?? null ) ); } /** * Deterministic anomaly flags over one day's events. * * @param array> $events ActivityLog::get_events() rows. * @return array */ public static function flag_anomalies( array $events ): array { $flags = array(); $failed = 0; $users_created = 0; foreach ( $events as $event ) { $type = (string) ( $event['event_type'] ?? $event['action'] ?? '' ); if ( 'user_login_failed' === $type || 'login_failed' === $type ) { ++$failed; } if ( 'user_created' === $type ) { ++$users_created; // Off-hours or weekend creation: the classic quiet-window // compromise step. Site time, because "outside business // hours" is a statement about the site's humans. $ts = isset( $event['timestamp'] ) ? strtotime( (string) $event['timestamp'] ) : false; if ( false !== $ts ) { $hour = (int) gmdate( 'G', $ts + (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ); $dow = (int) gmdate( 'N', $ts + (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ); if ( ( $hour < self::BUSINESS_HOURS[0] || $hour >= self::BUSINESS_HOURS[1] ) || $dow >= 6 ) { $flags[] = array( 'level' => 'warning', 'text' => sprintf( /* translators: %s: time the account was created (site time). */ __( 'A user account was created outside business hours (%s) -- verify it was expected.', '404ops-activity-log' ), gmdate( 'H:i', $ts + (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ) ), ); } } } if ( 'user_role_changed' === $type ) { // The new role lives in the details JSON; the summary line // names it too ("role changed to administrator"), so both // paths are checked. $raw_details = isset( $event['details'] ) ? $event['details'] : ''; $details = json_decode( is_string( $raw_details ) ? $raw_details : (string) wp_json_encode( $raw_details ), true ); $new_role = is_array( $details ) ? (string) ( $details['new_role'] ?? '' ) : ''; $summary_line = (string) ( $event['summary'] ?? '' ); if ( 'administrator' === $new_role || false !== stripos( $summary_line, 'administrator' ) ) { $flags[] = array( 'level' => 'critical', 'text' => __( 'A user\'s role was changed to administrator -- confirm this change was authorised.', '404ops-activity-log' ), ); } } if ( in_array( $type, array( 'plugin_activated', 'plugin_installed', 'theme_installed', 'theme_activated' ), true ) ) { $flags[] = array( 'level' => 'warning', 'text' => sprintf( /* translators: %s: the event's object name. */ __( 'An extension changed today: %s.', '404ops-activity-log' ), (string) ( $event['summary'] ?? $type ) ), ); } } if ( $failed >= self::FAILED_LOGIN_BURST ) { $flags[] = array( 'level' => 'warning', /* translators: %d: number of failed logins. */ 'text' => sprintf( __( '%d failed logins in one day -- a brute-force attempt or a forgotten password shared by a compromised client.', '404ops-activity-log' ), $failed ), ); } if ( $users_created >= self::BULK_USER_THRESHOLD ) { $flags[] = array( 'level' => 'warning', /* translators: %d: number of accounts created. */ 'text' => sprintf( __( '%d new user accounts in one day -- bulk registration, or a subscription drive you know about.', '404ops-activity-log' ), $users_created ), ); } return $flags; } /** * The AI summary of the day, or null (the digest falls back to flags + * the deterministic overview). BYOK: null also when no provider is * connected -- the digest does not require one to ship. * * @param array> $events * @param array $flags * @return array{text:string,model:string}|null */ public static function summarize( array $events, array $flags ): ?array { $result = self::complete( 'You are a WordPress site owner\'s security-conscious assistant. Given one day\'s activity events and locally-detected anomaly flags, write a 2-4 sentence plain-English summary: what happened today in aggregate, which flags matter most and why, and what (if anything) the owner should check first. Calm, factual, no preamble, no markdown.', self::prompt_body( $events, $flags ), 400 ); return null === $result ? null : array( 'text' => $result['text'], 'model' => $result['model'], ); } /** * Whether Activity Log is enabled and WordPress can generate text. */ public static function is_connected( int $max_tokens = 600 ): bool { if ( ! get_option( self::OPTION_AI_ENABLED, false ) || ! function_exists( 'wp_ai_client_prompt' ) ) { return false; } return wp_ai_client_prompt( 'Activity Log' ) ->using_system_instruction( 'Activity Log' ) ->using_max_tokens( max( 50, $max_tokens ) ) ->as_output_modalities( ModalityEnum::text() ) ->is_supported_for_text_generation(); } /** * The WordPress Connectors screen for configuring a provider. */ public static function connection_url(): string { return admin_url( 'options-connectors.php' ); } /** * One completion through the WordPress AI Client, for a caller * that has already decided what to send. The Pro add-on's weekly * summary is that caller: it builds and redacts its own prompt, and * reaches the AI only through this method, so the add-on never names * provider classes and WordPress manages the site-wide connection. * * Sends exactly `$instructions` and `$body`; the daily digest uses this * same entry point with its own prompt. * * @param string $instructions The system prompt. * @param string $body The user message. * @param int $max_tokens Output budget. * @return array{text:string,model:string,provider:string}|null Null without a connection, on any AI error, or for an empty answer. */ public static function complete( string $instructions, string $body, int $max_tokens = 600 ): ?array { if ( ! self::is_connected( $max_tokens ) ) { return null; } $response = wp_ai_client_prompt( $body ) ->using_system_instruction( $instructions ) ->using_max_tokens( max( 50, $max_tokens ) ) ->generate_text_result(); if ( is_wp_error( $response ) ) { return null; } try { $text = trim( $response->toText() ); } catch ( \RuntimeException $e ) { return null; } return '' !== $text ? array( 'text' => $text, 'model' => $response->getModelMetadata()->getId(), 'provider' => $response->getProviderMetadata()->getId(), ) : null; } /** * The prompt body: event-type counts, notable objects, and the flags. * Counts, not raw rows -- a 100-event day becomes ~15 lines, and the * summary asks for the shape of the day, not its transcript. * * @param array> $events * @param array $flags */ private static function prompt_body( array $events, array $flags ): string { $counts = array(); foreach ( $events as $event ) { $type = (string) ( $event['event_type'] ?? $event['action'] ?? 'unknown' ); if ( ! isset( $counts[ $type ] ) ) { $counts[ $type ] = 0; } ++$counts[ $type ]; } $lines = array(); $lines[] = sprintf( 'Total events today: %d', count( $events ) ); foreach ( $counts as $type => $count ) { $lines[] = sprintf( '%s: %d', $type, $count ); } if ( array() !== $flags ) { $lines[] = 'Locally-detected anomaly flags:'; foreach ( $flags as $flag ) { $lines[] = '- [' . $flag['level'] . '] ' . $flag['text']; } } return implode( "\n", $lines ); } /** * The deterministic overview: one sentence of counts. This is the whole * AI layer a site with no provider gets, and it is real information. * * @param array> $events */ public static function local_overview( array $events ): string { $counts = array(); foreach ( $events as $event ) { $type = (string) ( $event['event_type'] ?? $event['action'] ?? 'unknown' ); if ( ! isset( $counts[ $type ] ) ) { $counts[ $type ] = 0; } ++$counts[ $type ]; } arsort( $counts ); $top = array_slice( $counts, 0, 3, true ); $bits = array(); foreach ( $top as $type => $count ) { $bits[] = sprintf( '%d %s', $count, str_replace( '_', ' ', $type ) ); } /* translators: %s: comma-separated "count type" fragments. */ return sprintf( __( '%s today.', '404ops-activity-log' ), implode( ', ', $bits ) ); } }