PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / admin / class-ajax-handler.php

class-ajax-handler.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.24, at admin/class-ajax-handler.php

2,065 lines 93.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File: admin/class-ajax-handler.php
4 *
5 * Handles all AJAX requests for MxChat admin functionality
6 */
7
8 if (!defined('ABSPATH')) {
9 exit; // Exit if accessed directly
10 }
11
12 class MxChat_Ajax_Handler {
13
14 private $pinecone_manager = null;
15
16 /**
17 * Constructor - Register all AJAX hooks
18 */
19 public function __construct() {
20 $this->mxchat_init_ajax_hooks();
21 }
22
23
24 /**
25 * Register all AJAX action hooks
26 */
27 private function mxchat_init_ajax_hooks() {
28 // Settings AJAX
29 add_action('wp_ajax_mxchat_save_setting', array($this, 'mxchat_save_setting_callback'));
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'));
32 add_action('wp_ajax_migrate_pinecone_settings', array($this, 'ajax_migrate_pinecone_settings'));
33
34 // License AJAX
35 add_action('wp_ajax_mxchat_handle_activate_license', array($this, 'mxchat_handle_activate_license'));
36 add_action('wp_ajax_mxchat_check_license_status', array($this, 'mxchat_check_license_status'));
37 add_action('wp_ajax_mxchat_deactivate_license', array($this, 'mxchat_deactivate_license'));
38
39 // Actions & Intents AJAX
40 add_action('wp_ajax_mxchat_toggle_action', array($this, 'mxchat_toggle_action'));
41 add_action('wp_ajax_mxchat_update_intent_threshold', array($this, 'mxchat_update_intent_threshold'));
42
43 add_action('wp_ajax_mxchat_save_selected_bot', array($this, 'mxchat_save_selected_bot'));
44 add_action('wp_ajax_mxchat_check_api_keys', array($this, 'mxchat_check_api_keys'));
45
46 // Debug & Optimization AJAX
47 add_action('wp_ajax_mxchat_toggle_debug_mode', array($this, 'mxchat_toggle_debug_mode_callback'));
48 add_action('wp_ajax_mxchat_get_debug_log', array($this, 'mxchat_get_debug_log_callback'));
49 add_action('wp_ajax_mxchat_clear_debug_log', array($this, 'mxchat_clear_debug_log_callback'));
50 add_action('wp_ajax_mxchat_export_settings', array($this, 'mxchat_export_settings_callback'));
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'));
64 }
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
352 // ========================================
353 // SETTINGS AJAX HANDLERS
354 // ========================================
355
356 /**
357 * Validates and saves chat settings via AJAX request
358 */
359 public function mxchat_save_setting_callback() {
360 check_ajax_referer('mxchat_save_setting_nonce');
361 if (!current_user_can('manage_options')) {
362 ('MXChat Save: Unauthorized access attempt');
363 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]);
364 }
365
366 $name = isset($_POST['name']) ? $_POST['name'] : '';
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']) : '';
369
370 //error_log('MXChat Save: Processing field name: ' . $name);
371 //error_log('MXChat Save: Field value: ' . $value);
372
373 if (empty($name)) {
374 //error_log('MXChat Save: Empty field name detected');
375 wp_send_json_error(['message' => esc_html__('Invalid field name', 'mxchat')]);
376 }
377
378 // Load the full options array
379 $options = get_option('mxchat_options', []);
380 //error_log('MXChat Save: Current options array: ' . print_r($options, true));
381
382 // Extract field name from mxchat_options[field_name] format if present
383 // But preserve the full name for special cases like rate_limits that need the full path
384 $field_name = $name;
385 if (preg_match('/^mxchat_options\[([^\[\]]+)\]$/', $name, $matches)) {
386 $field_name = $matches[1];
387 }
388
389 // Handle special cases
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
496 case 'model':
497 //error_log('MXChat Save: Processing model selection');
498 //error_log('MXChat Save: Model value received: ' . $value);
499 //error_log('MXChat Save: Value type: ' . gettype($value));
500 //error_log('MXChat Save: Value length: ' . strlen($value));
501 //error_log('MXChat Save: Value === "openrouter": ' . ($value === 'openrouter' ? 'YES' : 'NO'));
502
503 // Allow 'openrouter' or validate against whitelist
504 if ($value === 'openrouter') {
505 //error_log('MXChat Save: Setting model to openrouter');
506 $options['model'] = 'openrouter';
507 } else {
508 //error_log('MXChat Save: Checking against whitelist');
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();
516
517 //error_log('MXChat Save: in_array result: ' . (in_array($value, $allowed_models) ? 'YES' : 'NO'));
518
519 if (in_array($value, $allowed_models)) {
520 //error_log('MXChat Save: Model is in whitelist, saving');
521 $options['model'] = sanitize_text_field($value);
522 } else {
523 //error_log('MXChat Save: Invalid model rejected: ' . $value);
524 //error_log('MXChat Save: Allowed models: ' . print_r($allowed_models, true));
525 wp_send_json_error(['message' => esc_html__('Invalid model selected', 'mxchat')]);
526 return;
527 }
528 }
529 break;
530
531 case 'openrouter_selected_model':
532 //error_log('MXChat Save: Processing OpenRouter model: ' . $value);
533 $options['openrouter_selected_model'] = sanitize_text_field($value);
534 // Force immediate save for new keys
535 //error_log('MXChat Save: OpenRouter model saved immediately');
536 break;
537
538 case 'openrouter_selected_model_name':
539 //error_log('MXChat Save: Processing OpenRouter model name: ' . $value);
540 $options['openrouter_selected_model_name'] = sanitize_text_field($value);
541 // Force immediate save for new keys
542 //error_log('MXChat Save: OpenRouter model name saved immediately');
543 break;
544
545 case 'openrouter_api_key':
546 //error_log('MXChat Save: Processing OpenRouter API key');
547 $options['openrouter_api_key'] = sanitize_text_field($value);
548 break;
549
550 // REMOVED DUPLICATE case 'openrouter_selected_model_name' HERE!
551
552 case 'additional_popular_questions':
553 //error_log('MXChat Save: Processing additional_popular_questions');
554 $questions = json_decode($value, true); // No need for stripslashes here
555 if (is_array($questions)) {
556 $options[$field_name] = $questions;
557 // Also update old option for backwards compatibility
558 update_option('additional_popular_questions', $questions);
559 //error_log('MXChat Save: Saved ' . count($questions) . ' additional questions');
560 } else {
561 //error_log('MXChat Save: Failed to decode questions JSON');
562 }
563 break;
564 case 'email_blocker_header_content':
565 //error_log('MXChat Save: Processing email_blocker_header_content');
566 // Allow HTML content but sanitize it safely
567 $options[$field_name] = wp_kses_post($value);
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;
576 case 'email_blocker_button_text':
577 //error_log('MXChat Save: Processing email_blocker_button_text');
578 $options[$field_name] = sanitize_text_field($value);
579 break;
580 case 'name_field_placeholder':
581 //error_log('MXChat Save: Processing name_field_placeholder');
582 $options[$field_name] = sanitize_text_field($value);
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;
589 case 'similarity_threshold':
590 //error_log('MXChat Save: Processing similarity_threshold');
591 // Validate and save - enforce min 20, max 85
592 $threshold = intval($value);
593 if ($threshold < 20) $threshold = 20;
594 if ($threshold > 85) $threshold = 85;
595 $options[$field_name] = $threshold;
596 break;
597 case 'rag_sources_limit':
598 //error_log('MXChat Save: Processing rag_sources_limit');
599 // Validate and save - enforce min 3, max 10, default 6
600 $rag_limit = intval($value);
601 if ($rag_limit < 3) $rag_limit = 3;
602 if ($rag_limit > 10) $rag_limit = 10;
603 $options[$field_name] = $rag_limit;
604 break;
605 case 'rag_chunks_limit':
606 // Validate and save - enforce min 8, max 20, default 15
607 $chunks_limit = intval($value);
608 if ($chunks_limit < 8) $chunks_limit = 8;
609 if ($chunks_limit > 20) $chunks_limit = 20;
610 $options[$field_name] = $chunks_limit;
611 break;
612 case 'live_agent_status':
613 //error_log('MXChat Save: Processing live_agent_status');
614 // Set the new value
615 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
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;
658 case 'enable_web_search':
659 //error_log('MXChat Save: Processing enable_web_search');
660 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
661 break;
662 case 'enable_woocommerce_integration':
663 //error_log('MXChat Save: Processing enable_woocommerce_integration');
664 // Handle values that used to be 1/0
665 $options[$field_name] = ($value === 'on' || $value === '1') ? 'on' : 'off';
666 break;
667 case 'post_type_visibility_mode':
668 // Validate mode value
669 $allowed_modes = array('all', 'include', 'exclude');
670 $options[$field_name] = in_array($value, $allowed_modes) ? $value : 'all';
671 break;
672 case 'post_type_visibility_list':
673 // Handle JSON array of post types
674 $post_types = json_decode($value, true);
675 if (is_array($post_types)) {
676 // Sanitize each post type slug
677 $options[$field_name] = array_map('sanitize_key', $post_types);
678 } else {
679 $options[$field_name] = array();
680 }
681 break;
682 case 'script_loading_strategy':
683 // Validate script loading strategy value
684 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
685 $options[$field_name] = in_array($value, $allowed_strategies) ? $value : 'default';
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;
691 default:
692 // Handle transcripts options
693 if (strpos($name, 'mxchat_transcripts_options') !== false) {
694 // Extract field name from mxchat_transcripts_options[field_name]
695 if (preg_match('/mxchat_transcripts_options\[([^\]]+)\]/', $name, $matches)) {
696 $field_name = $matches[1];
697
698 // Get current transcripts options
699 $transcripts_options = get_option('mxchat_transcripts_options', array());
700
701 // Ensure it's an array
702 if (!is_array($transcripts_options)) {
703 $transcripts_options = array();
704 }
705
706 // Handle checkbox values (convert 'on'/'off' to 1/0)
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') {
715 $transcripts_options[$field_name] = 1;
716 } else if ($value === 'off' || $value === '0' || $value === '') {
717 $transcripts_options[$field_name] = 0;
718 } else {
719 // For text/select fields, sanitize appropriately
720 if ($field_name === 'mxchat_notification_email') {
721 // sanitize_email() alone CANNOT validate this field: given
722 // "[email protected], [email protected]" it returns the single concatenated
723 // address "[email protected]", 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']);
733 } else {
734 $transcripts_options[$field_name] = sanitize_text_field($value);
735 }
736 }
737
738 // Use direct database update to bypass any filters
739 global $wpdb;
740
741 // Serialize the options array
742 $serialized = maybe_serialize($transcripts_options);
743
744 // Check if the option already exists in the database
745 $existing = $wpdb->get_var("SELECT option_id FROM {$wpdb->options} WHERE option_name = 'mxchat_transcripts_options'");
746
747 if ($existing) {
748 // Option exists, do an update
749 $result = $wpdb->update(
750 $wpdb->options,
751 array('option_value' => $serialized),
752 array('option_name' => 'mxchat_transcripts_options'),
753 array('%s'),
754 array('%s')
755 );
756 } else {
757 // Option doesn't exist (new install), do an insert
758 $result = $wpdb->insert(
759 $wpdb->options,
760 array(
761 'option_name' => 'mxchat_transcripts_options',
762 'option_value' => $serialized,
763 'autoload' => 'yes'
764 ),
765 array('%s', '%s', '%s')
766 );
767 }
768
769 // Clear all caches after direct DB update
770 wp_cache_delete('mxchat_transcripts_options', 'options');
771 wp_cache_delete('alloptions', 'options');
772 wp_cache_flush();
773
774 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
775 return;
776 }
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 }
818 // First check for rate limits settings
819 else if (strpos($name, 'mxchat_options[rate_limits]') !== false) {
820 //error_log('MXChat Save: Detected rate_limits field: ' . $name);
821
822 // Extract role ID and setting from the name
823 preg_match('/\[rate_limits\]\[(.*?)\]\[(.*?)\]/', $name, $matches);
824 //error_log('MXChat Save: Regex matches: ' . print_r($matches, true));
825
826 if (isset($matches[1]) && isset($matches[2])) {
827 $role_id = $matches[1];
828 $setting_key = $matches[2]; // limit, timeframe, message, or limit_custom
829
830 //error_log('MXChat Save: Role ID = ' . $role_id . ', Setting Key = ' . $setting_key);
831
832 // Initialize rate_limits if it doesn't exist
833 if (!isset($options['rate_limits'])) {
834 // //error_log('MXChat Save: Initializing rate_limits array');
835 $options['rate_limits'] = [];
836 }
837
838 // Initialize role settings if it doesn't exist
839 if (!isset($options['rate_limits'][$role_id])) {
840 //error_log('MXChat Save: Initializing rate_limits for role: ' . $role_id);
841 $options['rate_limits'][$role_id] = [
842 'limit' => ($role_id === 'logged_out') ? '10' : '100',
843 'timeframe' => 'daily',
844 'message' => 'Rate limit exceeded. Please try again later.'
845 ];
846 }
847
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 }
872 //error_log('MXChat Save: Updated rate_limits[' . $role_id . '][' . $setting_key . '] = ' . $value);
873 } else {
874 //error_log('MXChat Save: Failed to parse rate_limits pattern: ' . $name);
875 }
876 }
877 // Then check for role rate limits (old format)
878 else if (strpos($name, 'mxchat_options[role_rate_limits]') !== false) {
879 //error_log('MXChat Save: Processing role_rate_limits field: ' . $name);
880 // Extract role ID from the name
881 preg_match('/\[role_rate_limits\]\[(.*?)\]/', $name, $matches);
882 //error_log('MXChat Save: Regex matches: ' . print_r($matches, true));
883
884 if (isset($matches[1])) {
885 $role_id = $matches[1];
886 // Initialize role_rate_limits if it doesn't exist
887 if (!isset($options['role_rate_limits'])) {
888 //error_log('MXChat Save: Initializing role_rate_limits array');
889 $options['role_rate_limits'] = [];
890 }
891 // Update the specific role's rate limit
892 $options['role_rate_limits'][$role_id] = sanitize_text_field($value);
893 //error_log('MXChat Save: Updated role_rate_limits[' . $role_id . '] = ' . $value);
894 } else {
895 //error_log('MXChat Save: Failed to parse role_rate_limits pattern: ' . $name);
896 }
897 }
898 // Handle toggles - check both extracted field_name and original name for toggle detection
899 else if (strpos($field_name, 'toggle') !== false || in_array($field_name, [
900 'chat_persistence_toggle',
901 'privacy_toggle',
902 'complianz_toggle',
903 'chat_toolbar_toggle',
904 'show_pdf_upload_button',
905 'show_word_upload_button',
906 'enable_streaming_toggle',
907 'contextual_awareness_toggle',
908 'citation_links_toggle',
909 'enable_email_block',
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'
917 ])) {
918 //error_log('MXChat Save: Processing toggle: ' . $field_name);
919 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
920 } else {
921 //error_log('MXChat Save: Processing standard field: ' . $field_name);
922 // Store all other values directly using the extracted field name
923 $options[$field_name] = $value;
924 }
925 break;
926 }
927
928 // Save all updates to the options array
929 $updated = update_option('mxchat_options', $options);
930 //error_log('MXChat Save: Update result: ' . ($updated ? 'success' : 'unchanged') . ' for field: ' . $name);
931 //error_log('MXChat Save: Updated options array: ' . print_r($options, true));
932
933 // Log the save action if debug mode is enabled
934 if ( class_exists( 'MxChat_Admin' ) ) {
935 MxChat_Admin::mxchat_log_debug(
936 'settings_save',
937 sprintf( 'Field saved: %s', $field_name ),
938 array(
939 'field' => $field_name,
940 'updated' => $updated,
941 )
942 );
943 }
944
945 // Always return success even if WordPress says nothing changed
946 // (which happens when the value is the same as before)
947 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
948 }
949
950 /**
951 * Save the selected bot for knowledge base operations
952 */
953 public function mxchat_save_selected_bot() {
954 // Check nonce
955 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'mxchat_save_setting_nonce')) {
956 wp_send_json_error('Invalid nonce');
957 }
958
959 // Check permissions
960 if (!current_user_can('manage_options')) {
961 wp_send_json_error('Unauthorized');
962 }
963
964 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
965
966 // Save as user meta for the current user
967 $user_id = get_current_user_id();
968 update_user_meta($user_id, 'mxchat_selected_knowledge_bot', $bot_id);
969
970 // Also save as an option for site-wide default
971 update_option('mxchat_current_knowledge_bot', $bot_id);
972
973 // No cache clearing needed since we removed caching
974
975 wp_send_json_success(array(
976 'message' => 'Bot selection saved',
977 'bot_id' => $bot_id
978 ));
979 }
980
981 /**
982 * Handles AJAX request for saving chat settings
983 */
984 public function mxchat_save_prompts_setting_callback() {
985 check_ajax_referer('mxchat_prompts_setting_nonce');
986
987 if (!current_user_can('manage_options')) {
988 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]);
989 }
990
991 $name = isset($_POST['name']) ? $_POST['name'] : '';
992 $value = isset($_POST['value']) ? stripslashes($_POST['value']) : '';
993
994 //error_log('[MXCHAT-PROMPTS] Saving setting: ' . $name . ' = ' . $value);
995
996 if (empty($name)) {
997 wp_send_json_error(['message' => esc_html__('Invalid field name', 'mxchat')]);
998 }
999
1000 // Handle Pinecone settings - BYPASS WORDPRESS SANITIZATION
1001 if (strpos($name, 'mxchat_pinecone_addon_options') !== false) {
1002 //error_log('[MXCHAT-PROMPTS] Processing Pinecone setting: ' . $name);
1003
1004 // Extract the field name
1005 if (preg_match('/mxchat_pinecone_addon_options\[([^\]]+)\]/', $name, $matches)) {
1006 $field_name = $matches[1];
1007 //error_log('[MXCHAT-PROMPTS] Extracted field name: ' . $field_name);
1008
1009 // Get current options directly from database - NO WordPress filters
1010 global $wpdb;
1011 $current_options_raw = $wpdb->get_var(
1012 $wpdb->prepare(
1013 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1014 'mxchat_pinecone_addon_options'
1015 )
1016 );
1017
1018 // FIX: Handle the case where the option doesn't exist yet
1019 if ($current_options_raw === null) {
1020 // Option doesn't exist, create it with default values
1021 $current_options = array(
1022 'mxchat_use_pinecone' => '0',
1023 'mxchat_pinecone_api_key' => '',
1024 'mxchat_pinecone_host' => '',
1025 'mxchat_pinecone_index' => '',
1026 'mxchat_pinecone_environment' => ''
1027 );
1028 //error_log('[MXCHAT-PROMPTS] Option does not exist, creating with defaults');
1029 } else {
1030 // Unserialize the raw data
1031 $current_options = maybe_unserialize($current_options_raw);
1032 if (!is_array($current_options)) {
1033 // Fallback to defaults if unserialization fails
1034 $current_options = array(
1035 'mxchat_use_pinecone' => '0',
1036 'mxchat_pinecone_api_key' => '',
1037 'mxchat_pinecone_host' => '',
1038 'mxchat_pinecone_index' => '',
1039 'mxchat_pinecone_environment' => ''
1040 );
1041 //error_log('[MXCHAT-PROMPTS] Failed to unserialize, using defaults');
1042 }
1043 }
1044
1045 //error_log('[MXCHAT-PROMPTS] Current options from DB: ' . print_r($current_options, true));
1046
1047 // Update the specific field with proper sanitization
1048 switch ($field_name) {
1049 case 'mxchat_use_pinecone':
1050 $new_value = ($value === '1') ? '1' : '0';
1051 break;
1052 case 'mxchat_pinecone_api_key':
1053 case 'mxchat_pinecone_host':
1054 case 'mxchat_pinecone_index':
1055 case 'mxchat_pinecone_environment':
1056 $new_value = sanitize_text_field($value);
1057 if ($field_name === 'mxchat_pinecone_host') {
1058 $new_value = str_replace(['https://', 'http://'], '', $new_value);
1059 }
1060 break;
1061 case 'mxchat_pinecone_index_type':
1062 // 362c31: document only once the host has been checked (see sanitize_pinecone_settings).
1063 $new_value = ($value === 'document') ? 'document' : 'vector';
1064 if ($new_value === 'document' && strtolower(trim((string) ($current_options['mxchat_pinecone_host'] ?? ''), '/')) !== strtolower((string) ($current_options['mxchat_pinecone_docs_verified_host'] ?? ''))) {
1065 wp_send_json_error(['message' => esc_html__('Check the document index host first (Check index or Create index for me).', 'mxchat')]);
1066 }
1067 // 3e83e4: same gate as the form save — no switch onto a half-copied index.
1068 if ($new_value === 'document' && (($current_options['mxchat_pinecone_index_type'] ?? 'vector') !== 'document') && class_exists('MxChat_Pinecone_Documents')) {
1069 $running = MxChat_Pinecone_Documents::migration_blocks_switch((string) ($current_options['mxchat_pinecone_host'] ?? ''));
1070 if ($running !== null) {
1071 wp_send_json_error(['message' => MxChat_Pinecone_Documents::switch_blocked_message($running)]);
1072 }
1073 }
1074 break;
1075 case 'mxchat_pinecone_vector_host':
1076 $new_value = trim(str_replace(['https://', 'http://'], '', sanitize_text_field($value)), '/');
1077 break;
1078 case 'mxchat_pinecone_vector_index':
1079 case 'mxchat_pinecone_docs_region':
1080 $new_value = sanitize_text_field($value);
1081 break;
1082 case 'mxchat_pinecone_docs_cloud':
1083 $new_value = in_array($value, array('aws', 'gcp', 'azure'), true) ? $value : 'aws';
1084 break;
1085 case 'mxchat_pinecone_docs_language':
1086 $lang_value = sanitize_key($value);
1087 $new_value = (class_exists('MxChat_Pinecone_Documents') && array_key_exists($lang_value, MxChat_Pinecone_Documents::languages())) ? $lang_value : 'en';
1088 break;
1089 case 'mxchat_pinecone_top_k':
1090 // d0cae1: out-of-range and junk normalize to the default 50 —
1091 // same clamp the read site applies.
1092 $top_k = absint($value);
1093 $new_value = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50);
1094 break;
1095 default:
1096 wp_send_json_error(['message' => esc_html__('Unknown Pinecone field', 'mxchat')]);
1097 }
1098
1099 $current_options[$field_name] = $new_value;
1100 //error_log('[MXCHAT-PROMPTS] New value for ' . $field_name . ': "' . $new_value . '"');
1101 //error_log('[MXCHAT-PROMPTS] Updated options: ' . print_r($current_options, true));
1102
1103 // Save directly to database to bypass WordPress sanitization
1104 $serialized_options = maybe_serialize($current_options);
1105
1106 // FIX: Use INSERT ... ON DUPLICATE KEY UPDATE or separate INSERT/UPDATE logic
1107 $option_exists = $wpdb->get_var(
1108 $wpdb->prepare(
1109 "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
1110 'mxchat_pinecone_addon_options'
1111 )
1112 );
1113
1114 if ($option_exists > 0) {
1115 // Update existing option
1116 $save_result = $wpdb->update(
1117 $wpdb->options,
1118 array('option_value' => $serialized_options),
1119 array('option_name' => 'mxchat_pinecone_addon_options'),
1120 array('%s'),
1121 array('%s')
1122 );
1123 //error_log('[MXCHAT-PROMPTS] Updated existing option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED'));
1124 } else {
1125 // Insert new option
1126 // Credential option — must NOT autoload (holds the Pinecone secret;
1127 // autoloaded rows are read into memory on every request).
1128 $save_result = $wpdb->insert(
1129 $wpdb->options,
1130 array(
1131 'option_name' => 'mxchat_pinecone_addon_options',
1132 'option_value' => $serialized_options,
1133 'autoload' => 'off'
1134 ),
1135 array('%s', '%s', '%s')
1136 );
1137 //error_log('[MXCHAT-PROMPTS] Inserted new option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED'));
1138 }
1139
1140 // Clear any WordPress option cache to ensure get_option() returns fresh data
1141 wp_cache_delete('mxchat_pinecone_addon_options', 'options');
1142
1143 // IMPROVED VERIFICATION - Check if the database operation succeeded
1144 if ($save_result !== false) {
1145 // Double-check by reading fresh from database
1146 $verification_raw = $wpdb->get_var(
1147 $wpdb->prepare(
1148 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1149 'mxchat_pinecone_addon_options'
1150 )
1151 );
1152 $verification_options = maybe_unserialize($verification_raw);
1153 $verified_value = isset($verification_options[$field_name]) ? $verification_options[$field_name] : 'NOT_FOUND';
1154
1155 //error_log('[MXCHAT-PROMPTS] Final verification - Expected: "' . $new_value . '", Got: "' . $verified_value . '"');
1156
1157 // Use loose comparison (==) instead of strict (===) to avoid type issues
1158 if ($verified_value == $new_value || $save_result > 0) {
1159 wp_send_json_success(['message' => esc_html__('Pinecone setting saved', 'mxchat')]);
1160 } else {
1161 // Still return success if the DB operation worked, even if verification is quirky
1162 //error_log('[MXCHAT-PROMPTS] Verification mismatch but DB operation succeeded');
1163 wp_send_json_success(['message' => esc_html__('Pinecone setting saved (DB success)', 'mxchat')]);
1164 }
1165 } else {
1166 wp_send_json_error(['message' => esc_html__('Database save failed', 'mxchat')]);
1167 }
1168 } else {
1169 wp_send_json_error(['message' => esc_html__('Invalid field name format', 'mxchat')]);
1170 }
1171
1172 return; // Exit here for Pinecone settings
1173 }
1174 // Handle auto-sync settings (existing functionality)
1175 if (strpos($name, 'mxchat_auto_sync_') === 0) {
1176 $value = ($value === 'on' || $value === '1') ? '1' : '0';
1177 $updated = update_option($name, $value);
1178
1179 if ($updated || get_option($name) === $value) {
1180 wp_send_json_success(['message' => esc_html__('Auto-sync setting saved', 'mxchat')]);
1181 } else {
1182 wp_send_json_error(['message' => esc_html__('No changes detected', 'mxchat')]);
1183 }
1184 }
1185
1186 // Handle chunking settings - use direct DB access to bypass WordPress filters
1187 if (strpos($name, 'mxchat_chunk') === 0 || $name === 'mxchat_chunking_enabled') {
1188 global $wpdb;
1189
1190 // Get current options directly from database
1191 $current_options_raw = $wpdb->get_var(
1192 $wpdb->prepare(
1193 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1194 'mxchat_options'
1195 )
1196 );
1197
1198 $options = $current_options_raw !== null ? maybe_unserialize($current_options_raw) : array();
1199 if (!is_array($options)) {
1200 $options = array();
1201 }
1202
1203 // Update the specific chunking field
1204 if ($name === 'mxchat_chunking_enabled') {
1205 $options['chunking_enabled'] = in_array($value, array('on', '1', 'true', true), true);
1206 } elseif ($name === 'mxchat_chunk_size') {
1207 $options['chunk_size'] = max(1000, min(10000, intval($value)));
1208 }
1209
1210 // Save directly to database
1211 $serialized_options = maybe_serialize($options);
1212
1213 $option_exists = $wpdb->get_var(
1214 $wpdb->prepare(
1215 "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
1216 'mxchat_options'
1217 )
1218 );
1219
1220 if ($option_exists > 0) {
1221 $save_result = $wpdb->update(
1222 $wpdb->options,
1223 array('option_value' => $serialized_options),
1224 array('option_name' => 'mxchat_options'),
1225 array('%s'),
1226 array('%s')
1227 );
1228 } else {
1229 $save_result = $wpdb->insert(
1230 $wpdb->options,
1231 array(
1232 'option_name' => 'mxchat_options',
1233 'option_value' => $serialized_options,
1234 'autoload' => 'yes'
1235 ),
1236 array('%s', '%s', '%s')
1237 );
1238 }
1239
1240 // Clear object cache for this option
1241 wp_cache_delete('mxchat_options', 'options');
1242
1243 if ($save_result !== false) {
1244 wp_send_json_success(['message' => esc_html__('Chunking setting saved', 'mxchat')]);
1245 } else {
1246 wp_send_json_error(['message' => esc_html__('Failed to save chunking setting', 'mxchat')]);
1247 }
1248 return;
1249 }
1250
1251 // Handle ACF field exclusion toggles
1252 if (strpos($name, 'mxchat_acf_field_') === 0) {
1253 // The identifier after the prefix is the ACF field KEY (unique per
1254 // field), not the field name — names are shared across groups and
1255 // collide (plan 30e81f). Reject anything that isn't key-shaped so a
1256 // stale pre-3.2.20 page (or its exit beacon) posting a bare name
1257 // can't write junk into the key-based list.
1258 $field_key = str_replace('mxchat_acf_field_', '', $name);
1259 if (!preg_match('/^field_[A-Za-z0-9_\-]+$/', $field_key)) {
1260 wp_send_json_error(['message' => esc_html__('Invalid ACF field identifier', 'mxchat')]);
1261 return;
1262 }
1263 $is_enabled = ($value === 'on' || $value === '1');
1264
1265 // Get current excluded fields
1266 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
1267 if (!is_array($excluded_fields)) {
1268 $excluded_fields = array();
1269 }
1270
1271 if ($is_enabled) {
1272 // Remove from exclusion list (field should be included)
1273 $excluded_fields = array_values(array_diff($excluded_fields, array($field_key)));
1274 // Lazy legacy-name conversion: if this field's NAME is still
1275 // stored (its group was inactive when the 30e81f migration
1276 // ran), including this one field must not silently include
1277 // its same-named twins — swap the name entry for the keys of
1278 // every OTHER field currently wearing that name.
1279 $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field_key);
1280 } else {
1281 // Add to exclusion list (field should be excluded)
1282 if (!in_array($field_key, $excluded_fields, true)) {
1283 $excluded_fields[] = $field_key;
1284 }
1285 }
1286
1287 $updated = update_option('mxchat_acf_excluded_fields', $excluded_fields);
1288
1289 if ($updated || true) { // Always report success since the state may already be correct
1290 wp_send_json_success([
1291 'message' => $is_enabled
1292 ? esc_html__('Field will be included in imports', 'mxchat')
1293 : esc_html__('Field will be excluded from imports', 'mxchat')
1294 ]);
1295 } else {
1296 wp_send_json_error(['message' => esc_html__('Failed to save ACF field setting', 'mxchat')]);
1297 }
1298 return;
1299 }
1300
1301 // Handle custom post meta whitelist
1302 if ($name === 'mxchat_custom_meta_whitelist') {
1303 $updated = update_option('mxchat_custom_meta_whitelist', sanitize_textarea_field($value));
1304
1305 if ($updated || true) { // Always report success since the state may already be correct
1306 wp_send_json_success([
1307 'message' => esc_html__('Custom meta whitelist saved', 'mxchat')
1308 ]);
1309 } else {
1310 wp_send_json_error(['message' => esc_html__('Failed to save custom meta whitelist', 'mxchat')]);
1311 }
1312 return;
1313 }
1314
1315 // Handle other prompts options (autoload false — can hold the Pinecone secret)
1316 $options = get_option('mxchat_prompts_options', []);
1317 $options[$name] = $value;
1318 $updated = update_option('mxchat_prompts_options', $options, false);
1319
1320 if ($updated) {
1321 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
1322 } else {
1323 wp_send_json_error(['message' => esc_html__('No changes detected', 'mxchat')]);
1324 }
1325 }
1326
1327 /**
1328 * If the field behind $included_key still has its NAME stored in the
1329 * exclusion list (a legacy entry the 30e81f migration could not resolve
1330 * because the group was inactive), replace that name with the keys of
1331 * every OTHER current field wearing it. Including one field must never
1332 * silently include its same-named twins — that would be the original
1333 * collision bug in reverse, in the unsafe (privacy-losing) direction.
1334 */
1335 private function mxchat_expand_legacy_acf_name_entry($excluded_fields, $included_key) {
1336 if (!function_exists('acf_get_field')) {
1337 return $excluded_fields;
1338 }
1339 $field = acf_get_field($included_key);
1340 if (!$field || empty($field['name'])) {
1341 return $excluded_fields;
1342 }
1343 $field_name = $field['name'];
1344 if (!in_array($field_name, $excluded_fields, true)) {
1345 return $excluded_fields;
1346 }
1347 $excluded_fields = array_values(array_diff($excluded_fields, array($field_name)));
1348 foreach ($this->mxchat_acf_keys_for_name($field_name) as $twin_key) {
1349 if ($twin_key !== $included_key && !in_array($twin_key, $excluded_fields, true)) {
1350 $excluded_fields[] = $twin_key;
1351 }
1352 }
1353 return $excluded_fields;
1354 }
1355
1356 /**
1357 * Keys of every currently-registered top-level ACF field with this name.
1358 */
1359 private function mxchat_acf_keys_for_name($field_name) {
1360 $keys = array();
1361 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
1362 return $keys;
1363 }
1364 foreach (acf_get_field_groups() as $group) {
1365 $group_fields = acf_get_fields($group['key']);
1366 if (empty($group_fields)) {
1367 continue;
1368 }
1369 foreach ($group_fields as $field) {
1370 if (isset($field['name'], $field['key']) && $field['name'] === $field_name) {
1371 $keys[] = $field['key'];
1372 }
1373 }
1374 }
1375 return $keys;
1376 }
1377
1378 /**
1379 * Group-level ACF toggle (plan bf57e0): sets every field in one ACF field
1380 * group included or excluded in a SINGLE option write. The client must
1381 * never loop the per-field endpoint for this — get_option → modify →
1382 * update_option once per field from twenty concurrent requests is a
1383 * lost-update race that silently drops most of the group.
1384 */
1385 public function mxchat_acf_toggle_group_callback() {
1386 check_ajax_referer('mxchat_prompts_setting_nonce');
1387
1388 if (!current_user_can('manage_options')) {
1389 wp_send_json_error(['message' => esc_html__('Insufficient permissions', 'mxchat')], 403);
1390 return;
1391 }
1392
1393 if (!function_exists('acf_get_fields')) {
1394 wp_send_json_error(['message' => esc_html__('ACF is not active', 'mxchat')]);
1395 return;
1396 }
1397
1398 $group_key = isset($_POST['group_key']) ? sanitize_text_field(wp_unslash($_POST['group_key'])) : '';
1399 if (!preg_match('/^group_[A-Za-z0-9_\-]+$/', $group_key)) {
1400 wp_send_json_error(['message' => esc_html__('Invalid ACF group identifier', 'mxchat')]);
1401 return;
1402 }
1403 $state = isset($_POST['state']) ? sanitize_text_field(wp_unslash($_POST['state'])) : '';
1404 $include = ($state === 'on' || $state === '1');
1405
1406 // Resolve the group's fields SERVER-side — a client-supplied key list
1407 // is not trusted. This is the same call the settings UI lists from,
1408 // so the toggle covers exactly the rendered set (top-level fields).
1409 $group_fields = acf_get_fields($group_key);
1410 if (empty($group_fields)) {
1411 wp_send_json_error(['message' => esc_html__('No fields found for this group', 'mxchat')]);
1412 return;
1413 }
1414
1415 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
1416 if (!is_array($excluded_fields)) {
1417 $excluded_fields = array();
1418 }
1419
1420 $touched = array();
1421 foreach ($group_fields as $field) {
1422 if (empty($field['key'])) {
1423 continue;
1424 }
1425 if ($include) {
1426 $excluded_fields = array_values(array_diff($excluded_fields, array($field['key'])));
1427 $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field['key']);
1428 } elseif (!in_array($field['key'], $excluded_fields, true)) {
1429 $excluded_fields[] = $field['key'];
1430 }
1431 $touched[] = array(
1432 'name' => 'mxchat_acf_field_' . $field['key'],
1433 'value' => $include ? 'on' : 'off',
1434 );
1435 }
1436
1437 // The one write — the whole point of this endpoint.
1438 update_option('mxchat_acf_excluded_fields', array_values($excluded_fields));
1439
1440 wp_send_json_success([
1441 'message' => $include
1442 ? esc_html__('All fields in this group will be included in imports', 'mxchat')
1443 : esc_html__('All fields in this group will be excluded from imports', 'mxchat'),
1444 'fields' => $touched,
1445 ]);
1446 }
1447
1448 /**
1449 * Handles AJAX request for Pinecone settings migration
1450 */
1451 public function ajax_migrate_pinecone_settings() {
1452 // Verify nonce
1453 if (!wp_verify_nonce($_POST['_ajax_nonce'] ?? '', 'mxchat_save_setting_nonce')) {
1454 wp_send_json_error('Invalid nonce');
1455 }
1456
1457 // Check permissions
1458 if (!current_user_can('manage_options')) {
1459 wp_send_json_error('Unauthorized access');
1460 }
1461
1462 // Check if old Pinecone addon options exist
1463 $old_options = get_option('mxchat_pinecone_addon_options', array());
1464
1465 if (empty($old_options)) {
1466 wp_send_json_success(array('migrated' => false, 'message' => 'No old settings found'));
1467 }
1468
1469 // Get current core plugin options
1470 $current_options = get_option('mxchat_pinecone_addon_options', array());
1471
1472 // Only migrate if core options are empty or if explicitly requested
1473 $should_migrate = empty($current_options) ||
1474 (empty($current_options['mxchat_pinecone_api_key']) && !empty($old_options['mxchat_pinecone_api_key']));
1475
1476 if ($should_migrate) {
1477 // Migrate settings with proper sanitization
1478 $migrated_options = array(
1479 'mxchat_use_pinecone' => $old_options['mxchat_use_pinecone'] ?? '0',
1480 'mxchat_pinecone_api_key' => sanitize_text_field($old_options['mxchat_pinecone_api_key'] ?? ''),
1481 'mxchat_pinecone_host' => sanitize_text_field($old_options['mxchat_pinecone_host'] ?? ''),
1482 'mxchat_pinecone_index' => sanitize_text_field($old_options['mxchat_pinecone_index'] ?? ''),
1483 'mxchat_pinecone_environment' => sanitize_text_field($old_options['mxchat_pinecone_environment'] ?? '')
1484 );
1485
1486 update_option('mxchat_pinecone_addon_options', $migrated_options, false);
1487
1488 wp_send_json_success(array(
1489 'migrated' => true,
1490 'message' => 'Settings migrated successfully from Pinecone add-on'
1491 ));
1492 } else {
1493 wp_send_json_success(array(
1494 'migrated' => false,
1495 'message' => 'Settings already exist in core plugin'
1496 ));
1497 }
1498 }
1499
1500
1501 // ========================================
1502 // LICENSE AJAX HANDLERS
1503 // ========================================
1504
1505 /**
1506 * Validates and activates chat license via AJAX
1507 */
1508 public function mxchat_handle_activate_license() {
1509 // Check nonce
1510 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1511 wp_send_json_error(esc_html__('Invalid security token', 'mxchat'));
1512 return;
1513 }
1514
1515 // Verify user capabilities
1516 if (!current_user_can('manage_options')) {
1517 wp_send_json_error(esc_html__('Unauthorized access', 'mxchat'));
1518 return;
1519 }
1520
1521 $license_key = isset($_POST['mxchat_activation_key']) ? sanitize_text_field($_POST['mxchat_activation_key']) : '';
1522 $customer_email = isset($_POST['mxchat_pro_email']) ? sanitize_email($_POST['mxchat_pro_email']) : '';
1523
1524 if (empty($license_key) || empty($customer_email)) {
1525 wp_send_json_error(esc_html__('Email or License Key is missing', 'mxchat'));
1526 return;
1527 }
1528
1529 $product_id = 'MxChatPRO';
1530 $domain = parse_url(home_url(), PHP_URL_HOST); // Get the current domain
1531
1532 // Call WooCommerce Software API for activation (not just validation)
1533 $response = wp_remote_get(
1534 add_query_arg(
1535 array(
1536 'wc-api' => 'software-api',
1537 'request' => 'activation',
1538 'email' => $customer_email,
1539 'license_key' => $license_key,
1540 'product_id' => $product_id,
1541 'instance' => $domain, // THIS IS KEY - include the domain as instance
1542 'platform' => 'wordpress' // Optional but good to include
1543 ),
1544 'https://mxchat.ai/'
1545 ),
1546 array(
1547 'timeout' => 60,
1548 'sslverify' => true
1549 )
1550 );
1551
1552 if (is_wp_error($response)) {
1553 $error_message = $response->get_error_message();
1554 //error_log('MxChat License Activation Error: ' . $error_message);
1555 wp_send_json_error(esc_html__('Activation failed due to a server error: ', 'mxchat') . $error_message);
1556 return;
1557 }
1558
1559 $response_code = wp_remote_retrieve_response_code($response);
1560 $body = wp_remote_retrieve_body($response);
1561
1562 // Log response for debugging
1563 //error_log('MxChat License Response Code: ' . $response_code);
1564 //error_log('MxChat License Response Body: ' . $body);
1565
1566 if ($response_code !== 200) {
1567 wp_send_json_error(esc_html__('Server returned error code: ', 'mxchat') . $response_code);
1568 return;
1569 }
1570
1571 $data = json_decode($body);
1572
1573 if ($data && isset($data->activated) && $data->activated) {
1574 // Success - save local options
1575 update_option('mxchat_license_status', 'active');
1576 update_option('mxchat_pro_email', $customer_email);
1577 update_option('mxchat_activation_key', $license_key);
1578 delete_option('mxchat_license_error');
1579
1580 // Also track on your website (this is your existing domain tracking)
1581 $this->track_domain_on_website($license_key, $customer_email, $domain);
1582
1583 wp_send_json_success(array('message' => esc_html__('License activated successfully', 'mxchat')));
1584 } else {
1585 $error_message = isset($data->error) ? $data->error : esc_html__('Activation failed', 'mxchat');
1586 update_option('mxchat_license_status', 'inactive');
1587 update_option('mxchat_license_error', $error_message);
1588
1589 //error_log('MxChat Activation failed: ' . $error_message);
1590 wp_send_json_error($error_message);
1591 }
1592 }
1593
1594 /**
1595 * Track domain on your website (separate from WooCommerce activation)
1596 */
1597 private function track_domain_on_website($license_key, $email, $domain) {
1598 // This calls your website's tracking API
1599 wp_remote_post('https://mxchat.ai/mxchat-api/activate-license', array(
1600 'body' => array(
1601 'mxchat_pro_email' => $email,
1602 'mxchat_activation_key' => $license_key,
1603 'domain' => $domain
1604 ),
1605 'timeout' => 10,
1606 'sslverify' => true
1607 ));
1608 }
1609
1610 /**
1611 * Validates license via AJAX with email and key
1612 */
1613 public function mxchat_check_license_status() {
1614 // Verify nonce
1615 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1616 wp_send_json_error('Security check failed');
1617 return;
1618 }
1619
1620 // Add isset checks for safety
1621 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1622 $key = isset($_POST['key']) ? sanitize_text_field($_POST['key']) : '';
1623
1624 // Check if this license is actually active in your system
1625 $is_active = (get_option('mxchat_license_status') === 'active' &&
1626 get_option('mxchat_pro_email') === $email &&
1627 get_option('mxchat_activation_key') === $key);
1628
1629 wp_send_json(array(
1630 'is_active' => $is_active
1631 ));
1632 }
1633
1634 /**
1635 * Handle license deactivation - Complete version for plugin
1636 */
1637 function mxchat_deactivate_license() {
1638 // Add debugging
1639 //error_log('MxChat deactivate function called');
1640
1641 // Check nonce
1642 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1643 //error_log('MxChat deactivate: Nonce check failed');
1644 wp_send_json_error('Security check failed.');
1645 return;
1646 }
1647
1648 // plan-mxchat-20260731-c63fb6 — nonce is not authorization. Without this,
1649 // any authenticated user holding the nonce could revoke the site's PRO
1650 // licence. Every sibling handler in this file already checks.
1651 if (!current_user_can('manage_options')) {
1652 wp_send_json_error(esc_html__('Unauthorized', 'mxchat'), 403);
1653 return;
1654 }
1655
1656 //error_log('MxChat deactivate: Nonce check passed');
1657
1658 $license_key = get_option('mxchat_activation_key');
1659 $email = get_option('mxchat_pro_email');
1660 $domain = parse_url(home_url(), PHP_URL_HOST);
1661
1662 //error_log('MxChat deactivate: License: ' . $license_key . ', Email: ' . $email . ', Domain: ' . $domain);
1663
1664 if (empty($license_key) || empty($email)) {
1665 //error_log('MxChat deactivate: No active license found');
1666 wp_send_json_error('No active license found.');
1667 return;
1668 }
1669
1670 // Clear local license data first
1671 delete_option('mxchat_license_status');
1672 delete_option('mxchat_pro_email');
1673 delete_option('mxchat_activation_key');
1674 delete_option('mxchat_license_error');
1675
1676 //error_log('MxChat deactivate: Local data cleared');
1677
1678 // Notify your website's API to properly deactivate
1679 $response = wp_remote_post('https://mxchat.ai/mxchat-api/deactivate-license', array(
1680 'body' => array(
1681 'license_key' => $license_key,
1682 'email' => $email,
1683 'domain' => $domain
1684 ),
1685 'timeout' => 15,
1686 'sslverify' => true
1687 ));
1688
1689 if (is_wp_error($response)) {
1690 //error_log('MxChat deactivate: Server error - ' . $response->get_error_message());
1691 wp_send_json_success(array(
1692 'message' => 'License deactivated locally. Server could not be contacted to free activation slot.',
1693 'server_notified' => false
1694 ));
1695 return;
1696 }
1697
1698 $response_body = wp_remote_retrieve_body($response);
1699 $response_data = json_decode($response_body, true);
1700
1701 //error_log('MxChat deactivate: Server response - ' . $response_body);
1702
1703 if (isset($response_data['success']) && $response_data['success']) {
1704 //error_log('MxChat deactivate: Success with server notification');
1705 wp_send_json_success(array(
1706 'message' => 'License deactivated successfully. Activation slot has been freed up.',
1707 'server_notified' => true
1708 ));
1709 } else {
1710 //error_log('MxChat deactivate: Server responded but deactivation may have failed');
1711 wp_send_json_success(array(
1712 'message' => 'License deactivated locally. Please check your account dashboard to verify the activation was freed.',
1713 'server_notified' => false
1714 ));
1715 }
1716 }
1717
1718
1719 // ========================================
1720 // ACTIONS & INTENTS AJAX HANDLERS
1721 // ========================================
1722
1723 /**
1724 * Validates nonce and returns JSON error on failure
1725 */
1726 public function mxchat_toggle_action() {
1727 // Check nonce
1728 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_actions_nonce')) {
1729 wp_send_json_error(array('message' => 'Security check failed'));
1730 return;
1731 }
1732
1733 // Check permissions
1734 if (!current_user_can('manage_options')) {
1735 wp_send_json_error(array('message' => 'Permission denied'));
1736 return;
1737 }
1738
1739 // Validate params
1740 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
1741 $enabled = isset($_POST['enabled']) ? (bool)$_POST['enabled'] : false;
1742
1743 if (!$intent_id) {
1744 wp_send_json_error(array('message' => 'Invalid action ID'));
1745 return;
1746 }
1747
1748 // Update the intent/action status in the database
1749 global $wpdb;
1750 $table_name = $wpdb->prefix . 'mxchat_intents';
1751
1752 // Using the 'enabled' field - add this field if it doesn't exist
1753 $result = $wpdb->update(
1754 $table_name,
1755 array('enabled' => $enabled ? 1 : 0),
1756 array('id' => $intent_id),
1757 array('%d'),
1758 array('%d')
1759 );
1760
1761 if ($result === false) {
1762 wp_send_json_error(array('message' => 'Database error'));
1763 return;
1764 }
1765
1766 wp_send_json_success();
1767 }
1768
1769
1770 /**
1771 * Validates permissions for AJAX request handling
1772 */
1773 public function mxchat_update_intent_threshold() {
1774 // Check permissions
1775 if (!current_user_can('manage_options')) {
1776 if (wp_doing_ajax()) {
1777 wp_send_json_error(array('message' => 'Unauthorized user'));
1778 return;
1779 }
1780 wp_die(esc_html__('Unauthorized user', 'mxchat'));
1781 }
1782
1783 // Verify nonce
1784 check_admin_referer('mxchat_update_intent_threshold_nonce');
1785
1786 // Process the update if we have valid data
1787 if (isset($_POST['intent_id'], $_POST['intent_threshold'])) {
1788 global $wpdb;
1789 $table_name = $wpdb->prefix . 'mxchat_intents';
1790 $intent_id = intval($_POST['intent_id']);
1791 $threshold_percentage = max(70, min(95, intval($_POST['intent_threshold'])));
1792 $similarity_threshold = $threshold_percentage / 100;
1793
1794 $result = $wpdb->update(
1795 $table_name,
1796 ['similarity_threshold' => $similarity_threshold],
1797 ['id' => $intent_id],
1798 ['%f'],
1799 ['%d']
1800 );
1801
1802 // Handle AJAX requests
1803 if (wp_doing_ajax()) {
1804 if ($result === false) {
1805 wp_send_json_error(array('message' => 'Failed to update threshold'));
1806 } else {
1807 wp_send_json_success(array('threshold' => $threshold_percentage));
1808 }
1809 return;
1810 }
1811 }
1812
1813 // Redirect for regular form submissions
1814 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions&updated=true'));
1815 exit;
1816 }
1817
1818 // ========================================
1819 // HELPER METHODS
1820 // ========================================
1821
1822 /**
1823 * Returns a specific nonce action string
1824 */
1825 private function mxchat_get_nonce_action() {
1826 return 'mxchat_license_nonce';
1827 }
1828
1829 /**
1830 * Check API key status for all providers
1831 */
1832 public function mxchat_check_api_keys() {
1833 // Check nonce
1834 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'mxchat_save_setting_nonce')) {
1835 wp_send_json_error('Invalid nonce');
1836 }
1837
1838 // Check permissions
1839 if (!current_user_can('manage_options')) {
1840 wp_send_json_error('Unauthorized');
1841 }
1842
1843 // Get current options
1844 $options = get_option('mxchat_options', array());
1845
1846 // Check which API keys are present
1847 $api_key_status = array(
1848 'openai' => !empty($options['api_key']),
1849 'claude' => !empty($options['claude_api_key']),
1850 'xai' => !empty($options['xai_api_key']),
1851 'deepseek' => !empty($options['deepseek_api_key']),
1852 'gemini' => !empty($options['gemini_api_key']),
1853 'openrouter' => !empty($options['openrouter_api_key']),
1854 'voyage' => !empty($options['voyage_api_key'])
1855 );
1856
1857 wp_send_json_success($api_key_status);
1858 }
1859
1860 // ========================================
1861 // DEBUG & OPTIMIZATION AJAX HANDLERS
1862 // ========================================
1863
1864 /**
1865 * Toggle debug mode on/off
1866 */
1867 public function mxchat_toggle_debug_mode_callback() {
1868 // Verify nonce
1869 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1870 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1871 }
1872
1873 // Check permissions
1874 if ( ! current_user_can( 'manage_options' ) ) {
1875 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1876 }
1877
1878 $enabled = isset( $_POST['enabled'] ) && $_POST['enabled'] === 'on';
1879
1880 $options = get_option( 'mxchat_options', array() );
1881
1882 if ( $enabled ) {
1883 $options['debug_mode'] = 'on';
1884 update_option( 'mxchat_options', $options );
1885 MxChat_Admin::mxchat_log_debug( 'debug_mode', 'Debug mode enabled' );
1886 } else {
1887 // Log before disabling
1888 MxChat_Admin::mxchat_log_debug( 'debug_mode', 'Debug mode disabled' );
1889 $options['debug_mode'] = 'off';
1890 update_option( 'mxchat_options', $options );
1891 }
1892
1893 wp_send_json_success( array(
1894 'message' => $enabled ? esc_html__( 'Debug mode enabled', 'mxchat' ) : esc_html__( 'Debug mode disabled', 'mxchat' ),
1895 'enabled' => $enabled,
1896 ) );
1897 }
1898
1899 /**
1900 * Get the debug log entries
1901 */
1902 public function mxchat_get_debug_log_callback() {
1903 // Verify nonce
1904 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1905 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1906 }
1907
1908 // Check permissions
1909 if ( ! current_user_can( 'manage_options' ) ) {
1910 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1911 }
1912
1913 $log = MxChat_Admin::mxchat_get_debug_log();
1914
1915 wp_send_json_success( array(
1916 'log' => $log,
1917 'count' => count( $log ),
1918 ) );
1919 }
1920
1921 /**
1922 * Clear the debug log
1923 */
1924 public function mxchat_clear_debug_log_callback() {
1925 // Verify nonce
1926 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1927 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1928 }
1929
1930 // Check permissions
1931 if ( ! current_user_can( 'manage_options' ) ) {
1932 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1933 }
1934
1935 MxChat_Admin::mxchat_clear_debug_log();
1936
1937 // Log that the log was cleared (this will be the first entry in the new log)
1938 MxChat_Admin::mxchat_log_debug( 'debug_log', 'Debug log cleared by user' );
1939
1940 wp_send_json_success( array( 'message' => esc_html__( 'Debug log cleared', 'mxchat' ) ) );
1941 }
1942
1943 /**
1944 * Export settings as JSON
1945 */
1946 public function mxchat_export_settings_callback() {
1947 // Verify nonce
1948 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1949 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1950 }
1951
1952 // Check permissions
1953 if ( ! current_user_can( 'manage_options' ) ) {
1954 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1955 }
1956
1957 $export = MxChat_Admin::mxchat_export_settings();
1958
1959 // Log the export
1960 MxChat_Admin::mxchat_log_debug( 'settings_export', 'Settings exported by user' );
1961
1962 wp_send_json_success( array(
1963 'settings' => $export,
1964 'filename' => 'mxchat-settings-' . gmdate( 'Y-m-d-His' ) . '.json',
1965 ) );
1966 }
1967
1968 /**
1969 * Reset all settings to defaults
1970 */
1971 public function mxchat_reset_all_settings_callback() {
1972 // Verify nonce
1973 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1974 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1975 }
1976
1977 // Check permissions
1978 if ( ! current_user_can( 'manage_options' ) ) {
1979 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1980 }
1981
1982 // Require confirmation code
1983 $confirmation = isset( $_POST['confirmation'] ) ? sanitize_text_field( wp_unslash( $_POST['confirmation'] ) ) : '';
1984
1985 if ( strtoupper( $confirmation ) !== 'RESET' ) {
1986 wp_send_json_error( array( 'message' => esc_html__( 'Invalid confirmation code. Please type RESET to confirm.', 'mxchat' ) ) );
1987 }
1988
1989 // Perform the reset
1990 MxChat_Admin::mxchat_reset_all_settings();
1991
1992 wp_send_json_success( array( 'message' => esc_html__( 'All settings have been reset to defaults. The page will reload.', 'mxchat' ) ) );
1993 }
1994
1995 /**
1996 * Reset the global rate-limit usage counter to zero on demand.
1997 *
1998 * Zeroes the WP option mxchat_chat_limit_<bot>_global that the integrator
1999 * increments per message, then returns a freshly-formatted readout string
2000 * so the settings page can update without a reload. Does NOT change any
2001 * enforcement config — purely clears the running counter.
2002 */
2003 public function mxchat_reset_global_rate_limit_callback() {
2004 // Verify nonce
2005 if ( ! check_ajax_referer( 'mxchat_reset_global_usage', '_ajax_nonce', false ) ) {
2006 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
2007 }
2008
2009 // Check permissions
2010 if ( ! current_user_can( 'manage_options' ) ) {
2011 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
2012 }
2013
2014 // Resolve the per-bot counter key the same way the integrator does.
2015 $bot_id = isset( $_POST['bot_id'] ) ? sanitize_key( wp_unslash( $_POST['bot_id'] ) ) : 'default';
2016 $safe_bot = preg_replace( '/[^a-zA-Z0-9_]/', '_', $bot_id );
2017 if ( $safe_bot === '' ) {
2018 $safe_bot = 'default';
2019 }
2020 $option_key = 'mxchat_chat_limit_' . $safe_bot . '_global';
2021
2022 $now = time();
2023 update_option( $option_key, array( 'count' => 0, 'timestamp' => $now ) );
2024
2025 // Recompute the display string so the front-end can update in place.
2026 $all_options = get_option( 'mxchat_options', array() );
2027 $global_cfg = isset( $all_options['rate_limits_global'] ) && is_array( $all_options['rate_limits_global'] )
2028 ? $all_options['rate_limits_global']
2029 : array();
2030 $limit_raw = isset( $global_cfg['limit'] ) ? (string) $global_cfg['limit'] : 'unlimited';
2031 // Defensive: if a raw __custom__ ever slips through, fall back to the custom value.
2032 if ( ! ctype_digit( $limit_raw ) && isset( $global_cfg['limit_custom'] ) && ctype_digit( (string) $global_cfg['limit_custom'] ) ) {
2033 $limit_raw = (string) $global_cfg['limit_custom'];
2034 }
2035 $timeframe = isset( $global_cfg['timeframe'] ) ? (string) $global_cfg['timeframe'] : 'daily';
2036 $windows = array( 'hourly' => 3600, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2592000 );
2037 $window = isset( $windows[ $timeframe ] ) ? $windows[ $timeframe ] : 86400;
2038 $reset_at = $now + $window;
2039 $limit_int = ctype_digit( $limit_raw ) ? (int) $limit_raw : 0;
2040
2041 $text = sprintf(
2042 /* translators: 1: used count, 2: limit, 3: remaining, 4: human-readable time until reset */
2043 esc_html__( '%1$s of %2$s used · %3$s left · resets in %4$s', 'mxchat' ),
2044 number_format_i18n( 0 ),
2045 number_format_i18n( $limit_int ),
2046 number_format_i18n( $limit_int ),
2047 human_time_diff( $now, $reset_at )
2048 );
2049
2050 wp_send_json_success( array(
2051 'count' => 0,
2052 'limit' => $limit_int,
2053 'left' => $limit_int,
2054 'reset_at' => $reset_at,
2055 'pct' => 0,
2056 'text' => $text,
2057 'message' => esc_html__( 'Usage counter reset.', 'mxchat' ),
2058 ) );
2059 }
2060
2061 }
2062
2063 // Initialize the AJAX handler
2064 new MxChat_Ajax_Handler();
2065