$key, 'camera_secret' => $camera_secret, 'endpoint' => $endpoint ? $endpoint : AIMENTION_VIS_DEFAULT_ENDPOINT, 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), 'ip' => array( 'proxy' => in_array( $proxy, array( 'none', 'cloudflare', 'custom' ), true ) ? $proxy : 'none', 'header' => $header, 'trusted_ranges' => array_filter( array_map( 'trim', preg_split( '/[\s,]+/', $ranges ) ) ), ), ); } /* * Activation / deactivation. */ register_activation_hook( __FILE__, 'aimention_vis_activate' ); /** * Activation: create the local queue table; resume the schedule only if a key is already set. * * @return void */ function aimention_vis_activate() { // Local only: create the queue table. A fresh, unconnected install makes NO // external call and schedules nothing -- the plugin stays completely silent // until the user connects (opts in) by setting a collection key. If a key is // already present (e.g. reactivation of a connected site), resume the schedule. AIMention_Queue::create_table(); if ( '' !== trim( (string) get_option( 'aimention_vis_key', '' ) ) ) { aimention_vis_on_connect(); } } add_action( 'add_option_aimention_vis_key', 'aimention_vis_on_connect' ); add_action( 'update_option_aimention_vis_key', 'aimention_vis_on_connect' ); /** * Opt-in point: the moment a collection key is stored (via the in-plugin sign-up * or by pasting a key on Settings), spin up the background schedule and pull the * crawler directory. This is the plugin's FIRST external call -- nothing reaches * our servers before the user connects. * * @return void */ function aimention_vis_on_connect() { if ( '' === trim( (string) get_option( 'aimention_vis_key', '' ) ) ) { return; } if ( ! wp_next_scheduled( 'aimention_vis_process' ) ) { wp_schedule_event( time() + 60, 'aimention_vis_5min', 'aimention_vis_process' ); } if ( ! wp_next_scheduled( 'aimention_vis_refresh_directory' ) ) { wp_schedule_event( time() + 120, 'daily', 'aimention_vis_refresh_directory' ); } // Pull the directory now (off the current request) so detection can start. wp_schedule_single_event( time() + 5, 'aimention_vis_refresh_directory' ); } register_deactivation_hook( __FILE__, 'aimention_vis_deactivate' ); /** * Deactivation: clear the plugin's scheduled hooks. * * @return void */ function aimention_vis_deactivate() { wp_clear_scheduled_hook( 'aimention_vis_process' ); wp_clear_scheduled_hook( 'aimention_vis_refresh_directory' ); } add_filter( 'cron_schedules', 'aimention_vis_cron_schedules' ); // phpcs:ignore WordPress.WP.CronInterval -- a 5-minute drain is a deliberate product choice; the job is a lightweight bounded batch that keeps the raw-IP queue clearing well before its 24h cap. /** * Register the plugin's 5-minute cron schedule. * * @param array $schedules Existing cron schedules. * @return array Schedules with the plugin's interval added. */ function aimention_vis_cron_schedules( $schedules ) { $schedules['aimention_vis_5min'] = array( 'interval' => 300, 'display' => __( 'Every 5 minutes (AI Mention)', 'ai-mention-visibility' ), ); return $schedules; } /* * Request capture -- the ONLY thing that runs while a visitor waits. */ add_action( 'init', 'aimention_vis_capture', 1 ); /** * Detect a verified-candidate AI crawler on the current request and queue it. * * @return void */ function aimention_vis_capture() { if ( is_admin() || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua || ! aimention_vis_ua_hint( $ua ) ) { return; // human fast-path: one substring check, no DB, no work. } $snapshot = AIMention_Directory::get(); if ( ! $snapshot ) { return; // no directory pulled yet -> can't identify the agent. } $agent = AIMention_Directory::match_agent( $ua, $snapshot ); if ( ! $agent ) { return; // UA looked bot-ish but is not a known crawler. } $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; // plugin not configured yet. } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- values are validated with FILTER_VALIDATE_IP in the resolver; unslashing/altering them would break IP matching. $ip = AIMention_IP_Resolver::resolve( $_SERVER, $config['ip'] ); if ( ! $ip ) { return; } AIMention_Queue::enqueue( array( 'event_id' => wp_generate_uuid4(), 'path' => aimention_vis_current_path(), 'agent_id' => (string) $agent['agent_id'], 'operator' => (string) $agent['operator'], 'agent_purpose' => (string) $agent['agent_purpose'], 'raw_ip' => $ip, 'created_at' => time(), ) ); } /** * Cheap pre-filter so the vast majority of (human) requests do zero work. * Covers every current agent UA: all but two contain "bot"; the two that don't * are ChatGPT-User and Perplexity-User. * * @param string $ua The request User-Agent string. * @return bool True if the UA is worth the fuller directory match. */ function aimention_vis_ua_hint( $ua ) { return false !== stripos( $ua, 'bot' ) || false !== stripos( $ua, 'ChatGPT-User' ) || false !== stripos( $ua, 'Perplexity-User' ); } /** * The current request path (no query string), capped at 512 chars. * * @return string The request path. */ function aimention_vis_current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = wp_parse_url( $uri, PHP_URL_PATH ); return $path ? substr( $path, 0, 512 ) : '/'; } /* * Cron: refresh the directory, process the queue. */ add_action( 'aimention_vis_refresh_directory', 'aimention_vis_do_refresh' ); /** * Cron: pull the latest crawler directory (only once connected). * * @return void */ function aimention_vis_do_refresh() { $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; // never contact our servers before the user has connected (opted in). } AIMention_Directory::refresh( $config['endpoint'] ); } add_action( 'aimention_vis_process', 'aimention_vis_do_process' ); /** * Cron: drain the queue -- verify each pending event and send it, then drop the row. * * @return void */ function aimention_vis_do_process() { AIMention_Queue::purge_expired(); // enforce the 24h raw-IP cap first. $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; } $snapshot = AIMention_Directory::get(); $version = ( $snapshot && isset( $snapshot['version'] ) ) ? (string) $snapshot['version'] : ''; // Batch of 200 (was 50) per 5-minute run = ~57,600 events/day of drain // headroom. Real crawler events must clear before the 24h raw-IP purge drops // them, so a UA flood on one site can't bury genuine hits until they age out. // A hard guarantee would need per-visitor enqueue rate-limiting, which the // single-insert request path deliberately avoids; drain-to-empty is unsafe // because a failed send is retained for retry and would loop while the API is // down, so a larger bounded batch is the safe headroom lever instead. foreach ( AIMention_Queue::take_batch( 200 ) as $row ) { $agent = $snapshot ? aimention_vis_find_agent( $snapshot, $row['agent_id'] ) : null; if ( ! $agent ) { AIMention_Queue::delete( $row['id'] ); // agent no longer in directory. continue; } $verdict = AIMention_Classifier::classify( $row['raw_ip'], $agent ); $payload = AIMention_Sender::build_payload( $row, $verdict, array( 'key' => $config['key'], 'camera_secret' => $config['camera_secret'], 'host' => $config['host'], 'ip_prefix_or_hash' => AIMention_IP_Resolver::hash( $row['raw_ip'], $config['key'] ), 'directory_version' => $version, ) ); if ( AIMention_Sender::send( $payload, $config['endpoint'] ) ) { AIMention_Queue::delete( $row['id'] ); // sent -> raw IP gone. } // On failure: leave the row; retried next run, purged at the 24h cap. } } /** * Look up a directory agent entry by its agent_id. * * @param array $snapshot The directory snapshot. * @param string $agent_id The agent id to find. * @return array|null The agent entry, or null if not present. */ function aimention_vis_find_agent( $snapshot, $agent_id ) { foreach ( $snapshot['agents'] as $agent ) { if ( isset( $agent['agent_id'] ) && $agent['agent_id'] === $agent_id ) { return $agent; } } return null; } /* * Settings screen. */ add_action( 'admin_menu', 'aimention_vis_menu' ); /** * Register the AI Mention admin menu (Dashboard + Settings). * * @return void */ function aimention_vis_menu() { add_menu_page( __( 'AI Mention Visibility', 'ai-mention-visibility' ), __( 'AI Mention', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility', 'aimention_vis_dashboard_page', plugins_url( 'assets/mark-cream-128.webp', __FILE__ ), 26.5 ); // Rename the auto-created first submenu (same slug as parent) to "Dashboard". add_submenu_page( 'aimention-visibility', __( 'AI Mention Visibility', 'ai-mention-visibility' ), __( 'Dashboard', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility', 'aimention_vis_dashboard_page' ); // Settings on its own tab. add_submenu_page( 'aimention-visibility', __( 'AI Mention Settings', 'ai-mention-visibility' ), __( 'Settings', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility-settings', 'aimention_vis_settings_page' ); } add_action( 'admin_enqueue_scripts', 'aimention_vis_menu_icon_css' ); /** * Size the admin-menu icon so the brand mark renders crisply. * * @return void */ function aimention_vis_menu_icon_css() { wp_register_style( 'aimention-vis-admin-menu', false, array(), AIMENTION_VIS_VERSION ); wp_enqueue_style( 'aimention-vis-admin-menu' ); wp_add_inline_style( 'aimention-vis-admin-menu', '#adminmenu #toplevel_page_aimention-visibility .wp-menu-image img{width:20px;height:20px;}' ); } add_action( 'admin_enqueue_scripts', 'aimention_vis_admin_assets' ); /** * Enqueue the plugin's admin styles/scripts on its own screens only. * * @param string $hook Current admin page hook suffix. * @return void */ function aimention_vis_admin_assets( $hook ) { // Matches both "toplevel_page_aimention-visibility" (Dashboard) and // "..._page_aimention-visibility-settings" (Settings). if ( false === strpos( (string) $hook, 'aimention-visibility' ) ) { return; } // Modal CSS + support script: both tabs. wp_enqueue_style( 'aimention-vis-dashboard', plugins_url( 'assets/aimvis-dashboard.css', __FILE__ ), array(), AIMENTION_VIS_VERSION ); wp_enqueue_script( 'aimention-vis-support', plugins_url( 'assets/aimvis-support.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_localize_script( 'aimention-vis-support', 'aimentionVisSupport', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aimention_vis_support' ), ) ); // Dashboard-only: charts + the connect (provision) modal. if ( 'toplevel_page_aimention-visibility' === $hook ) { wp_enqueue_style( 'aimention-vis-charts', plugins_url( 'assets/aimvis-charts.css', __FILE__ ), array(), AIMENTION_VIS_VERSION ); wp_enqueue_script( 'aimention-vis-charts', plugins_url( 'assets/aimvis-charts.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_enqueue_script( 'aimention-vis-connect', plugins_url( 'assets/aimvis-connect.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_localize_script( 'aimention-vis-connect', 'aimentionVisConnect', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aimention_vis_provision' ), ) ); } } /** * Collect a diagnostic snapshot of the site for a support request. * * @return array Diagnostic fields (versions, active theme/plugins, config, reachability). */ function aimention_vis_gather_diagnostics() { global $wpdb; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active = (array) get_option( 'active_plugins', array() ); $all = function_exists( 'get_plugins' ) ? get_plugins() : array(); $plugins = array(); foreach ( $active as $slug ) { $plugins[] = isset( $all[ $slug ] ) ? $all[ $slug ]['Name'] . ' ' . $all[ $slug ]['Version'] : $slug; } $theme = wp_get_theme(); $config = aimention_vis_config(); $snapshot = AIMention_Directory::get(); // Connectivity probe: can this server reach our API at all? This is invaluable for debugging. $probe = wp_remote_get( rtrim( $config['endpoint'], '/' ) . '/api/visibility/directory/', array( 'timeout' => 5 ) ); $reach = is_wp_error( $probe ) ? 'error: ' . $probe->get_error_message() : 'HTTP ' . (int) wp_remote_retrieve_response_code( $probe ); $key = $config['key']; return array( 'plugin_version' => AIMENTION_VIS_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'mysql_version' => $wpdb->db_version(), 'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '', 'is_multisite' => is_multisite() ? 'yes' : 'no', 'memory_limit' => ini_get( 'memory_limit' ), 'max_execution_time' => ini_get( 'max_execution_time' ), 'active_theme' => $theme ? ( $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ) ) : '', 'active_plugins' => $plugins, 'site_url' => get_site_url(), 'home_url' => home_url(), 'endpoint' => $config['endpoint'], 'key_set' => ( '' !== $key ) ? ( 'yes (ending ' . substr( $key, -4 ) . ')' ) : 'no', 'proxy_mode' => $config['ip']['proxy'], 'directory' => $snapshot ? ( 'version ' . ( isset( $snapshot['version'] ) ? $snapshot['version'] : '?' ) . ', ' . count( $snapshot['agents'] ) . ' agents' ) : 'not fetched', 'cron_scheduled' => wp_next_scheduled( 'aimention_vis_process' ) ? 'yes' : 'no', 'api_reachable' => $reach, ); } add_action( 'wp_ajax_aimention_vis_support', 'aimention_vis_ajax_support' ); /** * AJAX: forward a support request (with diagnostics) to AI Mention. * * @return void */ function aimention_vis_ajax_support() { check_ajax_referer( 'aimention_vis_support', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'error' => 'forbidden' ), 403 ); } $config = aimention_vis_config(); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : ''; $resp = wp_remote_post( rtrim( $config['endpoint'], '/' ) . '/api/visibility/support/', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'business_name' => get_bloginfo( 'name' ), 'email' => $email, 'website' => $config['host'], 'host' => $config['host'], 'message' => $message, 'diagnostics' => aimention_vis_gather_diagnostics(), ) ), ) ); if ( is_wp_error( $resp ) ) { wp_send_json_error( array( 'error' => 'unreachable' ), 502 ); } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code >= 200 && $code < 300 ) { wp_send_json_success( array( 'sent' => true ) ); } wp_send_json_error( array( 'error' => 'http_' . $code ), ( $code >= 400 && $code < 600 ) ? $code : 502 ); } /** * Render the "Contact support" button and its modal. * * @return void */ function aimention_vis_support_ui() { $admin_mail = get_option( 'admin_email' ); echo '
' . esc_html__( "Stuck, or something not working? Send us your site details and we'll help.", 'ai-mention-visibility' ) . '
'; echo '' . esc_html__( "Tell us what's happening and we'll take a look. To help us debug, we'll include a snapshot of your site: WordPress & PHP versions, active plugins and theme, this plugin's settings, and a connectivity check. No visitor data or passwords are included.", 'ai-mention-visibility' ) . '
'; echo ''; echo '' . esc_html__( 'ChatGPT, Claude, Gemini and Perplexity crawl the web to decide what to recommend. This plugin shows you which ones read your site, and the real visitors they send you, verified and free forever.', 'ai-mention-visibility' ) . '
'; echo ''; echo '' . esc_html__( 'Already have an account?', 'ai-mention-visibility' ) . ' ' . esc_html__( 'Paste your key in Settings', 'ai-mention-visibility' ) . ' →
'; echo '' . esc_html__( 'Only verified AI-crawler visits are reported. Ordinary human pageviews are never recorded, and raw IP addresses never leave your server.', 'ai-mention-visibility' ) . '
'; echo '' . esc_html__( 'Connects this site instantly. We will email you a link to set a password for full web access. No card required.', 'ai-mention-visibility' ) . '
'; echo ''; echo '' . esc_html__( 'Could not load your dashboard right now. Check your collection key and endpoint below, then reload.', 'ai-mention-visibility' ) . '
';
echo '' . esc_html( $msg ) . '
' . esc_html__( 'These are the pages AI systems are using to understand your business.', 'ai-mention-visibility' ) . '
'; foreach ( $crawler['top_pages'] as $r ) { $bar( '' . esc_html( $r['path'] ?? '' ) . '', (int) ( $r['count'] ?? 0 ), $max, '#1a6bff' );
}
if ( '' !== $report ) {
echo ''
. esc_html__( 'Find out what they conclude from them', 'ai-mention-visibility' ) . ' →';
}
echo '