prefix . 'replyvane_items'; } public static function log_table() { global $wpdb; return $wpdb->prefix . 'replyvane_log'; } /** The site's contact page, if it has one with a usual address. */ private static function detect_contact_url() { static $url = null; if ( null === $url ) { $url = ''; foreach ( array( 'contact-us', 'contact', 'get-in-touch' ) as $slug ) { $page = get_page_by_path( $slug ); if ( $page && 'publish' === $page->post_status ) { $url = get_permalink( $page ); break; } } } return $url; } public static function defaults() { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); return array( 'bot_name' => ( $site ? $site : 'Website' ) . ' Help', 'welcome' => "Hi! Choose a question below, or type your own and I'll find the answer on our website.", 'popular_title' => 'Popular questions', 'no_match' => "I couldn't find an answer to that. Try one of the topics below, or contact our team and they'll be happy to help.", 'contact_url' => self::detect_contact_url(), // Answers from the website's own pages (no AI). 'answer_from_site' => 1, 'sslverify' => 1, 'color' => '#1f5c63', // Appearance: follow the theme unless set otherwise. 'color_mode' => 'theme', // "theme" or "custom". 'font_mode' => 'theme', // "theme" or "custom". 'font_custom' => '', 'question_bg' => '', // Empty = a light shade of the main colour. 'question_text' => '', // Empty = chosen automatically for readability. 'answer_bg' => '', 'icon' => 'chat', // A built-in icon, or "custom". 'icon_url' => '', 'icon_fill' => 0, 'notify_enabled' => 1, 'notify_sound' => 0, 'agent_avatars' => 1, 'message_avatars' => 1, // Small avatars next to chatbot, visitor and team messages. // Answer engine: "builtin" (no AI), or "wp_ai" (the site's WordPress AI Client provider, WordPress 7.0+). 'engine' => 'builtin', // Pages kept out of the chatbot's knowledge, chosen from a list. 'exclude_ids' => array(), 'ai_label' => 1, 'ai_model' => '', // Optional model name; empty = chosen automatically. 'position' => 'right', 'show_search' => 1, 'visibility' => 'everyone', // "everyone" or "admins" (preview: only logged-in administrators). 'show_logged_in' => 1, 'log_enabled' => 1, 'log_days' => 180, // Live chat (off until switched on in Settings). 'live_enabled' => 0, 'live_label' => 'Chat with our team', 'team_email' => get_option( 'admin_email' ), 'email_new_chat' => 1, 'escalate_minutes' => 3, 'alert_include_text' => 0, 'require_email' => 1, 'live_consent' => "We'll save your name, email and messages so our team can reply. Please don't share sensitive personal details in chat.", 'reply_from' => 'site', // Email replies come from the site's address ("site") or the team member's ("agent"). 'from_email' => get_option( 'admin_email' ), 'hours_enabled' => 0, 'hours_days' => array( 1, 2, 3, 4, 5 ), 'hours_start' => '09:00', 'hours_end' => '17:00', 'live_days' => 90, ); } public static function settings() { $saved = get_option( self::OPTION, array() ); return wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() ); } /** True while the chat is in preview mode (visible to administrators only). */ public static function admins_only() { return 'admins' === self::get( 'visibility' ); } public static function get( $key ) { $s = self::settings(); return isset( $s[ $key ] ) ? $s[ $key ] : null; } /** Changes whenever questions or topics change, so browsers fetch fresh data. */ public static function data_version() { return (string) get_option( 'replyvane_data_version', '1' ); } public static function bump_version() { update_option( 'replyvane_data_version', (string) time(), false ); } public static function activate() { // Switch off the earlier copy and move its data first. Replyvane_Migrate::deactivate_old( true ); Replyvane_Migrate::run(); self::create_tables(); if ( ! wp_next_scheduled( 'replyvane_cleanup' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'replyvane_cleanup' ); } if ( ! wp_next_scheduled( 'replyvane_kb_daily' ) ) { wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'replyvane_kb_daily' ); } // Read this website straight away. If it has no questions yet, create // starter questions from its pages when the scan finishes. if ( 0 === count( Replyvane_Store::items() ) ) { update_option( 'replyvane_autogen_pending', 1, false ); } wp_schedule_single_event( time() + 10, 'replyvane_kb_daily' ); } public static function deactivate() { wp_clear_scheduled_hook( 'replyvane_cleanup' ); wp_clear_scheduled_hook( 'replyvane_kb_daily' ); wp_clear_scheduled_hook( 'replyvane_kb_process' ); } public static function create_tables() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $items = self::items_table(); $log = self::log_table(); dbDelta( "CREATE TABLE {$items} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, slug varchar(100) NOT NULL, category varchar(100) NOT NULL DEFAULT 'general', question text NOT NULL, answer longtext NOT NULL, keywords text NOT NULL, links text NOT NULL, related text NOT NULL, featured tinyint(1) NOT NULL DEFAULT 0, sort_order int(11) NOT NULL DEFAULT 0, active tinyint(1) NOT NULL DEFAULT 1, clicks int(10) unsigned NOT NULL DEFAULT 0, source varchar(10) NOT NULL DEFAULT 'manual', updated_at datetime NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY slug (slug), KEY category (category), KEY active_order (active,sort_order) ) {$charset};" ); dbDelta( "CREATE TABLE {$log} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, session_id varchar(40) NOT NULL DEFAULT '', event varchar(10) NOT NULL DEFAULT '', faq_slug varchar(100) NOT NULL DEFAULT '', query varchar(300) NOT NULL DEFAULT '', matched tinyint(1) NOT NULL DEFAULT 0, page_url varchar(500) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY created_at (created_at), KEY event_matched (event,matched) ) {$charset};" ); Replyvane_Knowledge::create_tables(); Replyvane_Live::create_tables(); Replyvane_Live::setup_roles(); update_option( 'replyvane_db_version', self::DB_VERSION, false ); } /** * 4.5: "Keep out by address" was replaced by ticking pages. Addresses the * site owner added themselves become ticked pages, so nothing that was * hidden becomes visible. Login and account addresses stay built in. */ public static function migrate_settings() { $rev = (int) get_option( 'replyvane_settings_rev', 0 ); if ( $rev >= 5 ) { return; } if ( $rev < 4 ) { self::migrate_exclusions(); } self::migrate_ai_engine(); update_option( 'replyvane_settings_rev', 5, false ); } /** * 5.2: AI answers now come from the WordPress AI Client, set up once for the * whole site under Settings > Connectors. A provider chosen in an earlier * version becomes the WordPress AI engine, and the API keys and model names * the plugin stored before are deleted: it no longer keeps any keys. */ private static function migrate_ai_engine() { // Earlier builds' own copies of the settings: remove the API keys they kept. foreach ( array( 'sitereply_settings', 'chat_support_settings', 'rhc_faq_settings' ) as $legacy ) { $old = get_option( $legacy ); if ( is_array( $old ) && array_key_exists( 'popular_title', $old ) ) { // Only this plugin's own earlier settings. foreach ( array( 'claude_key', 'openai_key', 'grok_key', 'gemini_key' ) as $key ) { unset( $old[ $key ] ); } update_option( $legacy, $old, false ); } } $saved = get_option( self::OPTION, array() ); if ( ! is_array( $saved ) ) { return; } $changed = false; if ( isset( $saved['engine'] ) && in_array( $saved['engine'], array( 'claude', 'openai', 'grok', 'gemini' ), true ) ) { $saved['engine'] = 'wp_ai'; $changed = true; } foreach ( array( 'claude', 'openai', 'grok', 'gemini' ) as $old ) { foreach ( array( '_key', '_model' ) as $suffix ) { if ( array_key_exists( $old . $suffix, $saved ) ) { unset( $saved[ $old . $suffix ] ); $changed = true; } } } if ( array_key_exists( 'claude_model_other', $saved ) ) { unset( $saved['claude_model_other'] ); $changed = true; } if ( $changed ) { update_option( self::OPTION, $saved ); } } private static function migrate_exclusions() { $saved = get_option( self::OPTION, array() ); if ( is_array( $saved ) && isset( $saved['exclude'] ) ) { $lines = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', (string) $saved['exclude'] ) ) ); $norm = function ( $p ) { return strtolower( untrailingslashit( '/' . ltrim( $p, '/' ) ) ); }; $custom = array_values( array_diff( array_map( $norm, $lines ), array_map( $norm, Replyvane_Knowledge::default_patterns() ) ) ); if ( $custom ) { $ids = isset( $saved['exclude_ids'] ) ? array_map( 'intval', (array) $saved['exclude_ids'] ) : array(); $all = get_posts( array( 'post_type' => Replyvane_Knowledge::post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); foreach ( $all as $pid ) { $uri = get_page_uri( $pid ); if ( Replyvane_Knowledge::matches_patterns( get_permalink( $pid ), $custom ) || ( $uri && Replyvane_Knowledge::matches_patterns( home_url( '/' . $uri . '/' ), $custom ) ) ) { $ids[] = (int) $pid; } } $saved['exclude_ids'] = array_values( array_unique( $ids ) ); } unset( $saved['exclude'] ); update_option( self::OPTION, $saved ); } } public static function cleanup_log() { global $wpdb; $days = (int) self::get( 'log_days' ); if ( $days > 0 ) { $table = self::log_table(); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE created_at < %s", $table, gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); } } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching