init_hooks(); $this->database_ready = $this->check_database_ready(); AnswerRails_Utils::debug_log( 'constructor:db-check', array( 'database_ready' => $this->database_ready, ) ); $this->load_dependencies(); AnswerRails_Utils::debug_log( 'constructor:dependencies-loaded' ); if ( !$this->database_ready ) { AnswerRails_Utils::debug_log( 'constructor:database-unavailable' ); return; } $this->init_components(); AnswerRails_Utils::debug_log( 'constructor:components-initialized' ); } /** * Initialize WordPress hooks */ private function init_hooks() { add_action( 'init', array($this, 'init'), 10 ); add_action( 'rest_api_init', array($this, 'register_rest_routes') ); // Activation/deactivation hooks register_activation_hook( ANSWERRAILS_PLUGIN_FILE, array($this, 'activate') ); register_deactivation_hook( ANSWERRAILS_PLUGIN_FILE, array($this, 'deactivate') ); } /** * Load plugin dependencies */ private function load_dependencies() { AnswerRails_Utils::debug_log( 'load_dependencies:start' ); // Core includes. // #176 D1: the save-time sanitisation helpers, loaded FIRST — // this plugin's own register_setting() block and the one in // AnswerRails Pro both call into them. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-settings-sanitise.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-theme.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-core.php'; // #259 (D1/D2): the consent declaration — WP Consent API + Complianz. // Loaded beside Core because it decorates Core's frontend script tags. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-consent-declaration.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-response-service.php'; // #174: the advanced-privacy frontend enqueue lives in // AnswerRails_Core (decision 25 D4), which never enqueues an asset // this plugin does not ship. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-knowledge-loader.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-knowledge-ajax.php'; // REST API classes - UNIFIED SYSTEM require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-logger.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-response-service.php'; // #130: ledger STORAGE, loaded first — privacy-tools depends on it, // never the other way round. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-privacy-ledgers.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-privacy-tools.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-link-matcher.php'; // #144 Layer A: the site content index (standing closed-world // grounding context) — needed by the REST prompt-assembly path and // by admin-side cache invalidation, so loaded centrally. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-site-index.php'; // #142 Layer B: lookup-table focus resolution — the canonical // entry-line format authority (writer used by the knowledge sync // path, parser by the REST retrieval path), so loaded centrally. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-focus-resolver.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-api-health-monitor.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-moderation-service.php'; // #119 visitor-input moderation (depends on the health monitor) require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-provider-interface.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-provider-factory.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-model-capabilities.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-openai-provider.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-mistral-provider.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/providers/class-answerrails-anthropic-provider.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-api-router.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-rag-analytics.php'; // Local chunk store + chunker: needed by the REST chat path (lexical // fallback) as well as the knowledge loader, so loaded centrally. require_once ANSWERRAILS_PLUGIN_DIR . 'includes/knowledge/class-answerrails-knowledge-chunker.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/knowledge/class-answerrails-chunk-store.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-rest-auth.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-rest-openai.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-rest-chat.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-rest-router.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-knowledge-rest.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'includes/class-answerrails-migrations.php'; // Update delivery is not this plugin's job: Freemius delivers the // premium build, .org/WP core the free one (#95). // Admin includes if ( is_admin() ) { require_once ANSWERRAILS_PLUGIN_DIR . 'admin/class-answerrails-admin.php'; require_once ANSWERRAILS_PLUGIN_DIR . 'admin/class-answerrails-chatlog-list-table.php'; } AnswerRails_Utils::debug_log( 'load_dependencies:complete' ); } /** * Initialize plugin components */ private function init_components() { AnswerRails_Utils::debug_log( 'init_components:start' ); // Initialize core components (frontend only - NO REST API functions) $this->core = new AnswerRails_Core(); // #259 (D1/D2): declare AnswerRails to the WP Consent API and to // Complianz. Constructed here, on every request, for the same // reason the privacy tooling is: a consent declaration that only // happens on some requests is a declaration a CMP can miss. // ⚠ A DECLARATION, NOT A FIX — see the class docblock. $this->consent_declaration = new AnswerRails_Consent_Declaration(); // Shared response utilities available to REST and Pro handlers $this->response_service = new AnswerRails_Response_Service(); // FIXED: Initialize Knowledge Loader (not the old Knowledge class) $this->knowledge = new AnswerRails_Knowledge_Loader(); // ⚠ The Knowledge REST controller is constructed by the loader and // nowhere else: a second object runs register_rest_route() twice per // route ($override defaults to false, so WP APPENDS), publishing // duplicated endpoint entries. // Initialize AJAX handler $this->knowledge_ajax = new AnswerRails_Knowledge_Ajax($this->knowledge); // Initialize logger and bootstrap AnswerRails_Logger::bootstrap(); // DSAR tooling (#110): WP core privacy exporter/eraser, registered // unconditionally on every request. AnswerRails_Privacy_Tools::register(); // #144: site-index cache invalidation. Registered HERE (every // request), not in a REST-request-scoped constructor — the events // that stale the index (publish/edit/delete) fire on ADMIN // requests. The mechanism is unconditional; how far the index // REACHES is decided at render time. AnswerRails_Site_Index::register(); // ⚠ #163: link-cache invalidation registers HERE, not in // AnswerRails_REST_Chat's constructor — that constructor runs only // in REST chat requests, and knowledge items are saved and deleted // in ADMIN requests too. Registered here it covers both, and the // 1-hour transient TTL stays a backstop rather than the only // invalidation in operation. add_action( 'save_post_answerrails_kb', array('AnswerRails_Link_Matcher', 'clear_cache') ); add_action( 'delete_post', array('AnswerRails_Link_Matcher', 'clear_cache') ); // #377 D8: the recovery re-check's cron callback, the key-save // re-checks and admin_init reconciliation. Every request: the event // fires on whatever request WP-Cron runs on, and keys are saved on // admin requests. AnswerRails_API_Health_Monitor::register_recovery_hooks(); // Initialize RAG Analytics if ( class_exists( 'AnswerRails_RAG_Analytics' ) ) { $this->rag_analytics = new AnswerRails_RAG_Analytics(); } // Initialize admin interface if ( is_admin() ) { $this->admin = new AnswerRails_Admin($this->knowledge); // ⚠ The Knowledge Admin is NOT constructed here: its // constructor hooks admin_init, admin_notices and // admin_enqueue_scripts, and a second object registers all // three a second time. The loader owns the one hook-registering // instance; admin/views/knowledge.php builds a separate // render-only instance whose hooks are dead on arrival (it is // constructed after all three have already fired). } AnswerRails_Utils::debug_log( 'init_components:complete' ); } /** * Plugin initialization */ public function init() { AnswerRails_Utils::debug_log( 'init:start', array( 'database_ready' => $this->database_ready, 'core_loaded' => (bool) $this->core, ) ); if ( !$this->database_ready || !$this->core ) { AnswerRails_Utils::debug_log( 'init:skipped' ); return; } // Custom post types and taxonomies $this->register_post_types(); // Shortcode add_shortcode( 'answerrails_chat', array($this->core, 'chat_shortcode') ); // Legacy AJAX hooks for knowledge management only // ADD THIS: Initialize migrations AnswerRails_Migrations::init(); if ( is_admin() ) { add_action( 'admin_notices', array('AnswerRails_Migrations', 'display_migration_notices') ); } do_action( 'answerrails_init', $this ); AnswerRails_Utils::debug_log( 'init:complete' ); } /** * Register custom post types */ public function register_post_types() { // Knowledge items post type (shortened name) register_post_type( 'answerrails_kb', array( 'labels' => array( 'name' => __( 'Knowledge Items', 'answerrails' ), 'singular_name' => __( 'Knowledge Item', 'answerrails' ), 'menu_name' => __( 'Knowledge', 'answerrails' ), 'add_new' => __( 'Add Knowledge', 'answerrails' ), 'add_new_item' => __( 'Add New Knowledge Item', 'answerrails' ), 'edit_item' => __( 'Edit Knowledge Item', 'answerrails' ), 'new_item' => __( 'New Knowledge Item', 'answerrails' ), 'view_item' => __( 'View Knowledge Item', 'answerrails' ), 'search_items' => __( 'Search Knowledge Items', 'answerrails' ), 'not_found' => __( 'No knowledge items found', 'answerrails' ), 'not_found_in_trash' => __( 'No knowledge items found in trash', 'answerrails' ), ), 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'capability_type' => 'post', 'capabilities' => array( 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'publish_posts' => 'manage_options', 'read_private_posts' => 'manage_options', ), 'supports' => array('title', 'editor'), 'has_archive' => false, 'rewrite' => false, ) ); // Chat logs post type - AUDIT ACCESS register_post_type( 'answerrails_chatlog', array( 'labels' => array( 'name' => __( 'Chat Logs', 'answerrails' ), 'singular_name' => __( 'Chat Log', 'answerrails' ), 'menu_name' => __( 'Chat Logs', 'answerrails' ), 'all_items' => __( 'All Chat Logs', 'answerrails' ), 'view_item' => __( 'View Chat Log', 'answerrails' ), 'search_items' => __( 'Search Chat Logs', 'answerrails' ), 'not_found' => __( 'No chat logs found', 'answerrails' ), ), 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'capability_type' => 'post', 'capabilities' => array( 'create_posts' => 'do_not_allow', 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'read_private_posts' => 'manage_options', ), 'supports' => array('title', 'editor', 'custom-fields'), 'rewrite' => false, ) ); // Ideas post type - AUDIT ACCESS register_post_type( 'answerrails_idea', array( 'labels' => array( 'name' => __( 'Ideas', 'answerrails' ), 'singular_name' => __( 'Idea', 'answerrails' ), 'menu_name' => __( 'Captured Ideas', 'answerrails' ), 'all_items' => __( 'All Ideas', 'answerrails' ), 'view_item' => __( 'View Idea', 'answerrails' ), 'search_items' => __( 'Search Ideas', 'answerrails' ), ), 'public' => false, 'show_ui' => true, 'show_in_menu' => false, 'capability_type' => 'post', 'capabilities' => array( 'create_posts' => 'do_not_allow', 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', ), 'supports' => array('title', 'editor'), 'rewrite' => false, ) ); // Consent records post type - AUDIT ACCESS register_post_type( 'answerrails_consent', array( 'labels' => array( 'name' => __( 'Consent Records', 'answerrails' ), 'singular_name' => __( 'Consent Record', 'answerrails' ), 'menu_name' => __( 'Consent Records', 'answerrails' ), 'all_items' => __( 'All Consent Records', 'answerrails' ), 'view_item' => __( 'View Consent Record', 'answerrails' ), 'search_items' => __( 'Search Consent Records', 'answerrails' ), ), 'public' => false, 'show_ui' => true, 'show_in_menu' => false, 'capability_type' => 'post', 'capabilities' => array( 'create_posts' => 'do_not_allow', 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', ), 'supports' => array('title', 'custom-fields'), 'rewrite' => false, ) ); } /** * Register REST API routes - UNIFIED SYSTEM ONLY */ public function register_rest_routes() { if ( !$this->database_ready ) { return; } // Only use the unified REST Router system AnswerRails_REST_Router::register_routes_now(); // Knowledge REST routes are auto-registered by the single // AnswerRails_Knowledge_REST controller, which AnswerRails_Knowledge_Loader // constructs and owns — a second construction here registers every // knowledge route twice (#139). } // ⚠ #179: this plugin registers NO text domain. The free build is // .org-hosted: core auto-loads its language packs from // WP_LANG_DIR/plugins (since 4.6), and the .org review asks for exactly // that. ⚠ CONDITION: the // Domain Path header is tooling metadata, NOT a runtime registration — // core does NOT load .mo files bundled inside languages/ unless // load_plugin_textdomain() / WP_Textdomain_Registry::set_custom_path() // registers the path. languages/ ships EMPTY, so nothing is lost today; // if a build ever bundles .mo files (the premium build cannot use .org // packs), a registration must return WITH them — parked with the // Freemius-split pipeline, see TEXTDOMAIN_REMOVAL_DESIGN.md /** * Plugin activation */ public function activate() { AnswerRails_Utils::debug_log( 'activate:start' ); if ( !$this->database_ready ) { AnswerRails_Utils::debug_log( 'activate:database-unavailable' ); return; } // Create database tables if needed $this->create_tables(); AnswerRails_Utils::debug_log( 'activate:after-create-tables' ); // Set default options $this->set_default_options(); AnswerRails_Utils::debug_log( 'activate:after-set-defaults' ); // Run migrations on activation if ( class_exists( 'AnswerRails_Migrations' ) ) { AnswerRails_Utils::debug_log( 'activate:before-migrations' ); AnswerRails_Migrations::schedule_async_run(); AnswerRails_Utils::debug_log( 'activate:after-migrations' ); } // #377 D8 step 7: a Down chat streak outlives deactivation (which // clears its re-check), so activation re-arms it. if ( class_exists( 'AnswerRails_API_Health_Monitor' ) ) { ( new AnswerRails_API_Health_Monitor() )->reconcile_recovery(); } // Flush rewrite rules flush_rewrite_rules(); AnswerRails_Utils::debug_log( 'activate:after-flush-rewrite' ); do_action( 'answerrails_activated' ); AnswerRails_Utils::debug_log( 'activate:complete' ); } /** * Plugin deactivation */ public function deactivate() { if ( !$this->database_ready ) { return; } // Clean up if needed flush_rewrite_rules(); // #132 W7: clear the plugin's scheduled events. Deactivation is NOT // uninstall — options and data are untouched (#127 owns uninstall); // the schedules are re-created on next activation/init. wp_clear_scheduled_hook( 'answerrails_purge_old_logs' ); // Migration 1.9.0 clears the legacy daily-check event a pre-#95 // install may still have scheduled; nothing here registers it. wp_clear_scheduled_hook( 'answerrails_hourly_sync' ); // #377 D8 step 6: every recovery re-check carries args [$api], and // wp_clear_scheduled_hook() without args clears only the [] events — // so this hook is cleared BY HOOK, whatever its args. wp_unschedule_hook( 'answerrails_recovery_probe' ); do_action( 'answerrails_deactivated' ); } /** * Create database tables */ private function create_tables() { if ( !$this->database_ready ) { return; } global $wpdb; $charset_collate = $wpdb->get_charset_collate(); // Using custom post types - no additional tables needed } /** * Set default options (Core + REST system unified) */ private function set_default_options() { if ( !$this->database_ready ) { return; } $defaults = array( 'answerrails_chat_enabled' => true, 'answerrails_business_name' => 'Your Business', 'answerrails_chat_title' => 'How can we help you today?', 'answerrails_welcome_message' => 'Hi! I\'m here to help with any questions about our products and services.', 'answerrails_chat_system_prompt' => 'You are a helpful customer service assistant for a small business. Provide accurate, friendly responses about products and services.', 'answerrails_provider_openai_model' => AnswerRails_Utils::default_model( 'openai' ), 'answerrails_chat_temperature' => 0.4, 'answerrails_chat_max_tokens' => 1600, 'answerrails_privacy_mode' => 'session', 'answerrails_version' => ANSWERRAILS_VERSION, 'answerrails_active_provider' => 'mistral', 'answerrails_provider_openai_api_key' => '', 'answerrails_provider_mistral_api_key' => '', 'answerrails_provider_mistral_model' => AnswerRails_Utils::default_model( 'mistral' ), 'answerrails_provider_anthropic_api_key' => '', 'answerrails_provider_anthropic_model' => AnswerRails_Utils::default_model( 'anthropic' ), 'answerrails_provider_openai_org' => '', 'answerrails_provider_openai_project' => '', 'answerrails_provider_openai_base' => 'https://api.openai.com/v1', 'answerrails_api_debug' => 0, 'answerrails_services_json' => '[]', 'answerrails_hide_idea_in_reply' => 0, 'answerrails_use_knowledge' => 1, 'answerrails_knowledge_max_attachments' => 12, 'answerrails_knowledge_vector_store_id' => '', 'answerrails_capture_ideas' => 1, 'answerrails_privacy_log_conversations' => 0, 'answerrails_privacy_logging_mode' => 'anonymized', 'answerrails_privacy_retention_days' => 180, 'answerrails_require_consent' => 0, 'answerrails_recaptcha_enabled' => 0, 'answerrails_recaptcha_site_key' => '', 'answerrails_recaptcha_secret' => '', 'answerrails_recaptcha_min_score' => '0.5', 'answerrails_strip_emojis' => 1, 'answerrails_widget_title' => 'AnswerRails Assistant', 'answerrails_widget_subtitle' => 'Shop help & product suggestions', 'answerrails_assistant_label' => 'Assistant', 'answerrails_avatar_image_id' => 0, 'answerrails_avatar_url' => '', 'answerrails_display_mode' => 'home_only', 'answerrails_custom_pages' => array(), 'answerrails_links_new_tab' => 1, 'answerrails_links_decorate' => 1, 'answerrails_primary_domains' => wp_parse_url( home_url(), PHP_URL_HOST ), 'answerrails_remote_validation_allowed_domains' => '', 'answerrails_rag_enabled' => 1, 'answerrails_rag_confidence_threshold_high' => 0.8, 'answerrails_rag_confidence_threshold_medium' => 0.6, 'answerrails_rag_confidence_threshold_low' => 0.4, 'answerrails_rag_track_performance' => 1, 'answerrails_rag_detect_deflection' => 1, 'answerrails_rag_identify_gaps' => 1, ); foreach ( $defaults as $option => $value ) { if ( false === get_option( $option ) ) { add_option( $option, $value ); } } } /** * Determine if the global database connection is ready for use. */ private function check_database_ready() : bool { global $wpdb; if ( !isset( $wpdb ) || !$wpdb instanceof wpdb ) { return false; } if ( property_exists( $wpdb, 'ready' ) && !$wpdb->ready ) { return false; } if ( empty( $wpdb->dbh ) ) { return false; } return true; } } /** * Initialize the plugin */ function answerrails() { return AnswerRails::instance(); }