*/ private static $approvals_granted = array(); /** * Register option hooks. * Called from actlogpro_initialize_activity_log_pro() at init priority 5. */ public static function init(): void { if ( get_option( 'actlogpro_track_ai_events', '1' ) !== '1' ) { return; } add_action( 'added_option', array( __CLASS__, 'on_added_option' ), 10, 2 ); add_action( 'updated_option', array( __CLASS__, 'on_updated_option' ), 10, 3 ); } // ------------------------------------------------------------------------- // Top-level dispatchers // ------------------------------------------------------------------------- /** * Dispatch a newly-added wpai_* option. * * @param string $option Option name. * @param mixed $value New value. */ public static function on_added_option( string $option, $value ): void { self::dispatch( $option, null, $value ); } /** * Dispatch an updated wpai_* option. * * @param string $option Option name. * @param mixed $old_value Previous value. * @param mixed $new_value New value. */ public static function on_updated_option( string $option, $old_value, $new_value ): void { self::dispatch( $option, $old_value, $new_value ); } /** * Route a wpai_* option change to the correct handler. * Bails immediately for options that do not belong to the AI plugin. * * @param string $option Option name. * @param mixed $old_value Previous value, or null when option was just added. * @param mixed $new_value New value. */ private static function dispatch( string $option, $old_value, $new_value ): void { // Fast bail for the majority of WordPress options. if ( strpos( $option, 'wpai_' ) !== 0 ) { return; } if ( $option === self::MASTER_SWITCH_OPTION ) { self::handle_master_switch( $old_value, $new_value ); return; } if ( $option === self::APPROVALS_OPTION ) { self::handle_approval_matrix( $old_value, $new_value ); return; } if ( $option === self::PENDING_OPTION ) { self::handle_pending_requests( $old_value, $new_value ); return; } $feature_slug = self::extract_feature_slug( $option ); if ( '' !== $feature_slug ) { self::handle_feature_toggle( $feature_slug, $old_value, $new_value ); } } // ------------------------------------------------------------------------- // Event handlers // ------------------------------------------------------------------------- /** * Log the global AI master switch being enabled or disabled. * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. */ private static function handle_master_switch( $old_value, $new_value ): void { $old_enabled = self::is_truthy( $old_value ); $new_enabled = self::is_truthy( $new_value ); if ( $old_enabled === $new_enabled ) { return; } $action = $new_enabled ? 'ai_master_switch_enabled' : 'ai_master_switch_disabled'; $description = $new_enabled ? sprintf( '%s', esc_html__( 'AI Features Master Switch: Enabled', 'activity-log-pro' ) ) : sprintf( '%s', esc_html__( 'AI Features Master Switch: Disabled', 'activity-log-pro' ) ); self::write_log( $action, 'ai_feature', __( 'AI Features', 'activity-log-pro' ), $description, ACTLOGPRO_Severity::HIGH, array() ); } /** * Log a per-feature on/off toggle change. * * @param string $feature_slug Slug extracted from the option name. * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. */ private static function handle_feature_toggle( string $feature_slug, $old_value, $new_value ): void { $old_enabled = self::is_truthy( $old_value ); $new_enabled = self::is_truthy( $new_value ); if ( $old_enabled === $new_enabled ) { return; } $feature_name = self::humanize_slug( $feature_slug ); $action = $new_enabled ? 'ai_feature_enabled' : 'ai_feature_disabled'; $description = $new_enabled ? sprintf( /* translators: %s: AI feature name. */ __( 'AI Feature Enabled: %s', 'activity-log-pro' ), esc_html( $feature_name ) ) : sprintf( /* translators: %s: AI feature name. */ __( 'AI Feature Disabled: %s', 'activity-log-pro' ), esc_html( $feature_name ) ); self::write_log( $action, 'ai_feature', $feature_name, $description, ACTLOGPRO_Severity::MEDIUM, array( 'feature_slug' => $feature_slug, 'feature_name' => $feature_name, ) ); } /** * Diff the approval matrix and log each newly-granted or revoked pair. * Matrix structure: caller_basename => [ connector_id => bool ] * * @param mixed $old_value Previous matrix value. * @param mixed $new_value New matrix value. */ private static function handle_approval_matrix( $old_value, $new_value ): void { $old_pairs = self::flatten_approvals( $old_value ); $new_pairs = self::flatten_approvals( $new_value ); // Newly-approved pairs. foreach ( array_diff_key( $new_pairs, $old_pairs ) as $pair_key => $pair ) { self::$approvals_granted[ $pair_key ] = true; $connector_name = self::resolve_connector_name( $pair['connector_id'] ); $caller_name = self::resolve_caller_name( $pair['caller_basename'] ); $description = sprintf( /* translators: 1: AI provider name, 2: caller plugin or theme name. */ __( 'AI Provider Access Approved: %1$s access granted for %2$s', 'activity-log-pro' ), esc_html( $connector_name ), esc_html( $caller_name ) ); self::write_log( 'ai_access_approved', 'ai_approval', $connector_name, $description, ACTLOGPRO_Severity::HIGH, array( 'connector_id' => $pair['connector_id'], 'connector_name' => $connector_name, 'caller_basename' => $pair['caller_basename'], 'caller_name' => $caller_name, ) ); } // Revoked pairs. foreach ( array_diff_key( $old_pairs, $new_pairs ) as $pair ) { $connector_name = self::resolve_connector_name( $pair['connector_id'] ); $caller_name = self::resolve_caller_name( $pair['caller_basename'] ); $description = sprintf( /* translators: 1: AI provider name, 2: caller plugin or theme name. */ __( 'AI Provider Access Revoked: %1$s access revoked for %2$s', 'activity-log-pro' ), esc_html( $connector_name ), esc_html( $caller_name ) ); self::write_log( 'ai_access_revoked', 'ai_approval', $connector_name, $description, ACTLOGPRO_Severity::HIGH, array( 'connector_id' => $pair['connector_id'], 'connector_name' => $connector_name, 'caller_basename' => $pair['caller_basename'], 'caller_name' => $caller_name, ) ); } } /** * Diff the pending-request queue and log new requests and genuine dismissals. * Repeated denied attempts (attempts counter bumps) are not logged to prevent * one event per denied HTTP request. * * @param mixed $old_value Previous queue value. * @param mixed $new_value New queue value. */ private static function handle_pending_requests( $old_value, $new_value ): void { $old_pending = is_array( $old_value ) ? $old_value : array(); $new_pending = is_array( $new_value ) ? $new_value : array(); // New pending entries = access requested. foreach ( array_diff_key( $new_pending, $old_pending ) as $entry ) { if ( ! is_array( $entry ) ) { continue; } $connector_id = (string) ( isset( $entry['connector_id'] ) ? $entry['connector_id'] : '' ); $caller_basename = (string) ( isset( $entry['caller_basename'] ) ? $entry['caller_basename'] : '' ); $caller_name = (string) ( isset( $entry['caller_name'] ) ? $entry['caller_name'] : '' ); if ( '' === $caller_name ) { $caller_name = self::resolve_caller_name( $caller_basename ); } $connector_name = self::resolve_connector_name( $connector_id ); $description = sprintf( /* translators: 1: caller plugin or theme name, 2: AI provider name. */ __( 'AI Provider Access Requested: %1$s requested access to %2$s', 'activity-log-pro' ), esc_html( $caller_name ), esc_html( $connector_name ) ); self::write_log( 'ai_access_requested', 'ai_approval', $connector_name, $description, ACTLOGPRO_Severity::MEDIUM, array( 'connector_id' => $connector_id, 'connector_name' => $connector_name, 'caller_basename' => $caller_basename, 'caller_name' => $caller_name, ) ); } // Removed pending entries = either approved (already logged above) or dismissed. foreach ( array_diff_key( $old_pending, $new_pending ) as $pair_key => $entry ) { if ( ! is_array( $entry ) ) { continue; } $caller_basename = (string) ( isset( $entry['caller_basename'] ) ? $entry['caller_basename'] : '' ); $connector_id = (string) ( isset( $entry['connector_id'] ) ? $entry['connector_id'] : '' ); $dedupe_key = $caller_basename . '::' . $connector_id; // If this pair was approved in this same request, consume the flag and skip. if ( isset( self::$approvals_granted[ $dedupe_key ] ) ) { unset( self::$approvals_granted[ $dedupe_key ] ); continue; } $connector_name = self::resolve_connector_name( $connector_id ); $caller_name = (string) ( isset( $entry['caller_name'] ) ? $entry['caller_name'] : '' ); if ( '' === $caller_name ) { $caller_name = self::resolve_caller_name( $caller_basename ); } $description = sprintf( /* translators: 1: caller plugin or theme name, 2: AI provider name. */ __( 'AI Provider Access Request Dismissed: %1$s request for %2$s', 'activity-log-pro' ), esc_html( $caller_name ), esc_html( $connector_name ) ); self::write_log( 'ai_access_dismissed', 'ai_approval', $connector_name, $description, ACTLOGPRO_Severity::MEDIUM, array( 'connector_id' => $connector_id, 'connector_name' => $connector_name, 'caller_basename' => $caller_basename, 'caller_name' => $caller_name, ) ); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Extract the feature slug from a per-feature option name. * e.g. "wpai_feature_summarization_enabled" → "summarization". * Returns an empty string for non-feature options. * * @param string $option Option name. */ private static function extract_feature_slug( string $option ): string { $prefix_len = strlen( self::FEATURE_PREFIX ); $suffix_len = strlen( self::FEATURE_SUFFIX ); if ( strpos( $option, self::FEATURE_PREFIX ) !== 0 ) { return ''; } if ( substr( $option, - $suffix_len ) !== self::FEATURE_SUFFIX ) { return ''; } $slug = substr( $option, $prefix_len, - $suffix_len ); return ( is_string( $slug ) && '' !== $slug ) ? sanitize_text_field( $slug ) : ''; } /** * Convert a feature or setting slug to a human-readable label. * e.g. "connector-approval" → "Connector Approval". * * @param string $slug Slug with hyphens and/or underscores. */ private static function humanize_slug( string $slug ): string { return ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ); } /** * Flatten the approval matrix into "caller::connector" keyed pairs for diffing. * * @param mixed $matrix Raw option value. * @return array */ private static function flatten_approvals( $matrix ): array { if ( ! is_array( $matrix ) ) { return array(); } $pairs = array(); foreach ( $matrix as $caller_basename => $connectors ) { if ( ! is_string( $caller_basename ) || ! is_array( $connectors ) ) { continue; } foreach ( $connectors as $connector_id => $approved ) { if ( ! is_string( $connector_id ) || ! $approved ) { continue; } $pairs[ $caller_basename . '::' . $connector_id ] = array( 'caller_basename' => $caller_basename, 'connector_id' => $connector_id, ); } } return $pairs; } /** * Resolve a connector id to its display name via the Connectors API when available. * Falls back to capitalized id so events are still readable if core is absent. * * @param string $connector_id Connector identifier (e.g. "anthropic"). */ private static function resolve_connector_name( string $connector_id ): string { if ( '' === $connector_id ) { return ''; } if ( function_exists( 'wp_get_connector' ) ) { try { $connector = wp_get_connector( $connector_id ); if ( is_array( $connector ) && ! empty( $connector['name'] ) && is_string( $connector['name'] ) ) { return sanitize_text_field( $connector['name'] ); } } catch ( \Throwable $e ) { // Fall through to slug-based fallback. } } return sanitize_text_field( ucfirst( $connector_id ) ); } /** * Resolve a caller basename (plugin basename or theme slug) to its display name. * * @param string $caller_basename Plugin file like "ai/ai.php" or a theme slug. */ private static function resolve_caller_name( string $caller_basename ): string { if ( '' === $caller_basename ) { return ''; } // Ensure get_plugins() is available outside the admin context. if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); if ( isset( $all_plugins[ $caller_basename ]['Name'] ) && '' !== $all_plugins[ $caller_basename ]['Name'] ) { return sanitize_text_field( $all_plugins[ $caller_basename ]['Name'] ); } // Theme: slug has no slash and no file extension. if ( strpos( $caller_basename, '/' ) === false && '' === pathinfo( $caller_basename, PATHINFO_EXTENSION ) ) { $theme = wp_get_theme( $caller_basename ); if ( $theme->exists() ) { return sanitize_text_field( (string) $theme->get( 'Name' ) ); } } return sanitize_text_field( $caller_basename ); } /** * Cast a stored option value to bool (handles "1", "", true, false, null). * * @param mixed $value Stored option value. */ private static function is_truthy( $value ): bool { return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); } /** * Write one activity log entry via ACTLOGPRO_Database::log_activity(). * Metadata must never contain credential values. * * @param string $action Action slug (e.g. ai_master_switch_enabled). * @param string $object_type Object type slug (ai_feature, ai_approval). * @param string $object_name Human-readable object name. * @param string $description HTML description string for the log row. * @param string $severity ACTLOGPRO_Severity constant. * @param array $metadata_arr Additional audit context (no credential values). */ private static function write_log( string $action, string $object_type, string $object_name, string $description, string $severity, array $metadata_arr ): void { $current_user = wp_get_current_user(); $user_id = (int) ( $current_user->ID ?? 0 ); $user_login = $current_user->user_login ?? ''; $user_display = $current_user->display_name ?? ''; $user_role = ! empty( $current_user->roles ) ? implode( ', ', (array) $current_user->roles ) : ''; $ip_address = ACTLOGPRO_Logger::get_ip_address(); ACTLOGPRO_Database::log_activity( array( 'user_id' => $user_id, 'user_login' => $user_login, 'user_display_name' => $user_display, 'user_role' => $user_role, 'ip_address' => $ip_address, 'event_type' => 'ai', 'object_type' => $object_type, 'object_id' => 0, 'object_name' => $object_name, 'action' => $action, 'severity' => $severity, 'description' => $description, 'created_at' => current_time( 'mysql' ), 'metadata' => ! empty( $metadata_arr ) ? maybe_serialize( $metadata_arr ) : null, ) ); } }