| @@ -27,8 +27,9 @@ | ||
| 27 | 27 | private function mxchat_init_ajax_hooks() { |
| 28 | 28 | // Settings AJAX |
| 29 | 29 | add_action('wp_ajax_mxchat_save_setting', array($this, 'mxchat_save_setting_callback')); |
| 30 | 30 | add_action('wp_ajax_mxchat_save_prompts_setting', array($this, 'mxchat_save_prompts_setting_callback')); |
| 31 | + add_action('wp_ajax_mxchat_acf_toggle_group', array($this, 'mxchat_acf_toggle_group_callback')); | |
| 31 | 32 | add_action('wp_ajax_migrate_pinecone_settings', array($this, 'ajax_migrate_pinecone_settings')); |
| 32 | 33 | |
| 33 | 34 | // License AJAX |
| 34 | 35 | add_action('wp_ajax_mxchat_handle_activate_license', array($this, 'mxchat_handle_activate_license')); |
| @@ -47,10 +48,308 @@ | ||
| 47 | 48 | add_action('wp_ajax_mxchat_get_debug_log', array($this, 'mxchat_get_debug_log_callback')); |
| 48 | 49 | add_action('wp_ajax_mxchat_clear_debug_log', array($this, 'mxchat_clear_debug_log_callback')); |
| 49 | 50 | add_action('wp_ajax_mxchat_export_settings', array($this, 'mxchat_export_settings_callback')); |
| 50 | 51 | add_action('wp_ajax_mxchat_reset_all_settings', array($this, 'mxchat_reset_all_settings_callback')); |
| 52 | + | |
| 53 | + // Global rate-limit usage counter reset (admin-only, nonce-guarded) | |
| 54 | + add_action('wp_ajax_mxchat_reset_global_rate_limit', array($this, 'mxchat_reset_global_rate_limit_callback')); | |
| 55 | + | |
| 56 | + // Custom (OpenAI-compatible) Provider connection test | |
| 57 | + add_action('wp_ajax_mxchat_test_custom_provider', array($this, 'mxchat_test_custom_provider_callback')); | |
| 58 | + | |
| 59 | + // Built-in provider key validation — cheap per-provider auth check (plan-mxchat-20260623-c41f74) | |
| 60 | + add_action('wp_ajax_mxchat_test_provider_key', array($this, 'mxchat_test_provider_key_callback')); | |
| 61 | + | |
| 62 | + // Custom Post Meta discovery scan for the KB whitelist picker (plan-mxchat-20260709-fe8e4e) | |
| 63 | + add_action('wp_ajax_mxchat_scan_custom_meta_keys', array($this, 'mxchat_scan_custom_meta_keys_callback')); | |
| 51 | 64 | } |
| 52 | 65 | |
| 66 | +/** | |
| 67 | + * Discover non-ACF custom post-meta keys present on published public content, so the | |
| 68 | + * KB → Custom Post Meta section can offer a click-to-add picker instead of a blind | |
| 69 | + * "type the exact key you already know" textarea. plan-mxchat-20260709-fe8e4e. | |
| 70 | + * | |
| 71 | + * Bounded + button-triggered only (never on page load). Returns up to 50 keys by | |
| 72 | + * frequency, each with a short sample value, so the owner can judge relevance before | |
| 73 | + * whitelisting. Underscore-prefixed (protected/internal) keys are hidden unless the | |
| 74 | + * caller opts in; ACF-managed keys are excluded so this picker never double-lists the | |
| 75 | + * sibling ACF discovery picker on the same page. | |
| 76 | + */ | |
| 77 | +public function mxchat_scan_custom_meta_keys_callback() { | |
| 78 | + check_ajax_referer('mxchat_prompts_setting_nonce'); | |
| 79 | + | |
| 80 | + if (!current_user_can('manage_options')) { | |
| 81 | + wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]); | |
| 82 | + } | |
| 83 | + | |
| 84 | + global $wpdb; | |
| 85 | + | |
| 86 | + $include_internal = isset($_POST['include_internal']) && $_POST['include_internal'] === '1'; | |
| 87 | + | |
| 88 | + // Restrict discovery to public post types (the content the KB actually embeds). | |
| 89 | + $post_types = get_post_types(array('public' => true), 'names'); | |
| 90 | + if (empty($post_types)) { | |
| 91 | + wp_send_json_success(array('keys' => array(), 'scanned' => 0)); | |
| 92 | + } | |
| 93 | + $pt_placeholders = implode(',', array_fill(0, count($post_types), '%s')); | |
| 94 | + | |
| 95 | + // Build the set of ACF-managed meta keys to exclude. ACF stores, alongside each | |
| 96 | + // value key `foo`, a reference key `_foo` whose value is the ACF field key | |
| 97 | + // (`field_xxxxx`). Strip the leading underscore from every such reference key to | |
| 98 | + // get the real meta key, and exclude those — the ACF picker on this page owns them. | |
| 99 | + $acf_managed = array(); | |
| 100 | + $acf_refs = $wpdb->get_col( | |
| 101 | + $wpdb->prepare( | |
| 102 | + "SELECT DISTINCT meta_key FROM {$wpdb->postmeta} WHERE meta_key LIKE %s AND meta_value LIKE %s", | |
| 103 | + $wpdb->esc_like('_') . '%', | |
| 104 | + $wpdb->esc_like('field_') . '%' | |
| 105 | + ) | |
| 106 | + ); | |
| 107 | + foreach ((array) $acf_refs as $ref_key) { | |
| 108 | + if (strlen($ref_key) > 1 && $ref_key[0] === '_') { | |
| 109 | + $acf_managed[substr($ref_key, 1)] = true; | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + // Discover keys + counts + a sample value in one bounded aggregate query. | |
| 114 | + // SUBSTRING(MIN(...)) keeps the sample selection ONLY_FULL_GROUP_BY-safe. | |
| 115 | + $params = $post_types; | |
| 116 | + $sql = "SELECT pm.meta_key AS mk, COUNT(*) AS n, SUBSTRING(MIN(pm.meta_value), 1, 200) AS sample | |
| 117 | + FROM {$wpdb->postmeta} pm | |
| 118 | + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id | |
| 119 | + WHERE p.post_status = 'publish' | |
| 120 | + AND p.post_type IN ($pt_placeholders) | |
| 121 | + AND pm.meta_key <> ''"; | |
| 122 | + if (!$include_internal) { | |
| 123 | + $sql .= " AND pm.meta_key NOT LIKE %s"; | |
| 124 | + $params[] = $wpdb->esc_like('_') . '%'; | |
| 125 | + } | |
| 126 | + $sql .= " GROUP BY pm.meta_key ORDER BY n DESC, pm.meta_key ASC LIMIT 200"; | |
| 127 | + | |
| 128 | + // phpcs:ignore WordPress.DB.PreparedSQL — placeholders assembled above, values in $params. | |
| 129 | + $rows = $wpdb->get_results($wpdb->prepare($sql, $params)); | |
| 130 | + | |
| 131 | + $keys = array(); | |
| 132 | + foreach ((array) $rows as $row) { | |
| 133 | + $mk = $row->mk; | |
| 134 | + if (isset($acf_managed[$mk])) { | |
| 135 | + continue; // already offered by the ACF picker | |
| 136 | + } | |
| 137 | + | |
| 138 | + $raw = (string) $row->sample; | |
| 139 | + if ($raw !== '' && (is_serialized($raw) || preg_match('/^(a:\d+:\{|O:\d+:"|s:\d+:")/', $raw))) { | |
| 140 | + $sample = esc_html__('[structured value]', 'mxchat'); | |
| 141 | + } else { | |
| 142 | + $sample = trim(preg_replace('/\s+/', ' ', $raw)); | |
| 143 | + if (function_exists('mb_strlen') ? mb_strlen($sample) > 60 : strlen($sample) > 60) { | |
| 144 | + $sample = (function_exists('mb_substr') ? mb_substr($sample, 0, 60) : substr($sample, 0, 60)) . '…'; | |
| 145 | + } | |
| 146 | + if ($sample === '') { | |
| 147 | + $sample = esc_html__('(empty value)', 'mxchat'); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + $keys[] = array( | |
| 152 | + 'key' => $mk, | |
| 153 | + 'count' => (int) $row->n, | |
| 154 | + 'sample' => $sample, | |
| 155 | + ); | |
| 156 | + | |
| 157 | + if (count($keys) >= 50) { | |
| 158 | + break; | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + wp_send_json_success(array( | |
| 163 | + 'keys' => $keys, | |
| 164 | + 'scanned' => is_array($rows) ? count($rows) : 0, | |
| 165 | + )); | |
| 166 | +} | |
| 167 | + | |
| 168 | +/** | |
| 169 | + * Test connection to a Custom (OpenAI-compatible) provider by hitting its /models endpoint | |
| 170 | + * with whichever auth scheme the user configured. Reports model count or a clean error. | |
| 171 | + */ | |
| 172 | +public function mxchat_test_custom_provider_callback() { | |
| 173 | + check_ajax_referer('mxchat_test_custom_provider'); | |
| 174 | + if (!current_user_can('manage_options')) { | |
| 175 | + wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat'))); | |
| 176 | + } | |
| 177 | + | |
| 178 | + $options = get_option('mxchat_options', array()); | |
| 179 | + $base_url = isset($options['custom_provider_base_url']) ? trim((string) $options['custom_provider_base_url']) : ''; | |
| 180 | + $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : ''; | |
| 181 | + $auth = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer'; | |
| 182 | + $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : ''; | |
| 183 | + | |
| 184 | + if (empty($base_url)) { | |
| 185 | + wp_send_json_error(array('message' => esc_html__('Base URL is empty. Save it first.', 'mxchat'))); | |
| 186 | + } | |
| 187 | + | |
| 188 | + $url = rtrim($base_url, '/') . '/models'; | |
| 189 | + if (!empty($api_version)) { | |
| 190 | + $url = add_query_arg('api-version', $api_version, $url); | |
| 191 | + } | |
| 192 | + | |
| 193 | + $headers = array('Content-Type' => 'application/json'); | |
| 194 | + if (!empty($api_key)) { | |
| 195 | + if ($auth === 'api-key') { | |
| 196 | + $headers['api-key'] = $api_key; | |
| 197 | + } else { | |
| 198 | + $headers['Authorization'] = 'Bearer ' . $api_key; | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + $response = wp_remote_get($url, array( | |
| 203 | + 'headers' => $headers, | |
| 204 | + 'timeout' => 10, | |
| 205 | + )); | |
| 206 | + | |
| 207 | + if (is_wp_error($response)) { | |
| 208 | + wp_send_json_error(array('message' => sprintf(esc_html__('Network error: %s', 'mxchat'), esc_html($response->get_error_message())))); | |
| 209 | + } | |
| 210 | + | |
| 211 | + $code = (int) wp_remote_retrieve_response_code($response); | |
| 212 | + if ($code === 401 || $code === 403) { | |
| 213 | + wp_send_json_error(array('message' => sprintf(esc_html__('Auth rejected (HTTP %d). Check API key and auth scheme.', 'mxchat'), $code))); | |
| 214 | + } | |
| 215 | + if ($code === 404) { | |
| 216 | + wp_send_json_error(array('message' => esc_html__('Endpoint not found (HTTP 404). Check the Base URL.', 'mxchat'))); | |
| 217 | + } | |
| 218 | + if ($code < 200 || $code >= 300) { | |
| 219 | + wp_send_json_error(array('message' => sprintf(esc_html__('Upstream returned HTTP %d.', 'mxchat'), $code))); | |
| 220 | + } | |
| 221 | + | |
| 222 | + $body = json_decode(wp_remote_retrieve_body($response), true); | |
| 223 | + $count = 0; | |
| 224 | + if (is_array($body)) { | |
| 225 | + if (isset($body['data']) && is_array($body['data'])) { | |
| 226 | + $count = count($body['data']); | |
| 227 | + } elseif (isset($body['models']) && is_array($body['models'])) { | |
| 228 | + $count = count($body['models']); | |
| 229 | + } | |
| 230 | + } | |
| 231 | + | |
| 232 | + wp_send_json_success(array( | |
| 233 | + 'message' => sprintf(esc_html__('Connection OK — %d model(s) reported.', 'mxchat'), $count), | |
| 234 | + 'count' => $count, | |
| 235 | + )); | |
| 236 | +} | |
| 237 | + | |
| 238 | +/** | |
| 239 | + * Validate a BUILT-IN provider key with the lightest authenticated call per | |
| 240 | + * provider (a /models or key-info GET — never a generation). Reads the posted | |
| 241 | + * key value so the owner can test BEFORE saving; falls back to the saved option | |
| 242 | + * when the field is empty. Mirrors mxchat_test_custom_provider_callback and the | |
| 243 | + * add-on test buttons (cf5bd5 veo / 8d16f1 perplexity). The key is never logged. | |
| 244 | + * plan-mxchat-20260623-c41f74. | |
| 245 | + */ | |
| 246 | +public function mxchat_test_provider_key_callback() { | |
| 247 | + check_ajax_referer('mxchat_test_provider_key'); | |
| 248 | + if (!current_user_can('manage_options')) { | |
| 249 | + wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat'))); | |
| 250 | + } | |
| 251 | + | |
| 252 | + $provider = isset($_POST['provider']) ? sanitize_key(wp_unslash($_POST['provider'])) : ''; | |
| 253 | + $posted_key = isset($_POST['key']) ? trim((string) wp_unslash($_POST['key'])) : ''; | |
| 254 | + | |
| 255 | + $option_map = array( | |
| 256 | + 'openai' => 'api_key', | |
| 257 | + 'xai' => 'xai_api_key', | |
| 258 | + 'claude' => 'claude_api_key', | |
| 259 | + 'deepseek' => 'deepseek_api_key', | |
| 260 | + 'gemini' => 'gemini_api_key', | |
| 261 | + 'openrouter' => 'openrouter_api_key', | |
| 262 | + ); | |
| 263 | + if (!isset($option_map[$provider])) { | |
| 264 | + wp_send_json_error(array('message' => esc_html__('Unknown provider.', 'mxchat'))); | |
| 265 | + } | |
| 266 | + | |
| 267 | + // Prefer the just-typed value (test-before-save); fall back to the saved key. | |
| 268 | + $key = $posted_key; | |
| 269 | + if ($key === '') { | |
| 270 | + $options = get_option('mxchat_options', array()); | |
| 271 | + $key = isset($options[$option_map[$provider]]) ? trim((string) $options[$option_map[$provider]]) : ''; | |
| 272 | + } | |
| 273 | + if ($key === '') { | |
| 274 | + wp_send_json_error(array('message' => esc_html__('No API key entered or saved for this provider.', 'mxchat'))); | |
| 275 | + } | |
| 276 | + | |
| 277 | + // Lightest authenticated metadata call per provider — model-agnostic, no generation. | |
| 278 | + $headers = array(); | |
| 279 | + switch ($provider) { | |
| 280 | + case 'openai': | |
| 281 | + $url = 'https://api.openai.com/v1/models'; | |
| 282 | + $headers = array('Authorization' => 'Bearer ' . $key); | |
| 283 | + break; | |
| 284 | + case 'xai': | |
| 285 | + $url = 'https://api.x.ai/v1/models'; | |
| 286 | + $headers = array('Authorization' => 'Bearer ' . $key); | |
| 287 | + break; | |
| 288 | + case 'deepseek': | |
| 289 | + $url = 'https://api.deepseek.com/models'; | |
| 290 | + $headers = array('Authorization' => 'Bearer ' . $key); | |
| 291 | + break; | |
| 292 | + case 'openrouter': | |
| 293 | + // /auth/key validates the key itself (the public /models list does not). | |
| 294 | + $url = 'https://openrouter.ai/api/v1/auth/key'; | |
| 295 | + $headers = array('Authorization' => 'Bearer ' . $key); | |
| 296 | + break; | |
| 297 | + case 'gemini': | |
| 298 | + $url = add_query_arg(array('pageSize' => 1, 'key' => $key), 'https://generativelanguage.googleapis.com/v1beta/models'); | |
| 299 | + break; | |
| 300 | + case 'claude': | |
| 301 | + $url = 'https://api.anthropic.com/v1/models'; | |
| 302 | + $headers = array('x-api-key' => $key, 'anthropic-version' => '2023-06-01'); | |
| 303 | + break; | |
| 304 | + default: | |
| 305 | + wp_send_json_error(array('message' => esc_html__('Unknown provider.', 'mxchat'))); | |
| 306 | + } | |
| 307 | + | |
| 308 | + $response = wp_remote_get($url, array( | |
| 309 | + 'headers' => $headers, | |
| 310 | + 'timeout' => 10, | |
| 311 | + )); | |
| 312 | + | |
| 313 | + if (is_wp_error($response)) { | |
| 314 | + wp_send_json_error(array('message' => sprintf(esc_html__('Network error: %s', 'mxchat'), esc_html($response->get_error_message())))); | |
| 315 | + } | |
| 316 | + | |
| 317 | + $code = (int) wp_remote_retrieve_response_code($response); | |
| 318 | + if ($code >= 200 && $code < 300) { | |
| 319 | + wp_send_json_success(array('message' => esc_html__('Key is valid.', 'mxchat'))); | |
| 320 | + } | |
| 321 | + | |
| 322 | + // Surface the provider's own error text when present (trimmed; key never echoed). | |
| 323 | + $detail = ''; | |
| 324 | + $body = json_decode(wp_remote_retrieve_body($response), true); | |
| 325 | + if (is_array($body)) { | |
| 326 | + if (isset($body['error']['message'])) { | |
| 327 | + $detail = $body['error']['message']; | |
| 328 | + } elseif (isset($body['error']) && is_string($body['error'])) { | |
| 329 | + $detail = $body['error']; | |
| 330 | + } elseif (isset($body['message'])) { | |
| 331 | + $detail = $body['message']; | |
| 332 | + } | |
| 333 | + } | |
| 334 | + $detail = trim((string) $detail); | |
| 335 | + if (strlen($detail) > 200) { | |
| 336 | + $detail = substr($detail, 0, 200) . '…'; | |
| 337 | + } | |
| 338 | + | |
| 339 | + if ($code === 401 || $code === 403) { | |
| 340 | + $msg = ($detail !== '') | |
| 341 | + ? sprintf(esc_html__('Key rejected (HTTP %1$d): %2$s', 'mxchat'), $code, esc_html($detail)) | |
| 342 | + : sprintf(esc_html__('Key rejected (HTTP %d). Check the API key.', 'mxchat'), $code); | |
| 343 | + wp_send_json_error(array('message' => $msg)); | |
| 344 | + } | |
| 345 | + | |
| 346 | + $msg = ($detail !== '') | |
| 347 | + ? sprintf(esc_html__('Provider returned HTTP %1$d: %2$s', 'mxchat'), $code, esc_html($detail)) | |
| 348 | + : sprintf(esc_html__('Provider returned HTTP %d.', 'mxchat'), $code); | |
| 349 | + wp_send_json_error(array('message' => $msg)); | |
| 350 | +} | |
| 351 | + | |
| 53 | 352 | // ======================================== |
| 54 | 353 | // SETTINGS AJAX HANDLERS |
| 55 | 354 | // ======================================== |
| 56 | 355 | |
| @@ -64,10 +363,10 @@ | ||
| 64 | 363 | wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]); |
| 65 | 364 | } |
| 66 | 365 | |
| 67 | 366 | $name = isset($_POST['name']) ? $_POST['name'] : ''; |
| 68 | - // Strip slashes from the value before saving | |
| 69 | - $value = isset($_POST['value']) ? stripslashes($_POST['value']) : ''; | |
| 367 | + // Remove WP's added slashes before saving (wp_unslash is the canonical form; plan-3f8158). | |
| 368 | + $value = isset($_POST['value']) ? wp_unslash($_POST['value']) : ''; | |
| 70 | 369 | |
| 71 | 370 | //error_log('MXChat Save: Processing field name: ' . $name); |
| 72 | 371 | //error_log('MXChat Save: Field value: ' . $value); |
| 73 | 372 | |
| @@ -88,8 +387,113 @@ | ||
| 88 | 387 | } |
| 89 | 388 | |
| 90 | 389 | // Handle special cases |
| 91 | 390 | switch ($field_name) { |
| 391 | + // Editor Assistant enable toggle (plan-8cb0cb). STANDALONE option — NOT part | |
| 392 | + // of mxchat_options, so it skips the mxchat_sanitize strip-trap entirely. Save | |
| 393 | + // it directly and short-circuit (mirrors the mxchat_transcripts_options pattern | |
| 394 | + // below); never falls through to the generic mxchat_options save. Default OFF. | |
| 395 | + case 'mxchat_editor_assistant_enabled': | |
| 396 | + $ea_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; | |
| 397 | + update_option('mxchat_editor_assistant_enabled', $ea_value); | |
| 398 | + wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); | |
| 399 | + return; | |
| 400 | + | |
| 401 | + // Smart asset loading toggle (plan-915355). STANDALONE option, same | |
| 402 | + // reasoning as the Editor Assistant case above — saved directly and | |
| 403 | + // short-circuited so it never touches mxchat_options / mxchat_sanitize. | |
| 404 | + // Default OFF (opt-in performance optimization). | |
| 405 | + case 'mxchat_smart_asset_loading': | |
| 406 | + $sal_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; | |
| 407 | + update_option('mxchat_smart_asset_loading', $sal_value); | |
| 408 | + wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); | |
| 409 | + return; | |
| 410 | + | |
| 411 | + // Hybrid keyword boost toggle (plan-38ffa1). STANDALONE option, same | |
| 412 | + // pattern. Enabling runs capability detection HERE, at admin-save time — | |
| 413 | + // building the FULLTEXT index during a visitor's chat request is not | |
| 414 | + // acceptable, and detection is a one-time cost the admin can wait on. | |
| 415 | + case 'mxchat_hybrid_keyword_toggle': | |
| 416 | + $hkb_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; | |
| 417 | + update_option('mxchat_hybrid_keyword_toggle', $hkb_value); | |
| 418 | + if ($hkb_value === 'on') { | |
| 419 | + MxChat_Utils::mxchat_hybrid_detect_capability(true); | |
| 420 | + } | |
| 421 | + wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); | |
| 422 | + return; | |
| 423 | + | |
| 424 | + // ACF→PDF import-time extraction (plan 11720c). STANDALONE option, same | |
| 425 | + // pattern. Moved from a per-import modal checkbox to an install-level | |
| 426 | + // setting on Knowledge → ACF Fields. Stored '1'/'0' to match the | |
| 427 | + // knowledge page's sibling toggles. Default OFF. | |
| 428 | + case 'mxchat_acf_pdf_extraction': | |
| 429 | + $apx_value = ($value === 'on' || $value === '1') ? '1' : '0'; | |
| 430 | + update_option('mxchat_acf_pdf_extraction', $apx_value); | |
| 431 | + wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); | |
| 432 | + return; | |
| 433 | + | |
| 434 | + // In-chat YouTube card: master switch + its own confidence floor | |
| 435 | + // (plan f52492). STANDALONE options, same pattern as the cases above. | |
| 436 | + // Default ON — the card already ships, so this is an opt-OUT. | |
| 437 | + case 'mxchat_video_embed_enabled': | |
| 438 | + $vce_value = ($value === 'on' || $value === '1') ? 'on' : 'off'; | |
| 439 | + update_option('mxchat_video_embed_enabled', $vce_value); | |
| 440 | + wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); | |
| 441 | + return; | |
| 442 | + | |
| 443 | + // Clamped to the same 20-95 the field advertises. An out-of-range or | |
| 444 | + // non-numeric POST is corrected rather than refused, and the corrected | |
| 445 | + // value is echoed back so the field can reconcile — a silently stored | |
| 446 | + // 0 here would put a video on every answer, which is the bug. | |
| 447 | + case 'mxchat_video_embed_threshold': | |
| 448 | + $vct_value = is_numeric($value) | |
| 449 | + ? (int) $value | |
| 450 | + : MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT; | |
| 451 | + if ($vct_value < 20) { $vct_value = 20; } | |
| 452 | + if ($vct_value > 95) { $vct_value = 95; } | |
| 453 | + update_option('mxchat_video_embed_threshold', $vct_value); | |
| 454 | + wp_send_json_success([ | |
| 455 | + 'message' => esc_html__('Setting saved', 'mxchat'), | |
| 456 | + 'value' => $vct_value, | |
| 457 | + ]); | |
| 458 | + return; | |
| 459 | + | |
| 460 | + // Live-agent availability schedules (plans 8ccaa2 + 99d7a4). STANDALONE | |
| 461 | + // options, same reasoning as the Editor Assistant case above — nested | |
| 462 | + // structures that mxchat_sanitize() would strip on the next autosave of any | |
| 463 | + // other field. Each channel's value arrives as JSON from its own hidden | |
| 464 | + // input, which that channel's schedule editor keeps in sync; the class owns | |
| 465 | + // all validation. The bare legacy name is kept as a defense against a | |
| 466 | + // browser still running pre-split cached admin JS: that UI edited "both | |
| 467 | + // channels" as one, so its save writes both. | |
| 468 | + case 'live_agent_schedule_slack': | |
| 469 | + case 'live_agent_schedule_telegram': | |
| 470 | + case 'live_agent_schedule_webhook': | |
| 471 | + case 'live_agent_schedule': | |
| 472 | + if (!class_exists('MxChat_Live_Agent_Schedule')) { | |
| 473 | + wp_send_json_error(['message' => esc_html__('Schedule unavailable', 'mxchat')]); | |
| 474 | + return; | |
| 475 | + } | |
| 476 | + $decoded = json_decode($value, true); | |
| 477 | + if (!is_array($decoded)) { | |
| 478 | + wp_send_json_error(['message' => esc_html__('Invalid schedule', 'mxchat')]); | |
| 479 | + return; | |
| 480 | + } | |
| 481 | + $channels = ($field_name === 'live_agent_schedule') | |
| 482 | + ? array('slack', 'telegram') | |
| 483 | + : array(substr($field_name, strlen('live_agent_schedule_'))); | |
| 484 | + $saved_schedule = null; | |
| 485 | + foreach ($channels as $schedule_channel) { | |
| 486 | + $saved_schedule = MxChat_Live_Agent_Schedule::save($schedule_channel, $decoded); | |
| 487 | + } | |
| 488 | + // Echo the normalized result so the editor can reconcile if it ever | |
| 489 | + // disagrees with the server (e.g. a time the class rejected). | |
| 490 | + wp_send_json_success([ | |
| 491 | + 'message' => esc_html__('Setting saved', 'mxchat'), | |
| 492 | + 'schedule' => $saved_schedule, | |
| 493 | + ]); | |
| 494 | + return; | |
| 495 | + | |
| 92 | 496 | case 'model': |
| 93 | 497 | //error_log('MXChat Save: Processing model selection'); |
| 94 | 498 | //error_log('MXChat Save: Model value received: ' . $value); |
| 95 | 499 | //error_log('MXChat Save: Value type: ' . gettype($value)); |
| @@ -101,20 +505,15 @@ | ||
| 101 | 505 | //error_log('MXChat Save: Setting model to openrouter'); |
| 102 | 506 | $options['model'] = 'openrouter'; |
| 103 | 507 | } else { |
| 104 | 508 | //error_log('MXChat Save: Checking against whitelist'); |
| 105 | - $allowed_models = array( | |
| 106 | - 'gemini-3-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', | |
| 107 | - 'gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash', | |
| 108 | - 'grok-4-0709', 'grok-4-1-fast-reasoning', 'grok-4-1-fast-non-reasoning', 'grok-3-beta', 'grok-3-fast-beta', 'grok-3-mini-beta', | |
| 109 | - 'grok-3-mini-fast-beta', 'grok-2', | |
| 110 | - 'deepseek-chat', | |
| 111 | - 'claude-opus-4-6', 'claude-opus-4-5', 'claude-sonnet-4-6', | |
| 112 | - 'claude-sonnet-4-5-20250929', 'claude-opus-4-1-20250805', 'claude-haiku-4-5-20251001', | |
| 113 | - 'claude-opus-4-20250514', 'claude-sonnet-4-20250514', | |
| 114 | - 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-5.3-chat-latest', | |
| 115 | - 'gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.1-2025-11-13', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', | |
| 116 | - ); | |
| 509 | + // Catalog refactor (plan-d14e89): canonical allowlist lives in | |
| 510 | + // includes/class-mxchat-model-catalog.php. A new chat model | |
| 511 | + // added there is automatically accepted by autosave. | |
| 512 | + if (!class_exists('MxChat_Model_Catalog')) { | |
| 513 | + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-mxchat-model-catalog.php'; | |
| 514 | + } | |
| 515 | + $allowed_models = MxChat_Model_Catalog::chat_model_ids(); | |
| 117 | 516 | |
| 118 | 517 | //error_log('MXChat Save: in_array result: ' . (in_array($value, $allowed_models) ? 'YES' : 'NO')); |
| 119 | 518 | |
| 120 | 519 | if (in_array($value, $allowed_models)) { |
| @@ -166,8 +565,15 @@ | ||
| 166 | 565 | //error_log('MXChat Save: Processing email_blocker_header_content'); |
| 167 | 566 | // Allow HTML content but sanitize it safely |
| 168 | 567 | $options[$field_name] = wp_kses_post($value); |
| 169 | 568 | break; |
| 569 | + case 'intro_message': | |
| 570 | + // Stored-XSS hardening (Wordfence CWE-79, plan-3f8158): sanitize on save as | |
| 571 | + // defense in depth. wp_kses_post mirrors mxchat_sanitize() (the options.php | |
| 572 | + // save path) so both save routes treat intro_message identically and strip | |
| 573 | + // <script>/</textarea> breakout while keeping basic formatting + {visitor_name}. | |
| 574 | + $options[$field_name] = wp_kses_post($value); | |
| 575 | + break; | |
| 170 | 576 | case 'email_blocker_button_text': |
| 171 | 577 | //error_log('MXChat Save: Processing email_blocker_button_text'); |
| 172 | 578 | $options[$field_name] = sanitize_text_field($value); |
| 173 | 579 | break; |
| @@ -174,8 +580,13 @@ | ||
| 174 | 580 | case 'name_field_placeholder': |
| 175 | 581 | //error_log('MXChat Save: Processing name_field_placeholder'); |
| 176 | 582 | $options[$field_name] = sanitize_text_field($value); |
| 177 | 583 | break; |
| 584 | + case 'consent_checkbox_label': | |
| 585 | + // b062c4 — same allowlist as the options.php save path and the | |
| 586 | + // widget render, so the stored label always equals the shown label. | |
| 587 | + $options[$field_name] = MxChat_Utils::sanitize_consent_label($value); | |
| 588 | + break; | |
| 178 | 589 | case 'similarity_threshold': |
| 179 | 590 | //error_log('MXChat Save: Processing similarity_threshold'); |
| 180 | 591 | // Validate and save - enforce min 20, max 85 |
| 181 | 592 | $threshold = intval($value); |
| @@ -202,8 +613,49 @@ | ||
| 202 | 613 | //error_log('MXChat Save: Processing live_agent_status'); |
| 203 | 614 | // Set the new value |
| 204 | 615 | $options[$field_name] = ($value === 'on') ? 'on' : 'off'; |
| 205 | 616 | break; |
| 617 | + // Shared handoff channel (plan 1a2666): re-probe the channel's privacy | |
| 618 | + // at configuration time so the settings screen can warn about private | |
| 619 | + // channels (their inbound events arrive as message.groups, which the | |
| 620 | + // documented app setup never subscribes to). Only id-shaped values can | |
| 621 | + // be checked before the first handoff resolves a #name — the handoff | |
| 622 | + // path probes those when it caches the resolved id. | |
| 623 | + case 'live_agent_shared_channel': | |
| 624 | + $options[$field_name] = sanitize_text_field($value); | |
| 625 | + delete_option('mxchat_slack_shared_channel_privacy'); | |
| 626 | + $shared_channel_target = ltrim(trim((string) $options[$field_name]), '#'); | |
| 627 | + if ($shared_channel_target !== '' && preg_match('/^[CG][A-Z0-9]{6,}$/', $shared_channel_target)) { | |
| 628 | + if (!class_exists('MxChat_Integrator')) { | |
| 629 | + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-mxchat-integrator.php'; | |
| 630 | + } | |
| 631 | + MxChat_Integrator::mxchat_probe_slack_channel_privacy( | |
| 632 | + $options['live_agent_bot_token'] ?? '', | |
| 633 | + $shared_channel_target, | |
| 634 | + trim((string) $options[$field_name]) | |
| 635 | + ); | |
| 636 | + } | |
| 637 | + break; | |
| 638 | + // Webhook handoff destination (plan d88e22). Status normalized like the | |
| 639 | + // other channel toggles; the URL is refused outright when it isn't | |
| 640 | + // https so the admin hears about it at save time instead of the | |
| 641 | + // handoff silently never firing. | |
| 642 | + case 'webhook_handoff_status': | |
| 643 | + $options[$field_name] = ($value === 'on') ? 'on' : 'off'; | |
| 644 | + break; | |
| 645 | + case 'webhook_handoff_url': | |
| 646 | + $wh_url = trim((string) $value); | |
| 647 | + if ($wh_url === '') { | |
| 648 | + $options[$field_name] = ''; | |
| 649 | + break; | |
| 650 | + } | |
| 651 | + $wh_clean = esc_url_raw($wh_url, array('https')); | |
| 652 | + if ($wh_clean === '' || stripos($wh_clean, 'https://') !== 0) { | |
| 653 | + wp_send_json_error(['message' => esc_html__('Webhook URL must start with https://', 'mxchat')]); | |
| 654 | + return; | |
| 655 | + } | |
| 656 | + $options[$field_name] = $wh_clean; | |
| 657 | + break; | |
| 206 | 658 | case 'enable_web_search': |
| 207 | 659 | //error_log('MXChat Save: Processing enable_web_search'); |
| 208 | 660 | $options[$field_name] = ($value === 'on') ? 'on' : 'off'; |
| 209 | 661 | break; |
| @@ -231,8 +683,12 @@ | ||
| 231 | 683 | // Validate script loading strategy value |
| 232 | 684 | $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction'); |
| 233 | 685 | $options[$field_name] = in_array($value, $allowed_strategies) ? $value : 'default'; |
| 234 | 686 | break; |
| 687 | + case 'auto_retry_on_transient_error': | |
| 688 | + // Boolean toggle — accept 1/0/on/off, default to '1' if any truthy value. | |
| 689 | + $options[$field_name] = ($value === '1' || $value === 'on' || $value === 1 || $value === true) ? '1' : '0'; | |
| 690 | + break; | |
| 235 | 691 | default: |
| 236 | 692 | // Handle transcripts options |
| 237 | 693 | if (strpos($name, 'mxchat_transcripts_options') !== false) { |
| 238 | 694 | // Extract field name from mxchat_transcripts_options[field_name] |
| @@ -247,9 +703,16 @@ | ||
| 247 | 703 | $transcripts_options = array(); |
| 248 | 704 | } |
| 249 | 705 | |
| 250 | 706 | // Handle checkbox values (convert 'on'/'off' to 1/0) |
| 251 | - if ($value === 'on' || $value === '1') { | |
| 707 | + if ($field_name === 'mxchat_retention_days') { | |
| 708 | + // Number field, NOT a checkbox — without this branch a | |
| 709 | + // value of '1' would hit the 'on'/'1' coercion below | |
| 710 | + // (harmlessly) but nothing would clamp: this direct-DB | |
| 711 | + // path bypasses the registered sanitiser and its | |
| 712 | + // 0-3650 clamp entirely (plan-3c3338). | |
| 713 | + $transcripts_options[$field_name] = max(0, min(3650, (int) $value)); | |
| 714 | + } else if ($value === 'on' || $value === '1') { | |
| 252 | 715 | $transcripts_options[$field_name] = 1; |
| 253 | 716 | } else if ($value === 'off' || $value === '0' || $value === '') { |
| 254 | 717 | $transcripts_options[$field_name] = 0; |
| 255 | 718 | } else { |
| @@ -254,9 +717,20 @@ | ||
| 254 | 717 | $transcripts_options[$field_name] = 0; |
| 255 | 718 | } else { |
| 256 | 719 | // For text/select fields, sanitize appropriately |
| 257 | 720 | if ($field_name === 'mxchat_notification_email') { |
| 258 | - $transcripts_options[$field_name] = sanitize_email($value); | |
| 721 | + // sanitize_email() alone CANNOT validate this field: given | |
| 722 | + // "a@x.com, b@y.com" it returns the single concatenated | |
| 723 | + // address "a@x.comby.com", which is_email() then accepts. | |
| 724 | + // That is how two addresses used to be stored as one dead | |
| 725 | + // one, silently. MxChat_Utils validates the raw parts first | |
| 726 | + // and refuses the whole list if any of them is bad. | |
| 727 | + $parsed = MxChat_Utils::parse_notification_emails($value); | |
| 728 | + if ($parsed['error'] !== '') { | |
| 729 | + // Reject: the previously stored value stays untouched. | |
| 730 | + wp_send_json_error(array('message' => $parsed['error'])); | |
| 731 | + } | |
| 732 | + $transcripts_options[$field_name] = implode(', ', $parsed['emails']); | |
| 259 | 733 | } else { |
| 260 | 734 | $transcripts_options[$field_name] = sanitize_text_field($value); |
| 261 | 735 | } |
| 262 | 736 | } |
| @@ -300,8 +774,48 @@ | ||
| 300 | 774 | wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); |
| 301 | 775 | return; |
| 302 | 776 | } |
| 303 | 777 | } |
| 778 | + // Whole-chatbot global cap (sits in mxchat_options['rate_limits_global']). | |
| 779 | + // Field names: mxchat_options[rate_limits_global][limit|timeframe|limit_custom] | |
| 780 | + else if (strpos($name, 'mxchat_options[rate_limits_global]') !== false) { | |
| 781 | + preg_match('/\[rate_limits_global\]\[(.*?)\]/', $name, $matches); | |
| 782 | + if (isset($matches[1])) { | |
| 783 | + $setting_key = $matches[1]; | |
| 784 | + if (!isset($options['rate_limits_global']) || !is_array($options['rate_limits_global'])) { | |
| 785 | + $options['rate_limits_global'] = array('limit' => 'unlimited', 'timeframe' => 'daily'); | |
| 786 | + } | |
| 787 | + if ($setting_key === 'limit') { | |
| 788 | + // Selection from the preset dropdown. If __custom__, resolve from limit_custom; otherwise store directly. | |
| 789 | + if ($value === '__custom__') { | |
| 790 | + $custom = isset($options['rate_limits_global']['limit_custom']) ? (string) $options['rate_limits_global']['limit_custom'] : ''; | |
| 791 | + if ($custom !== '' && ctype_digit($custom) && (int) $custom >= 1) { | |
| 792 | + $options['rate_limits_global']['limit'] = $custom; | |
| 793 | + } | |
| 794 | + // else leave existing limit untouched until the custom value arrives | |
| 795 | + } else { | |
| 796 | + $options['rate_limits_global']['limit'] = $value; | |
| 797 | + } | |
| 798 | + } elseif ($setting_key === 'limit_custom') { | |
| 799 | + $clean = preg_replace('/[^0-9]/', '', (string) $value); | |
| 800 | + $options['rate_limits_global']['limit_custom'] = $clean; | |
| 801 | + // Mirror a valid custom value into limit UNCONDITIONALLY (plan-74eb86). | |
| 802 | + // The custom number input is only editable when the dropdown is on | |
| 803 | + // "Custom…" (the toggle JS hides it for presets/unlimited) and autosave | |
| 804 | + // sends one field per change event, so a limit_custom change only fires | |
| 805 | + // in custom mode — there is no preset to clobber. The old guard required | |
| 806 | + // limit to already be non-preset, which it isn't on a first-time custom | |
| 807 | + // entry (the limit=__custom__ event arrives before limit_custom is set), | |
| 808 | + // so the value never landed in limit on the first save and reverted on refresh. | |
| 809 | + if ($clean !== '' && (int) $clean >= 1) { | |
| 810 | + $options['rate_limits_global']['limit'] = $clean; | |
| 811 | + } | |
| 812 | + } elseif ($setting_key === 'timeframe') { | |
| 813 | + $allowed_tf = array('hourly','daily','weekly','monthly'); | |
| 814 | + $options['rate_limits_global']['timeframe'] = in_array($value, $allowed_tf, true) ? $value : 'daily'; | |
| 815 | + } | |
| 816 | + } | |
| 817 | + } | |
| 304 | 818 | // First check for rate limits settings |
| 305 | 819 | else if (strpos($name, 'mxchat_options[rate_limits]') !== false) { |
| 306 | 820 | //error_log('MXChat Save: Detected rate_limits field: ' . $name); |
| 307 | 821 | |
| @@ -310,9 +824,9 @@ | ||
| 310 | 824 | //error_log('MXChat Save: Regex matches: ' . print_r($matches, true)); |
| 311 | 825 | |
| 312 | 826 | if (isset($matches[1]) && isset($matches[2])) { |
| 313 | 827 | $role_id = $matches[1]; |
| 314 | - $setting_key = $matches[2]; // limit, timeframe, or message | |
| 828 | + $setting_key = $matches[2]; // limit, timeframe, message, or limit_custom | |
| 315 | 829 | |
| 316 | 830 | //error_log('MXChat Save: Role ID = ' . $role_id . ', Setting Key = ' . $setting_key); |
| 317 | 831 | |
| 318 | 832 | // Initialize rate_limits if it doesn't exist |
| @@ -330,10 +844,32 @@ | ||
| 330 | 844 | 'message' => 'Rate limit exceeded. Please try again later.' |
| 331 | 845 | ]; |
| 332 | 846 | } |
| 333 | 847 | |
| 334 | - // Update the specific setting | |
| 335 | - $options['rate_limits'][$role_id][$setting_key] = $value; | |
| 848 | + if ($setting_key === 'limit') { | |
| 849 | + if ($value === '__custom__') { | |
| 850 | + // Pull the integer from limit_custom that may have arrived (or will arrive). | |
| 851 | + $custom = isset($options['rate_limits'][$role_id]['limit_custom']) ? (string) $options['rate_limits'][$role_id]['limit_custom'] : ''; | |
| 852 | + if ($custom !== '' && ctype_digit($custom) && (int) $custom >= 1) { | |
| 853 | + $options['rate_limits'][$role_id]['limit'] = $custom; | |
| 854 | + } | |
| 855 | + } else { | |
| 856 | + $options['rate_limits'][$role_id]['limit'] = $value; | |
| 857 | + } | |
| 858 | + } elseif ($setting_key === 'limit_custom') { | |
| 859 | + $clean = preg_replace('/[^0-9]/', '', (string) $value); | |
| 860 | + $options['rate_limits'][$role_id]['limit_custom'] = $clean; | |
| 861 | + // Mirror a valid custom value into limit UNCONDITIONALLY — same reasoning | |
| 862 | + // as the global branch above (plan-74eb86). The per-role custom input is | |
| 863 | + // only editable in custom mode and autosave is one-field-per-change, so | |
| 864 | + // this never clobbers a preset; it fixes the first-time-save revert. | |
| 865 | + if ($clean !== '' && (int) $clean >= 1) { | |
| 866 | + $options['rate_limits'][$role_id]['limit'] = $clean; | |
| 867 | + } | |
| 868 | + } else { | |
| 869 | + // Update the specific setting (timeframe, message) | |
| 870 | + $options['rate_limits'][$role_id][$setting_key] = $value; | |
| 871 | + } | |
| 336 | 872 | //error_log('MXChat Save: Updated rate_limits[' . $role_id . '][' . $setting_key . '] = ' . $value); |
| 337 | 873 | } else { |
| 338 | 874 | //error_log('MXChat Save: Failed to parse rate_limits pattern: ' . $name); |
| 339 | 875 | } |
| @@ -370,9 +906,15 @@ | ||
| 370 | 906 | 'enable_streaming_toggle', |
| 371 | 907 | 'contextual_awareness_toggle', |
| 372 | 908 | 'citation_links_toggle', |
| 373 | 909 | 'enable_email_block', |
| 374 | - 'enable_name_field' | |
| 910 | + 'enable_name_field', | |
| 911 | + 'enable_consent_checkbox', | |
| 912 | + 'consent_checkbox_required', | |
| 913 | + 'custom_provider_for_embeddings', | |
| 914 | + 'custom_provider_for_images', | |
| 915 | + 'print_button_enabled', | |
| 916 | + 'reset_chat_enabled' | |
| 375 | 917 | ])) { |
| 376 | 918 | //error_log('MXChat Save: Processing toggle: ' . $field_name); |
| 377 | 919 | $options[$field_name] = ($value === 'on') ? 'on' : 'off'; |
| 378 | 920 | } else { |
| @@ -515,8 +1057,14 @@ | ||
| 515 | 1057 | if ($field_name === 'mxchat_pinecone_host') { |
| 516 | 1058 | $new_value = str_replace(['https://', 'http://'], '', $new_value); |
| 517 | 1059 | } |
| 518 | 1060 | break; |
| 1061 | + case 'mxchat_pinecone_top_k': | |
| 1062 | + // d0cae1: out-of-range and junk normalize to the default 50 — | |
| 1063 | + // same clamp the read site applies. | |
| 1064 | + $top_k = absint($value); | |
| 1065 | + $new_value = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50); | |
| 1066 | + break; | |
| 519 | 1067 | default: |
| 520 | 1068 | wp_send_json_error(['message' => esc_html__('Unknown Pinecone field', 'mxchat')]); |
| 521 | 1069 | } |
| 522 | 1070 | |
| @@ -546,14 +1094,16 @@ | ||
| 546 | 1094 | ); |
| 547 | 1095 | //error_log('[MXCHAT-PROMPTS] Updated existing option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED')); |
| 548 | 1096 | } else { |
| 549 | 1097 | // Insert new option |
| 1098 | + // Credential option — must NOT autoload (holds the Pinecone secret; | |
| 1099 | + // autoloaded rows are read into memory on every request). | |
| 550 | 1100 | $save_result = $wpdb->insert( |
| 551 | 1101 | $wpdb->options, |
| 552 | 1102 | array( |
| 553 | 1103 | 'option_name' => 'mxchat_pinecone_addon_options', |
| 554 | 1104 | 'option_value' => $serialized_options, |
| 555 | - 'autoload' => 'yes' | |
| 1105 | + 'autoload' => 'off' | |
| 556 | 1106 | ), |
| 557 | 1107 | array('%s', '%s', '%s') |
| 558 | 1108 | ); |
| 559 | 1109 | //error_log('[MXCHAT-PROMPTS] Inserted new option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED')); |
| @@ -671,10 +1221,18 @@ | ||
| 671 | 1221 | } |
| 672 | 1222 | |
| 673 | 1223 | // Handle ACF field exclusion toggles |
| 674 | 1224 | if (strpos($name, 'mxchat_acf_field_') === 0) { |
| 675 | - // Extract field name from the input name (e.g., mxchat_acf_field_private_notes -> private_notes) | |
| 676 | - $field_name = str_replace('mxchat_acf_field_', '', $name); | |
| 1225 | + // The identifier after the prefix is the ACF field KEY (unique per | |
| 1226 | + // field), not the field name — names are shared across groups and | |
| 1227 | + // collide (plan 30e81f). Reject anything that isn't key-shaped so a | |
| 1228 | + // stale pre-3.2.20 page (or its exit beacon) posting a bare name | |
| 1229 | + // can't write junk into the key-based list. | |
| 1230 | + $field_key = str_replace('mxchat_acf_field_', '', $name); | |
| 1231 | + if (!preg_match('/^field_[A-Za-z0-9_\-]+$/', $field_key)) { | |
| 1232 | + wp_send_json_error(['message' => esc_html__('Invalid ACF field identifier', 'mxchat')]); | |
| 1233 | + return; | |
| 1234 | + } | |
| 677 | 1235 | $is_enabled = ($value === 'on' || $value === '1'); |
| 678 | 1236 | |
| 679 | 1237 | // Get current excluded fields |
| 680 | 1238 | $excluded_fields = get_option('mxchat_acf_excluded_fields', array()); |
| @@ -683,13 +1241,19 @@ | ||
| 683 | 1241 | } |
| 684 | 1242 | |
| 685 | 1243 | if ($is_enabled) { |
| 686 | 1244 | // Remove from exclusion list (field should be included) |
| 687 | - $excluded_fields = array_values(array_diff($excluded_fields, array($field_name))); | |
| 1245 | + $excluded_fields = array_values(array_diff($excluded_fields, array($field_key))); | |
| 1246 | + // Lazy legacy-name conversion: if this field's NAME is still | |
| 1247 | + // stored (its group was inactive when the 30e81f migration | |
| 1248 | + // ran), including this one field must not silently include | |
| 1249 | + // its same-named twins — swap the name entry for the keys of | |
| 1250 | + // every OTHER field currently wearing that name. | |
| 1251 | + $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field_key); | |
| 688 | 1252 | } else { |
| 689 | 1253 | // Add to exclusion list (field should be excluded) |
| 690 | - if (!in_array($field_name, $excluded_fields)) { | |
| 691 | - $excluded_fields[] = $field_name; | |
| 1254 | + if (!in_array($field_key, $excluded_fields, true)) { | |
| 1255 | + $excluded_fields[] = $field_key; | |
| 692 | 1256 | } |
| 693 | 1257 | } |
| 694 | 1258 | |
| 695 | 1259 | $updated = update_option('mxchat_acf_excluded_fields', $excluded_fields); |
| @@ -696,10 +1260,10 @@ | ||
| 696 | 1260 | |
| 697 | 1261 | if ($updated || true) { // Always report success since the state may already be correct |
| 698 | 1262 | wp_send_json_success([ |
| 699 | 1263 | 'message' => $is_enabled |
| 700 | - ? sprintf(esc_html__('Field "%s" will be included in imports', 'mxchat'), $field_name) | |
| 701 | - : sprintf(esc_html__('Field "%s" will be excluded from imports', 'mxchat'), $field_name) | |
| 1264 | + ? esc_html__('Field will be included in imports', 'mxchat') | |
| 1265 | + : esc_html__('Field will be excluded from imports', 'mxchat') | |
| 702 | 1266 | ]); |
| 703 | 1267 | } else { |
| 704 | 1268 | wp_send_json_error(['message' => esc_html__('Failed to save ACF field setting', 'mxchat')]); |
| 705 | 1269 | } |
| @@ -719,12 +1283,12 @@ | ||
| 719 | 1283 | } |
| 720 | 1284 | return; |
| 721 | 1285 | } |
| 722 | 1286 | |
| 723 | - // Handle other prompts options | |
| 1287 | + // Handle other prompts options (autoload false — can hold the Pinecone secret) | |
| 724 | 1288 | $options = get_option('mxchat_prompts_options', []); |
| 725 | 1289 | $options[$name] = $value; |
| 726 | - $updated = update_option('mxchat_prompts_options', $options); | |
| 1290 | + $updated = update_option('mxchat_prompts_options', $options, false); | |
| 727 | 1291 | |
| 728 | 1292 | if ($updated) { |
| 729 | 1293 | wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]); |
| 730 | 1294 | } else { |
| @@ -731,10 +1295,130 @@ | ||
| 731 | 1295 | wp_send_json_error(['message' => esc_html__('No changes detected', 'mxchat')]); |
| 732 | 1296 | } |
| 733 | 1297 | } |
| 734 | 1298 | |
| 1299 | + /** | |
| 1300 | + * If the field behind $included_key still has its NAME stored in the | |
| 1301 | + * exclusion list (a legacy entry the 30e81f migration could not resolve | |
| 1302 | + * because the group was inactive), replace that name with the keys of | |
| 1303 | + * every OTHER current field wearing it. Including one field must never | |
| 1304 | + * silently include its same-named twins — that would be the original | |
| 1305 | + * collision bug in reverse, in the unsafe (privacy-losing) direction. | |
| 1306 | + */ | |
| 1307 | + private function mxchat_expand_legacy_acf_name_entry($excluded_fields, $included_key) { | |
| 1308 | + if (!function_exists('acf_get_field')) { | |
| 1309 | + return $excluded_fields; | |
| 1310 | + } | |
| 1311 | + $field = acf_get_field($included_key); | |
| 1312 | + if (!$field || empty($field['name'])) { | |
| 1313 | + return $excluded_fields; | |
| 1314 | + } | |
| 1315 | + $field_name = $field['name']; | |
| 1316 | + if (!in_array($field_name, $excluded_fields, true)) { | |
| 1317 | + return $excluded_fields; | |
| 1318 | + } | |
| 1319 | + $excluded_fields = array_values(array_diff($excluded_fields, array($field_name))); | |
| 1320 | + foreach ($this->mxchat_acf_keys_for_name($field_name) as $twin_key) { | |
| 1321 | + if ($twin_key !== $included_key && !in_array($twin_key, $excluded_fields, true)) { | |
| 1322 | + $excluded_fields[] = $twin_key; | |
| 1323 | + } | |
| 1324 | + } | |
| 1325 | + return $excluded_fields; | |
| 1326 | + } | |
| 735 | 1327 | |
| 736 | 1328 | /** |
| 1329 | + * Keys of every currently-registered top-level ACF field with this name. | |
| 1330 | + */ | |
| 1331 | + private function mxchat_acf_keys_for_name($field_name) { | |
| 1332 | + $keys = array(); | |
| 1333 | + if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) { | |
| 1334 | + return $keys; | |
| 1335 | + } | |
| 1336 | + foreach (acf_get_field_groups() as $group) { | |
| 1337 | + $group_fields = acf_get_fields($group['key']); | |
| 1338 | + if (empty($group_fields)) { | |
| 1339 | + continue; | |
| 1340 | + } | |
| 1341 | + foreach ($group_fields as $field) { | |
| 1342 | + if (isset($field['name'], $field['key']) && $field['name'] === $field_name) { | |
| 1343 | + $keys[] = $field['key']; | |
| 1344 | + } | |
| 1345 | + } | |
| 1346 | + } | |
| 1347 | + return $keys; | |
| 1348 | + } | |
| 1349 | + | |
| 1350 | + /** | |
| 1351 | + * Group-level ACF toggle (plan bf57e0): sets every field in one ACF field | |
| 1352 | + * group included or excluded in a SINGLE option write. The client must | |
| 1353 | + * never loop the per-field endpoint for this — get_option → modify → | |
| 1354 | + * update_option once per field from twenty concurrent requests is a | |
| 1355 | + * lost-update race that silently drops most of the group. | |
| 1356 | + */ | |
| 1357 | + public function mxchat_acf_toggle_group_callback() { | |
| 1358 | + check_ajax_referer('mxchat_prompts_setting_nonce'); | |
| 1359 | + | |
| 1360 | + if (!current_user_can('manage_options')) { | |
| 1361 | + wp_send_json_error(['message' => esc_html__('Insufficient permissions', 'mxchat')], 403); | |
| 1362 | + return; | |
| 1363 | + } | |
| 1364 | + | |
| 1365 | + if (!function_exists('acf_get_fields')) { | |
| 1366 | + wp_send_json_error(['message' => esc_html__('ACF is not active', 'mxchat')]); | |
| 1367 | + return; | |
| 1368 | + } | |
| 1369 | + | |
| 1370 | + $group_key = isset($_POST['group_key']) ? sanitize_text_field(wp_unslash($_POST['group_key'])) : ''; | |
| 1371 | + if (!preg_match('/^group_[A-Za-z0-9_\-]+$/', $group_key)) { | |
| 1372 | + wp_send_json_error(['message' => esc_html__('Invalid ACF group identifier', 'mxchat')]); | |
| 1373 | + return; | |
| 1374 | + } | |
| 1375 | + $state = isset($_POST['state']) ? sanitize_text_field(wp_unslash($_POST['state'])) : ''; | |
| 1376 | + $include = ($state === 'on' || $state === '1'); | |
| 1377 | + | |
| 1378 | + // Resolve the group's fields SERVER-side — a client-supplied key list | |
| 1379 | + // is not trusted. This is the same call the settings UI lists from, | |
| 1380 | + // so the toggle covers exactly the rendered set (top-level fields). | |
| 1381 | + $group_fields = acf_get_fields($group_key); | |
| 1382 | + if (empty($group_fields)) { | |
| 1383 | + wp_send_json_error(['message' => esc_html__('No fields found for this group', 'mxchat')]); | |
| 1384 | + return; | |
| 1385 | + } | |
| 1386 | + | |
| 1387 | + $excluded_fields = get_option('mxchat_acf_excluded_fields', array()); | |
| 1388 | + if (!is_array($excluded_fields)) { | |
| 1389 | + $excluded_fields = array(); | |
| 1390 | + } | |
| 1391 | + | |
| 1392 | + $touched = array(); | |
| 1393 | + foreach ($group_fields as $field) { | |
| 1394 | + if (empty($field['key'])) { | |
| 1395 | + continue; | |
| 1396 | + } | |
| 1397 | + if ($include) { | |
| 1398 | + $excluded_fields = array_values(array_diff($excluded_fields, array($field['key']))); | |
| 1399 | + $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field['key']); | |
| 1400 | + } elseif (!in_array($field['key'], $excluded_fields, true)) { | |
| 1401 | + $excluded_fields[] = $field['key']; | |
| 1402 | + } | |
| 1403 | + $touched[] = array( | |
| 1404 | + 'name' => 'mxchat_acf_field_' . $field['key'], | |
| 1405 | + 'value' => $include ? 'on' : 'off', | |
| 1406 | + ); | |
| 1407 | + } | |
| 1408 | + | |
| 1409 | + // The one write — the whole point of this endpoint. | |
| 1410 | + update_option('mxchat_acf_excluded_fields', array_values($excluded_fields)); | |
| 1411 | + | |
| 1412 | + wp_send_json_success([ | |
| 1413 | + 'message' => $include | |
| 1414 | + ? esc_html__('All fields in this group will be included in imports', 'mxchat') | |
| 1415 | + : esc_html__('All fields in this group will be excluded from imports', 'mxchat'), | |
| 1416 | + 'fields' => $touched, | |
| 1417 | + ]); | |
| 1418 | + } | |
| 1419 | + | |
| 1420 | + /** | |
| 737 | 1421 | * Handles AJAX request for Pinecone settings migration |
| 738 | 1422 | */ |
| 739 | 1423 | public function ajax_migrate_pinecone_settings() { |
| 740 | 1424 | // Verify nonce |
| @@ -770,9 +1454,9 @@ | ||
| 770 | 1454 | 'mxchat_pinecone_index' => sanitize_text_field($old_options['mxchat_pinecone_index'] ?? ''), |
| 771 | 1455 | 'mxchat_pinecone_environment' => sanitize_text_field($old_options['mxchat_pinecone_environment'] ?? '') |
| 772 | 1456 | ); |
| 773 | 1457 | |
| 774 | - update_option('mxchat_pinecone_addon_options', $migrated_options); | |
| 1458 | + update_option('mxchat_pinecone_addon_options', $migrated_options, false); | |
| 775 | 1459 | |
| 776 | 1460 | wp_send_json_success(array( |
| 777 | 1461 | 'migrated' => true, |
| 778 | 1462 | 'message' => 'Settings migrated successfully from Pinecone add-on' |
| @@ -931,9 +1615,17 @@ | ||
| 931 | 1615 | //error_log('MxChat deactivate: Nonce check failed'); |
| 932 | 1616 | wp_send_json_error('Security check failed.'); |
| 933 | 1617 | return; |
| 934 | 1618 | } |
| 935 | - | |
| 1619 | + | |
| 1620 | + // plan-mxchat-20260731-c63fb6 — nonce is not authorization. Without this, | |
| 1621 | + // any authenticated user holding the nonce could revoke the site's PRO | |
| 1622 | + // licence. Every sibling handler in this file already checks. | |
| 1623 | + if (!current_user_can('manage_options')) { | |
| 1624 | + wp_send_json_error(esc_html__('Unauthorized', 'mxchat'), 403); | |
| 1625 | + return; | |
| 1626 | + } | |
| 1627 | + | |
| 936 | 1628 | //error_log('MxChat deactivate: Nonce check passed'); |
| 937 | 1629 | |
| 938 | 1630 | $license_key = get_option('mxchat_activation_key'); |
| 939 | 1631 | $email = get_option('mxchat_pro_email'); |
| @@ -1269,8 +1961,74 @@ | ||
| 1269 | 1961 | // Perform the reset |
| 1270 | 1962 | MxChat_Admin::mxchat_reset_all_settings(); |
| 1271 | 1963 | |
| 1272 | 1964 | wp_send_json_success( array( 'message' => esc_html__( 'All settings have been reset to defaults. The page will reload.', 'mxchat' ) ) ); |
| 1965 | + } | |
| 1966 | + | |
| 1967 | + /** | |
| 1968 | + * Reset the global rate-limit usage counter to zero on demand. | |
| 1969 | + * | |
| 1970 | + * Zeroes the WP option mxchat_chat_limit_<bot>_global that the integrator | |
| 1971 | + * increments per message, then returns a freshly-formatted readout string | |
| 1972 | + * so the settings page can update without a reload. Does NOT change any | |
| 1973 | + * enforcement config — purely clears the running counter. | |
| 1974 | + */ | |
| 1975 | + public function mxchat_reset_global_rate_limit_callback() { | |
| 1976 | + // Verify nonce | |
| 1977 | + if ( ! check_ajax_referer( 'mxchat_reset_global_usage', '_ajax_nonce', false ) ) { | |
| 1978 | + wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) ); | |
| 1979 | + } | |
| 1980 | + | |
| 1981 | + // Check permissions | |
| 1982 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 1983 | + wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) ); | |
| 1984 | + } | |
| 1985 | + | |
| 1986 | + // Resolve the per-bot counter key the same way the integrator does. | |
| 1987 | + $bot_id = isset( $_POST['bot_id'] ) ? sanitize_key( wp_unslash( $_POST['bot_id'] ) ) : 'default'; | |
| 1988 | + $safe_bot = preg_replace( '/[^a-zA-Z0-9_]/', '_', $bot_id ); | |
| 1989 | + if ( $safe_bot === '' ) { | |
| 1990 | + $safe_bot = 'default'; | |
| 1991 | + } | |
| 1992 | + $option_key = 'mxchat_chat_limit_' . $safe_bot . '_global'; | |
| 1993 | + | |
| 1994 | + $now = time(); | |
| 1995 | + update_option( $option_key, array( 'count' => 0, 'timestamp' => $now ) ); | |
| 1996 | + | |
| 1997 | + // Recompute the display string so the front-end can update in place. | |
| 1998 | + $all_options = get_option( 'mxchat_options', array() ); | |
| 1999 | + $global_cfg = isset( $all_options['rate_limits_global'] ) && is_array( $all_options['rate_limits_global'] ) | |
| 2000 | + ? $all_options['rate_limits_global'] | |
| 2001 | + : array(); | |
| 2002 | + $limit_raw = isset( $global_cfg['limit'] ) ? (string) $global_cfg['limit'] : 'unlimited'; | |
| 2003 | + // Defensive: if a raw __custom__ ever slips through, fall back to the custom value. | |
| 2004 | + if ( ! ctype_digit( $limit_raw ) && isset( $global_cfg['limit_custom'] ) && ctype_digit( (string) $global_cfg['limit_custom'] ) ) { | |
| 2005 | + $limit_raw = (string) $global_cfg['limit_custom']; | |
| 2006 | + } | |
| 2007 | + $timeframe = isset( $global_cfg['timeframe'] ) ? (string) $global_cfg['timeframe'] : 'daily'; | |
| 2008 | + $windows = array( 'hourly' => 3600, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2592000 ); | |
| 2009 | + $window = isset( $windows[ $timeframe ] ) ? $windows[ $timeframe ] : 86400; | |
| 2010 | + $reset_at = $now + $window; | |
| 2011 | + $limit_int = ctype_digit( $limit_raw ) ? (int) $limit_raw : 0; | |
| 2012 | + | |
| 2013 | + $text = sprintf( | |
| 2014 | + /* translators: 1: used count, 2: limit, 3: remaining, 4: human-readable time until reset */ | |
| 2015 | + esc_html__( '%1$s of %2$s used · %3$s left · resets in %4$s', 'mxchat' ), | |
| 2016 | + number_format_i18n( 0 ), | |
| 2017 | + number_format_i18n( $limit_int ), | |
| 2018 | + number_format_i18n( $limit_int ), | |
| 2019 | + human_time_diff( $now, $reset_at ) | |
| 2020 | + ); | |
| 2021 | + | |
| 2022 | + wp_send_json_success( array( | |
| 2023 | + 'count' => 0, | |
| 2024 | + 'limit' => $limit_int, | |
| 2025 | + 'left' => $limit_int, | |
| 2026 | + 'reset_at' => $reset_at, | |
| 2027 | + 'pct' => 0, | |
| 2028 | + 'text' => $text, | |
| 2029 | + 'message' => esc_html__( 'Usage counter reset.', 'mxchat' ), | |
| 2030 | + ) ); | |
| 1273 | 2031 | } |
| 1274 | 2032 | |
| 1275 | 2033 | } |
| 1276 | 2034 | |