mxchat_init_ajax_hooks(); } /** * Register all AJAX action hooks */ private function mxchat_init_ajax_hooks() { // Settings AJAX add_action('wp_ajax_mxchat_save_setting', array($this, 'mxchat_save_setting_callback')); add_action('wp_ajax_mxchat_save_prompts_setting', array($this, 'mxchat_save_prompts_setting_callback')); add_action('wp_ajax_mxchat_acf_toggle_group', array($this, 'mxchat_acf_toggle_group_callback')); add_action('wp_ajax_migrate_pinecone_settings', array($this, 'ajax_migrate_pinecone_settings')); // License AJAX add_action('wp_ajax_mxchat_handle_activate_license', array($this, 'mxchat_handle_activate_license')); add_action('wp_ajax_mxchat_check_license_status', array($this, 'mxchat_check_license_status')); add_action('wp_ajax_mxchat_deactivate_license', array($this, 'mxchat_deactivate_license')); // Actions & Intents AJAX add_action('wp_ajax_mxchat_toggle_action', array($this, 'mxchat_toggle_action')); add_action('wp_ajax_mxchat_update_intent_threshold', array($this, 'mxchat_update_intent_threshold')); add_action('wp_ajax_mxchat_save_selected_bot', array($this, 'mxchat_save_selected_bot')); add_action('wp_ajax_mxchat_check_api_keys', array($this, 'mxchat_check_api_keys')); // Debug & Optimization AJAX add_action('wp_ajax_mxchat_toggle_debug_mode', array($this, 'mxchat_toggle_debug_mode_callback')); add_action('wp_ajax_mxchat_get_debug_log', array($this, 'mxchat_get_debug_log_callback')); add_action('wp_ajax_mxchat_clear_debug_log', array($this, 'mxchat_clear_debug_log_callback')); add_action('wp_ajax_mxchat_export_settings', array($this, 'mxchat_export_settings_callback')); add_action('wp_ajax_mxchat_reset_all_settings', array($this, 'mxchat_reset_all_settings_callback')); // Global rate-limit usage counter reset (admin-only, nonce-guarded) add_action('wp_ajax_mxchat_reset_global_rate_limit', array($this, 'mxchat_reset_global_rate_limit_callback')); // Custom (OpenAI-compatible) Provider connection test add_action('wp_ajax_mxchat_test_custom_provider', array($this, 'mxchat_test_custom_provider_callback')); // Built-in provider key validation — cheap per-provider auth check (plan-mxchat-20260623-c41f74) add_action('wp_ajax_mxchat_test_provider_key', array($this, 'mxchat_test_provider_key_callback')); // Custom Post Meta discovery scan for the KB whitelist picker (plan-mxchat-20260709-fe8e4e) add_action('wp_ajax_mxchat_scan_custom_meta_keys', array($this, 'mxchat_scan_custom_meta_keys_callback')); } /** * Discover non-ACF custom post-meta keys present on published public content, so the * KB → Custom Post Meta section can offer a click-to-add picker instead of a blind * "type the exact key you already know" textarea. plan-mxchat-20260709-fe8e4e. * * Bounded + button-triggered only (never on page load). Returns up to 50 keys by * frequency, each with a short sample value, so the owner can judge relevance before * whitelisting. Underscore-prefixed (protected/internal) keys are hidden unless the * caller opts in; ACF-managed keys are excluded so this picker never double-lists the * sibling ACF discovery picker on the same page. */ public function mxchat_scan_custom_meta_keys_callback() { check_ajax_referer('mxchat_prompts_setting_nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]); } global $wpdb; $include_internal = isset($_POST['include_internal']) && $_POST['include_internal'] === '1'; // Restrict discovery to public post types (the content the KB actually embeds). $post_types = get_post_types(array('public' => true), 'names'); if (empty($post_types)) { wp_send_json_success(array('keys' => array(), 'scanned' => 0)); } $pt_placeholders = implode(',', array_fill(0, count($post_types), '%s')); // Build the set of ACF-managed meta keys to exclude. ACF stores, alongside each // value key `foo`, a reference key `_foo` whose value is the ACF field key // (`field_xxxxx`). Strip the leading underscore from every such reference key to // get the real meta key, and exclude those — the ACF picker on this page owns them. $acf_managed = array(); $acf_refs = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT meta_key FROM {$wpdb->postmeta} WHERE meta_key LIKE %s AND meta_value LIKE %s", $wpdb->esc_like('_') . '%', $wpdb->esc_like('field_') . '%' ) ); foreach ((array) $acf_refs as $ref_key) { if (strlen($ref_key) > 1 && $ref_key[0] === '_') { $acf_managed[substr($ref_key, 1)] = true; } } // Discover keys + counts + a sample value in one bounded aggregate query. // SUBSTRING(MIN(...)) keeps the sample selection ONLY_FULL_GROUP_BY-safe. $params = $post_types; $sql = "SELECT pm.meta_key AS mk, COUNT(*) AS n, SUBSTRING(MIN(pm.meta_value), 1, 200) AS sample FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_status = 'publish' AND p.post_type IN ($pt_placeholders) AND pm.meta_key <> ''"; if (!$include_internal) { $sql .= " AND pm.meta_key NOT LIKE %s"; $params[] = $wpdb->esc_like('_') . '%'; } $sql .= " GROUP BY pm.meta_key ORDER BY n DESC, pm.meta_key ASC LIMIT 200"; // phpcs:ignore WordPress.DB.PreparedSQL — placeholders assembled above, values in $params. $rows = $wpdb->get_results($wpdb->prepare($sql, $params)); $keys = array(); foreach ((array) $rows as $row) { $mk = $row->mk; if (isset($acf_managed[$mk])) { continue; // already offered by the ACF picker } $raw = (string) $row->sample; if ($raw !== '' && (is_serialized($raw) || preg_match('/^(a:\d+:\{|O:\d+:"|s:\d+:")/', $raw))) { $sample = esc_html__('[structured value]', 'mxchat'); } else { $sample = trim(preg_replace('/\s+/', ' ', $raw)); if (function_exists('mb_strlen') ? mb_strlen($sample) > 60 : strlen($sample) > 60) { $sample = (function_exists('mb_substr') ? mb_substr($sample, 0, 60) : substr($sample, 0, 60)) . '…'; } if ($sample === '') { $sample = esc_html__('(empty value)', 'mxchat'); } } $keys[] = array( 'key' => $mk, 'count' => (int) $row->n, 'sample' => $sample, ); if (count($keys) >= 50) { break; } } wp_send_json_success(array( 'keys' => $keys, 'scanned' => is_array($rows) ? count($rows) : 0, )); } /** * Test connection to a Custom (OpenAI-compatible) provider by hitting its /models endpoint * with whichever auth scheme the user configured. Reports model count or a clean error. */ public function mxchat_test_custom_provider_callback() { check_ajax_referer('mxchat_test_custom_provider'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat'))); } $options = get_option('mxchat_options', array()); $base_url = isset($options['custom_provider_base_url']) ? trim((string) $options['custom_provider_base_url']) : ''; $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : ''; $auth = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer'; $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : ''; if (empty($base_url)) { wp_send_json_error(array('message' => esc_html__('Base URL is empty. Save it first.', 'mxchat'))); } $url = rtrim($base_url, '/') . '/models'; if (!empty($api_version)) { $url = add_query_arg('api-version', $api_version, $url); } $headers = array('Content-Type' => 'application/json'); if (!empty($api_key)) { if ($auth === 'api-key') { $headers['api-key'] = $api_key; } else { $headers['Authorization'] = 'Bearer ' . $api_key; } } $response = wp_remote_get($url, array( 'headers' => $headers, 'timeout' => 10, )); if (is_wp_error($response)) { wp_send_json_error(array('message' => sprintf(esc_html__('Network error: %s', 'mxchat'), esc_html($response->get_error_message())))); } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 401 || $code === 403) { wp_send_json_error(array('message' => sprintf(esc_html__('Auth rejected (HTTP %d). Check API key and auth scheme.', 'mxchat'), $code))); } if ($code === 404) { wp_send_json_error(array('message' => esc_html__('Endpoint not found (HTTP 404). Check the Base URL.', 'mxchat'))); } if ($code < 200 || $code >= 300) { wp_send_json_error(array('message' => sprintf(esc_html__('Upstream returned HTTP %d.', 'mxchat'), $code))); } $body = json_decode(wp_remote_retrieve_body($response), true); $count = 0; if (is_array($body)) { if (isset($body['data']) && is_array($body['data'])) { $count = count($body['data']); } elseif (isset($body['models']) && is_array($body['models'])) { $count = count($body['models']); } } wp_send_json_success(array( 'message' => sprintf(esc_html__('Connection OK — %d model(s) reported.', 'mxchat'), $count), 'count' => $count, )); } /** * Validate a BUILT-IN provider key with the lightest authenticated call per * provider (a /models or key-info GET — never a generation). Reads the posted * key value so the owner can test BEFORE saving; falls back to the saved option * when the field is empty. Mirrors mxchat_test_custom_provider_callback and the * add-on test buttons (cf5bd5 veo / 8d16f1 perplexity). The key is never logged. * plan-mxchat-20260623-c41f74. */ public function mxchat_test_provider_key_callback() { check_ajax_referer('mxchat_test_provider_key'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat'))); } $provider = isset($_POST['provider']) ? sanitize_key(wp_unslash($_POST['provider'])) : ''; $posted_key = isset($_POST['key']) ? trim((string) wp_unslash($_POST['key'])) : ''; $option_map = array( 'openai' => 'api_key', 'xai' => 'xai_api_key', 'claude' => 'claude_api_key', 'deepseek' => 'deepseek_api_key', 'gemini' => 'gemini_api_key', 'openrouter' => 'openrouter_api_key', ); if (!isset($option_map[$provider])) { wp_send_json_error(array('message' => esc_html__('Unknown provider.', 'mxchat'))); } // Prefer the just-typed value (test-before-save); fall back to the saved key. $key = $posted_key; if ($key === '') { $options = get_option('mxchat_options', array()); $key = isset($options[$option_map[$provider]]) ? trim((string) $options[$option_map[$provider]]) : ''; } if ($key === '') { wp_send_json_error(array('message' => esc_html__('No API key entered or saved for this provider.', 'mxchat'))); } // Lightest authenticated metadata call per provider — model-agnostic, no generation. $headers = array(); switch ($provider) { case 'openai': $url = 'https://api.openai.com/v1/models'; $headers = array('Authorization' => 'Bearer ' . $key); break; case 'xai': $url = 'https://api.x.ai/v1/models'; $headers = array('Authorization' => 'Bearer ' . $key); break; case 'deepseek': $url = 'https://api.deepseek.com/models'; $headers = array('Authorization' => 'Bearer ' . $key); break; case 'openrouter': // /auth/key validates the key itself (the public /models list does not). $url = 'https://openrouter.ai/api/v1/auth/key'; $headers = array('Authorization' => 'Bearer ' . $key); break; case 'gemini': $url = add_query_arg(array('pageSize' => 1, 'key' => $key), 'https://generativelanguage.googleapis.com/v1beta/models'); break; case 'claude': $url = 'https://api.anthropic.com/v1/models'; $headers = array('x-api-key' => $key, 'anthropic-version' => '2023-06-01'); break; default: wp_send_json_error(array('message' => esc_html__('Unknown provider.', 'mxchat'))); } $response = wp_remote_get($url, array( 'headers' => $headers, 'timeout' => 10, )); if (is_wp_error($response)) { wp_send_json_error(array('message' => sprintf(esc_html__('Network error: %s', 'mxchat'), esc_html($response->get_error_message())))); } $code = (int) wp_remote_retrieve_response_code($response); if ($code >= 200 && $code < 300) { wp_send_json_success(array('message' => esc_html__('Key is valid.', 'mxchat'))); } // Surface the provider's own error text when present (trimmed; key never echoed). $detail = ''; $body = json_decode(wp_remote_retrieve_body($response), true); if (is_array($body)) { if (isset($body['error']['message'])) { $detail = $body['error']['message']; } elseif (isset($body['error']) && is_string($body['error'])) { $detail = $body['error']; } elseif (isset($body['message'])) { $detail = $body['message']; } } $detail = trim((string) $detail); if (strlen($detail) > 200) { $detail = substr($detail, 0, 200) . '…'; } if ($code === 401 || $code === 403) { $msg = ($detail !== '') ? sprintf(esc_html__('Key rejected (HTTP %1$d): %2$s', 'mxchat'), $code, esc_html($detail)) : sprintf(esc_html__('Key rejected (HTTP %d). Check the API key.', 'mxchat'), $code); wp_send_json_error(array('message' => $msg)); } $msg = ($detail !== '') ? sprintf(esc_html__('Provider returned HTTP %1$d: %2$s', 'mxchat'), $code, esc_html($detail)) : sprintf(esc_html__('Provider returned HTTP %d.', 'mxchat'), $code); wp_send_json_error(array('message' => $msg)); } // ======================================== // SETTINGS AJAX HANDLERS // ======================================== /** * Validates and saves chat settings via AJAX request */ public function mxchat_save_setting_callback() { check_ajax_referer('mxchat_save_setting_nonce'); if (!current_user_can('manage_options')) { ('MXChat Save: Unauthorized access attempt'); wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]); } $name = isset($_POST['name']) ? $_POST['name'] : ''; // Remove WP's added slashes before saving (wp_unslash is the canonical form; plan-3f8158). $value = isset($_POST['value']) ? wp_unslash($_POST['value']) : ''; //error_log('MXChat Save: Processing field name: ' . $name); //error_log('MXChat Save: Field value: ' . $value); if (empty($name)) { //error_log('MXChat Save: Empty field name detected'); wp_send_json_error(['message' => esc_html__('Invalid field name', 'mxchat')]); } // Load the full options array $options = get_option('mxchat_options', []); //error_log('MXChat Save: Current options array: ' . print_r($options, true)); // Extract field name from mxchat_options[field_name] format if present // But preserve the full name for special cases like rate_limits that need the full path $field_name = $name; if (preg_match('/^mxchat_options\[([^\[\]]+)\]$/', $name, $matches)) { $field_name = $matches[1]; } // Handle special cases switch ($field_name) { // Editor Assistant enable toggle (plan-8cb0cb). STANDALONE option — NOT part // of mxchat_options, so it skips the mxchat_sanitize strip-trap entirely. Save // it directly and short-circuit (mirrors the mxchat_transcripts_options pattern // below); never falls through to the generic mxchat_options save. Default OFF. case 'mxchat_editor_assistant_enabled': $ea_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; update_option('mxchat_editor_assistant_enabled', $ea_value); wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); return; // Smart asset loading toggle (plan-915355). STANDALONE option, same // reasoning as the Editor Assistant case above — saved directly and // short-circuited so it never touches mxchat_options / mxchat_sanitize. // Default OFF (opt-in performance optimization). case 'mxchat_smart_asset_loading': $sal_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; update_option('mxchat_smart_asset_loading', $sal_value); wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); return; // Hybrid keyword boost toggle (plan-38ffa1). STANDALONE option, same // pattern. Enabling runs capability detection HERE, at admin-save time — // building the FULLTEXT index during a visitor's chat request is not // acceptable, and detection is a one-time cost the admin can wait on. case 'mxchat_hybrid_keyword_toggle': $hkb_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; update_option('mxchat_hybrid_keyword_toggle', $hkb_value); if ($hkb_value === 'on') { MxChat_Utils::mxchat_hybrid_detect_capability(true); } wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); return; // ACF→PDF import-time extraction (plan 11720c). STANDALONE option, same // pattern. Moved from a per-import modal checkbox to an install-level // setting on Knowledge → ACF Fields. Stored '1'/'0' to match the // knowledge page's sibling toggles. Default OFF. case 'mxchat_acf_pdf_extraction': $apx_value = ($value === 'on' || $value === '1') ? '1' : '0'; update_option('mxchat_acf_pdf_extraction', $apx_value); wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); return; // In-chat YouTube card: master switch + its own confidence floor // (plan f52492). STANDALONE options, same pattern as the cases above. // Default ON — the card already ships, so this is an opt-OUT. case 'mxchat_video_embed_enabled': $vce_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; update_option('mxchat_video_embed_enabled', $vce_value); wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); return; // Clamped to the same 20-95 the field advertises. An out-of-range or // non-numeric POST is corrected rather than refused, and the corrected // value is echoed back so the field can reconcile — a silently stored // 0 here would put a video on every answer, which is the bug. case 'mxchat_video_embed_threshold': $vct_value = is_numeric($value) ? (int) $value : MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT; if ($vct_value < 20) { $vct_value = 20; } if ($vct_value > 95) { $vct_value = 95; } update_option('mxchat_video_embed_threshold', $vct_value); wp_send_json_success([ 'message' => esc_html__('Setting saved', 'mxchat'), 'value' => $vct_value, ]); return; // Live-agent availability schedules (plans 8ccaa2 + 99d7a4). STANDALONE // options, same reasoning as the Editor Assistant case above — nested // structures that mxchat_sanitize() would strip on the next autosave of any // other field. Each channel's value arrives as JSON from its own hidden // input, which that channel's schedule editor keeps in sync; the class owns // all validation. The bare legacy name is kept as a defense against a // browser still running pre-split cached admin JS: that UI edited "both // channels" as one, so its save writes both. case 'live_agent_schedule_slack': case 'live_agent_schedule_telegram': case 'live_agent_schedule_webhook': case 'live_agent_schedule': if (!class_exists('MxChat_Live_Agent_Schedule')) { wp_send_json_error(['message' => esc_html__('Schedule unavailable', 'mxchat')]); return; } $decoded = json_decode($value, true); if (!is_array($decoded)) { wp_send_json_error(['message' => esc_html__('Invalid schedule', 'mxchat')]); return; } $channels = ($field_name === 'live_agent_schedule') ? array('slack', 'telegram') : array(substr($field_name, strlen('live_agent_schedule_'))); $saved_schedule = null; foreach ($channels as $schedule_channel) { $saved_schedule = MxChat_Live_Agent_Schedule::save($schedule_channel, $decoded); } // Echo the normalized result so the editor can reconcile if it ever // disagrees with the server (e.g. a time the class rejected). wp_send_json_success([ 'message' => esc_html__('Setting saved', 'mxchat'), 'schedule' => $saved_schedule, ]); return; case 'model': //error_log('MXChat Save: Processing model selection'); //error_log('MXChat Save: Model value received: ' . $value); //error_log('MXChat Save: Value type: ' . gettype($value)); //error_log('MXChat Save: Value length: ' . strlen($value)); //error_log('MXChat Save: Value === "openrouter": ' . ($value === 'openrouter' ? 'YES' : 'NO')); // Allow 'openrouter' or validate against whitelist if ($value === 'openrouter') { //error_log('MXChat Save: Setting model to openrouter'); $options['model'] = 'openrouter'; } else { //error_log('MXChat Save: Checking against whitelist'); // Catalog refactor (plan-d14e89): canonical allowlist lives in // includes/class-mxchat-model-catalog.php. A new chat model // added there is automatically accepted by autosave. if (!class_exists('MxChat_Model_Catalog')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-mxchat-model-catalog.php'; } $allowed_models = MxChat_Model_Catalog::chat_model_ids(); //error_log('MXChat Save: in_array result: ' . (in_array($value, $allowed_models) ? 'YES' : 'NO')); if (in_array($value, $allowed_models)) { //error_log('MXChat Save: Model is in whitelist, saving'); $options['model'] = sanitize_text_field($value); } else { //error_log('MXChat Save: Invalid model rejected: ' . $value); //error_log('MXChat Save: Allowed models: ' . print_r($allowed_models, true)); wp_send_json_error(['message' => esc_html__('Invalid model selected', 'mxchat')]); return; } } break; case 'openrouter_selected_model': //error_log('MXChat Save: Processing OpenRouter model: ' . $value); $options['openrouter_selected_model'] = sanitize_text_field($value); // Force immediate save for new keys //error_log('MXChat Save: OpenRouter model saved immediately'); break; case 'openrouter_selected_model_name': //error_log('MXChat Save: Processing OpenRouter model name: ' . $value); $options['openrouter_selected_model_name'] = sanitize_text_field($value); // Force immediate save for new keys //error_log('MXChat Save: OpenRouter model name saved immediately'); break; case 'openrouter_api_key': //error_log('MXChat Save: Processing OpenRouter API key'); $options['openrouter_api_key'] = sanitize_text_field($value); break; // REMOVED DUPLICATE case 'openrouter_selected_model_name' HERE! case 'additional_popular_questions': //error_log('MXChat Save: Processing additional_popular_questions'); $questions = json_decode($value, true); // No need for stripslashes here if (is_array($questions)) { $options[$field_name] = $questions; // Also update old option for backwards compatibility update_option('additional_popular_questions', $questions); //error_log('MXChat Save: Saved ' . count($questions) . ' additional questions'); } else { //error_log('MXChat Save: Failed to decode questions JSON'); } break; case 'email_blocker_header_content': //error_log('MXChat Save: Processing email_blocker_header_content'); // Allow HTML content but sanitize it safely $options[$field_name] = wp_kses_post($value); break; case 'intro_message': // Stored-XSS hardening (Wordfence CWE-79, plan-3f8158): sanitize on save as // defense in depth. wp_kses_post mirrors mxchat_sanitize() (the options.php // save path) so both save routes treat intro_message identically and strip //