MINUTE_IN_SECONDS, 'display' => __( 'Every minute (Aiso)', 'aiso-ai-crawler-analytics' ), ); } return $schedules; } /** * Ensure the recurring sync event exists. */ public static function schedule() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, self::CRON_SCHEDULE, self::CRON_HOOK ); } } /** * Remove the recurring sync event. */ public static function unschedule() { $timestamp = wp_next_scheduled( self::CRON_HOOK ); if ( $timestamp ) { wp_unschedule_event( $timestamp, self::CRON_HOOK ); } wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Cron entry point: flush a few batches and prune old local rows. */ public function cron_handler() { if ( $this->is_connected() ) { $this->run_sync( 5 ); } Aiso_Store::prune( (int) Aiso_Analytics::option( 'retention_days', 90 ) ); } /** * Is the plugin connected to an Aiso account? * * @return bool */ public function is_connected() { if ( ! (bool) Aiso_Analytics::option( 'sync_enabled', false ) ) { return false; } return '' !== trim( (string) Aiso_Analytics::option( 'api_key', '' ) ); } /** * API base URL (filterable, defaults to the Aiso constant). * * @return string */ public function api_base() { $base = (string) Aiso_Analytics::option( 'endpoint_base', '' ); if ( '' === $base ) { $base = AISO_ENDPOINT; } /** * Filter the Aiso API base URL (useful for staging environments). * * @param string $base Base URL without a trailing slash. */ $base = apply_filters( 'aiso_api_base', $base ); return untrailingslashit( esc_url_raw( $base ) ); } /** * Build a full endpoint URL. * * @param string $path Path beginning with a slash. * @return string */ private function endpoint( $path ) { return $this->api_base() . $path; } /** * Run the sync loop. Rate-limited by a short transient lock to avoid overlap. * * @param int $max_batches Maximum batches to push in one run. * @return array{sent:int,batches:int,error:string} */ public function run_sync( $max_batches = 5 ) { $result = array( 'sent' => 0, 'batches' => 0, 'error' => '', ); if ( ! $this->is_connected() ) { $result['error'] = __( 'Not connected to Aiso.', 'aiso-ai-crawler-analytics' ); return $result; } if ( get_transient( self::FLUSH_LOCK ) ) { return $result; // Another run is in flight. } set_transient( self::FLUSH_LOCK, 1, 30 ); for ( $i = 0; $i < $max_batches; $i++ ) { $rows = Aiso_Store::unsynced( 100 ); if ( empty( $rows ) ) { break; } $response = $this->post_batch( $rows ); if ( ! $response['success'] ) { $result['error'] = $response['message']; break; } $ids = wp_list_pluck( $rows, 'id' ); Aiso_Store::mark_synced( $ids ); $result['sent'] += count( $rows ); $result['batches']++; if ( count( $rows ) < 100 ) { break; // Drained the queue. } } delete_transient( self::FLUSH_LOCK ); update_option( 'aiso_last_sync', time(), false ); return $result; } /** * Shutdown nudge: push one batch at most, and no more than every 20s. */ public function maybe_flush_on_shutdown() { if ( get_transient( 'aiso_flush_recent' ) ) { return; } set_transient( 'aiso_flush_recent', 1, 20 ); $this->run_sync( 1 ); } /** * POST a batch of local rows to the ingest endpoint. * * @param array> $rows Local hit rows. * @return array{success:bool,stored:int,message:string} */ private function post_batch( $rows ) { $hits = array(); foreach ( $rows as $r ) { $ts = isset( $r['hit_time'] ) ? strtotime( $r['hit_time'] . ' UTC' ) : time(); $function = isset( $r['function_tag'] ) ? (string) $r['function_tag'] : ''; $verified = isset( $r['verified'] ) && null !== $r['verified'] ? (bool) (int) $r['verified'] : null; $hits[] = array( 'id' => isset( $r['id'] ) ? (string) $r['id'] : '', 'timestamp' => gmdate( 'c', $ts ), 'method' => isset( $r['method'] ) ? (string) $r['method'] : 'GET', 'path' => isset( $r['path'] ) ? (string) $r['path'] : '/', 'url' => isset( $r['url'] ) ? (string) $r['url'] : '', 'query' => array(), 'userAgent' => isset( $r['user_agent'] ) ? (string) $r['user_agent'] : '', 'botType' => isset( $r['bot_name'] ) ? (string) $r['bot_name'] : '', 'botSlug' => isset( $r['bot_slug'] ) ? (string) $r['bot_slug'] : '', 'company' => isset( $r['company'] ) ? (string) $r['company'] : '', 'platformCategory' => isset( $r['platform'] ) ? (string) $r['platform'] : '', 'functionTags' => '' !== $function ? array( $function ) : array(), 'isAIBot' => true, 'verified' => $verified, 'ip' => isset( $r['ip'] ) ? (string) $r['ip'] : '', 'country' => isset( $r['country'] ) ? (string) $r['country'] : '', 'referer' => isset( $r['referer'] ) ? (string) $r['referer'] : '', 'status' => isset( $r['status'] ) ? (int) $r['status'] : 0, ); } $body = array( 'apiKey' => (string) Aiso_Analytics::option( 'api_key', '' ), 'site' => home_url(), 'pluginVersion' => AISO_VERSION, 'hits' => $hits, ); $response = wp_remote_post( $this->endpoint( '/api/wordpress-bot-logs' ), array( 'timeout' => 10, 'redirection' => 2, 'headers' => array( 'Content-Type' => 'application/json', 'X-Aiso-Api-Key' => (string) Aiso_Analytics::option( 'api_key', '' ), 'Accept' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'stored' => 0, 'message' => $response->get_error_message(), ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 || empty( $json['success'] ) ) { $msg = is_array( $json ) && isset( $json['error'] ) ? (string) $json['error'] : sprintf( /* translators: %d: HTTP status code. */ __( 'Server returned HTTP %d.', 'aiso-ai-crawler-analytics' ), $code ); return array( 'success' => false, 'stored' => 0, 'message' => $msg, ); } return array( 'success' => true, 'stored' => isset( $json['stored'] ) ? (int) $json['stored'] : count( $hits ), 'message' => '', ); } /** * Validate an API key against the server. * * @param string $api_key Key to test. * @return array{valid:bool,domain:string,message:string} */ public function test_connection( $api_key ) { $api_key = trim( (string) $api_key ); if ( '' === $api_key ) { return array( 'valid' => false, 'domain' => '', 'message' => __( 'Enter an API key first.', 'aiso-ai-crawler-analytics' ), ); } $response = wp_remote_get( add_query_arg( 'apiKey', rawurlencode( $api_key ), $this->endpoint( '/api/wordpress-bot-logs' ) ), array( 'timeout' => 10, 'headers' => array( 'X-Aiso-Api-Key' => $api_key, 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return array( 'valid' => false, 'domain' => '', 'message' => $response->get_error_message(), ); } $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $json['valid'] ) ) { return array( 'valid' => true, 'domain' => isset( $json['domain'] ) ? (string) $json['domain'] : '', 'message' => '', ); } return array( 'valid' => false, 'domain' => '', 'message' => __( 'That key was not recognised by Aiso.', 'aiso-ai-crawler-analytics' ), ); } /** * Provision a new client key for this site. * * @param string $email Contact email. * @return array{success:bool,api_key:string,message:string} */ public function provision_key( $email ) { $email = sanitize_email( $email ); if ( ! is_email( $email ) ) { return array( 'success' => false, 'api_key' => '', 'message' => __( 'Enter a valid email address.', 'aiso-ai-crawler-analytics' ), ); } $domain = wp_parse_url( home_url(), PHP_URL_HOST ); $domain = $domain ? preg_replace( '/^www\./', '', $domain ) : ''; $response = wp_remote_post( $this->endpoint( '/api/client-keys' ), array( 'timeout' => 12, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'clientEmail' => $email, 'domain' => $domain, ) ), ) ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'api_key' => '', 'message' => $response->get_error_message(), ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 409 === $code ) { return array( 'success' => false, 'api_key' => '', 'message' => __( 'This domain already has an Aiso key. Paste your existing key below instead.', 'aiso-ai-crawler-analytics' ), ); } if ( $code >= 200 && $code < 300 && ! empty( $json['apiKey'] ) ) { return array( 'success' => true, 'api_key' => (string) $json['apiKey'], 'message' => '', ); } $msg = is_array( $json ) && isset( $json['error'] ) ? (string) $json['error'] : __( 'Could not create a key. Try again.', 'aiso-ai-crawler-analytics' ); return array( 'success' => false, 'api_key' => '', 'message' => $msg, ); } }