*/
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,
) );
}
}