setup(); } return self::$instance; } private function setup() { $this->load_dependencies(); $this->init_components(); $this->maybe_upgrade(); $this->register_hooks(); } private function load_dependencies() { require_once AIADVISORS_DIR . 'includes/class-helpers.php'; require_once AIADVISORS_DIR . 'includes/class-settings.php'; require_once AIADVISORS_DIR . 'includes/class-queue.php'; require_once AIADVISORS_DIR . 'includes/class-privacy.php'; require_once AIADVISORS_DIR . 'includes/class-tracker.php'; require_once AIADVISORS_DIR . 'includes/class-sender.php'; require_once AIADVISORS_DIR . 'includes/class-score.php'; require_once AIADVISORS_DIR . 'includes/class-inbox.php'; require_once AIADVISORS_DIR . 'includes/class-fixer.php'; if ( is_admin() ) { require_once AIADVISORS_DIR . 'admin/class-admin.php'; } } private function init_components() { $this->settings = new Settings(); $this->queue = new Queue(); $this->tracker = new Tracker( $this->queue, $this->settings ); $this->sender = new Sender( $this->queue, $this->settings ); $this->score = new Score( $this->settings ); $this->inbox = new Inbox( $this->settings ); if ( is_admin() ) { $this->admin = new Admin( $this->settings, $this->queue, $this->sender, $this->inbox ); } } /** * Check if database tables need upgrading (for plugin updates, not just fresh installs). */ private function maybe_upgrade() { $current_version = get_option( 'aiadvisors_db_version', '1' ); if ( version_compare( $current_version, '3', '<' ) ) { self::activate(); // Re-runs table creation (dbDelta is safe to run multiple times). } } private function register_hooks() { // Only track when enabled and domain key is set. if ( $this->settings->get( 'enabled' ) && $this->settings->has_domain_key() ) { add_action( 'plugins_loaded', array( $this->tracker, 'start_capture' ), 1 ); add_action( 'shutdown', array( $this->tracker, 'log_request' ), PHP_INT_MAX ); } // Queue processing via WP-Cron. add_action( 'aiadvisors_send_queue', array( $this->sender, 'process_queue' ) ); // Queue cleanup daily. add_action( 'aiadvisors_cleanup_queue', array( $this->queue, 'cleanup_old_entries' ) ); if ( ! wp_next_scheduled( 'aiadvisors_cleanup_queue' ) ) { wp_schedule_event( time(), 'daily', 'aiadvisors_cleanup_queue' ); } // AEO score badge + dashboard widget. $this->score->init(); // Register schema output on frontend (for injected FAQ/Org schemas). Fixer::register_schema_output(); // Register robots.txt rules via the WordPress robots_txt filter. Fixer::register_robots_output(); // Register custom cron interval. // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval -- 60s interval is intentional for near-real-time batched sending. add_filter( 'cron_schedules', array( $this, 'add_cron_intervals' ) ); } public function add_cron_intervals( $schedules ) { if ( ! isset( $schedules['aiadvisors_minute'] ) ) { $schedules['aiadvisors_minute'] = array( 'interval' => 60, 'display' => __( 'Every Minute', 'ai-advisors' ), ); } return $schedules; } public static function activate() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table_name = $wpdb->prefix . 'aiadvisors_queue'; $sql = "CREATE TABLE $table_name ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, payload LONGTEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, attempts TINYINT(3) UNSIGNED NOT NULL DEFAULT 0, next_attempt_at DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY next_attempt_idx (next_attempt_at, attempts), KEY created_at_idx (created_at) ) $charset_collate;"; // Actions history table (tracks applied/dismissed inbox items locally). $actions_table = $wpdb->prefix . 'aiadvisors_actions'; // action_id is VARCHAR(191): pending-action ids are derived from page // paths and can exceed 36 chars; 191 keeps the unique index inside the // 767-byte utf8mb4 limit on older MySQL. $sql2 = "CREATE TABLE $actions_table ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, action_id VARCHAR(191) NOT NULL, type VARCHAR(50) NOT NULL, title TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', payload LONGTEXT, applied_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY action_id_idx (action_id), KEY status_idx (status) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); dbDelta( $sql2 ); // update_option (not add_option) so re-running activate() from // maybe_upgrade() can actually raise the stored version; add_option is a // no-op when the row already exists, which would loop dbDelta forever. update_option( 'aiadvisors_db_version', '3' ); $default_settings = array( 'enabled' => false, 'domain_key' => '', 'endpoint' => AIADVISORS_API_ENDPOINT, 'batch_size' => 500, 'max_queue_size' => 100000, 'retention_days' => 7, 'circuit_breaker_threshold' => 3, 'circuit_breaker_cooldown' => 1800, 'redact_query_keys' => 'password,pass,token,auth,authorization,apikey,api_key,secret,card,cc,ssn', ); add_option( 'aiadvisors_settings', $default_settings ); update_option( 'aiadvisors_install_time', time() ); } public static function deactivate() { wp_clear_scheduled_hook( 'aiadvisors_send_queue' ); wp_clear_scheduled_hook( 'aiadvisors_cleanup_queue' ); delete_transient( 'aiadvisors_sending_lock' ); delete_transient( 'aiadvisors_circuit_breaker' ); } public static function uninstall() { global $wpdb; // Drop custom tables. // phpcs:disable WordPress.DB.DirectDatabaseQuery -- DROP TABLE on the plugin's own custom tables during uninstall; the schema change is intentional and the result is not cacheable. $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}aiadvisors_queue" ); $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}aiadvisors_actions" ); // phpcs:enable // Delete options (including legacy keys from pre-1.0 file-based robots). $options = array( 'aiadvisors_settings', 'aiadvisors_db_version', 'aiadvisors_install_time', 'aiadvisors_remove_data_on_uninstall', 'aiadvisors_robots_rules', 'aiadvisors_robots_backup', 'aiadvisors_llms_backup', 'aiadvisors_faq_schema', 'aiadvisors_has_page_faq', 'aiadvisors_org_schema', ); foreach ( $options as $option ) { delete_option( $option ); } // Delete transients. $transients = array( 'aiadvisors_sending_lock', 'aiadvisors_circuit_breaker', 'aiadvisors_last_send', 'aiadvisors_last_error', 'aiadvisors_inbox_data', 'aiadvisors_action_cache', 'aiadvisors_score_data', 'aiadvisors_automation_prefs', ); foreach ( $transients as $transient ) { delete_transient( $transient ); } // Per-IP rate-limit transients use dynamic names (aiadvisors_rl_). // They self-expire after 60s, but clean any lingering rows here for // DB-backed transient storage. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_aiadvisors_rl_' ) . '%', $wpdb->esc_like( '_transient_timeout_aiadvisors_rl_' ) . '%' ) ); // phpcs:enable // Delete plugin post meta left by content fixes. $meta_keys = array( '_aiadvisors_content_backup', '_aiadvisors_heading_rewrite_applied', '_aiadvisors_metadesc_backup', '_aiadvisors_metadesc_key', '_aiadvisors_meta_description', '_aiadvisors_draft', '_aiadvisors_draft_source', '_aiadvisors_faq_schema', ); foreach ( $meta_keys as $meta_key ) { delete_post_meta_by_key( $meta_key ); } // Note: a physical llms.txt written to the web root is intentionally // left in place; the admin can remove it via the inbox Undo before // uninstalling, or delete the file manually. } }