PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
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.25, at admin/class-ajax-handler.php

2,101 lines 95.8 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 // Page-cache purge on settings save (plan b76ed6, Support Ticket 468).
444 // STANDALONE option, same pattern. Default ON — the purge already ships
445 // (3.2.9), so this is an opt-OUT for large sites. Switching it off also
446 // drops a deferred purge the debounce may have scheduled seconds ago,
447 // so "off" takes effect at once rather than after one more purge.
448 case 'mxchat_cache_purge_enabled':
449 $cpe_value = ($value === 'on' || $value === '1') ? 'on' : 'off';
450 update_option('mxchat_cache_purge_enabled', $cpe_value);
451 if ($cpe_value === 'off' && class_exists('MxChat_Cache_Purge')) {
452 wp_clear_scheduled_hook(MxChat_Cache_Purge::DEFERRED_EVENT);
453 }
454 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
455 return;
456
457 // Clamped to the same 20-95 the field advertises. An out-of-range or
458 // non-numeric POST is corrected rather than refused, and the corrected
459 // value is echoed back so the field can reconcile — a silently stored
460 // 0 here would put a video on every answer, which is the bug.
461 case 'mxchat_video_embed_threshold':
462 $vct_value = is_numeric($value)
463 ? (int) $value
464 : MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT;
465 if ($vct_value < 20) { $vct_value = 20; }
466 if ($vct_value > 95) { $vct_value = 95; }
467 update_option('mxchat_video_embed_threshold', $vct_value);
468 wp_send_json_success([
469 'message' => esc_html__('Setting saved', 'mxchat'),
470 'value' => $vct_value,
471 ]);
472 return;
473
474 // Live-agent availability schedules (plans 8ccaa2 + 99d7a4). STANDALONE
475 // options, same reasoning as the Editor Assistant case above — nested
476 // structures that mxchat_sanitize() would strip on the next autosave of any
477 // other field. Each channel's value arrives as JSON from its own hidden
478 // input, which that channel's schedule editor keeps in sync; the class owns
479 // all validation. The bare legacy name is kept as a defense against a
480 // browser still running pre-split cached admin JS: that UI edited "both
481 // channels" as one, so its save writes both.
482 case 'live_agent_schedule_slack':
483 case 'live_agent_schedule_telegram':
484 case 'live_agent_schedule_webhook':
485 case 'live_agent_schedule':
486 if (!class_exists('MxChat_Live_Agent_Schedule')) {
487 wp_send_json_error(['message' => esc_html__('Schedule unavailable', 'mxchat')]);
488 return;
489 }
490 $decoded = json_decode($value, true);
491 if (!is_array($decoded)) {
492 wp_send_json_error(['message' => esc_html__('Invalid schedule', 'mxchat')]);
493 return;
494 }
495 $channels = ($field_name === 'live_agent_schedule')
496 ? array('slack', 'telegram')
497 : array(substr($field_name, strlen('live_agent_schedule_')));
498 $saved_schedule = null;
499 foreach ($channels as $schedule_channel) {
500 $saved_schedule = MxChat_Live_Agent_Schedule::save($schedule_channel, $decoded);
501 }
502 // Echo the normalized result so the editor can reconcile if it ever
503 // disagrees with the server (e.g. a time the class rejected).
504 wp_send_json_success([
505 'message' => esc_html__('Setting saved', 'mxchat'),
506 'schedule' => $saved_schedule,
507 ]);
508 return;
509
510 case 'model':
511 //error_log('MXChat Save: Processing model selection');
512 //error_log('MXChat Save: Model value received: ' . $value);
513 //error_log('MXChat Save: Value type: ' . gettype($value));
514 //error_log('MXChat Save: Value length: ' . strlen($value));
515 //error_log('MXChat Save: Value === "openrouter": ' . ($value === 'openrouter' ? 'YES' : 'NO'));
516
517 // Allow 'openrouter' or validate against whitelist
518 if ($value === 'openrouter') {
519 //error_log('MXChat Save: Setting model to openrouter');
520 $options['model'] = 'openrouter';
521 } else {
522 //error_log('MXChat Save: Checking against whitelist');
523 // Catalog refactor (plan-d14e89): canonical allowlist lives in
524 // includes/class-mxchat-model-catalog.php. A new chat model
525 // added there is automatically accepted by autosave.
526 if (!class_exists('MxChat_Model_Catalog')) {
527 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-mxchat-model-catalog.php';
528 }
529 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
530
531 //error_log('MXChat Save: in_array result: ' . (in_array($value, $allowed_models) ? 'YES' : 'NO'));
532
533 if (in_array($value, $allowed_models)) {
534 //error_log('MXChat Save: Model is in whitelist, saving');
535 $options['model'] = sanitize_text_field($value);
536 } else {
537 //error_log('MXChat Save: Invalid model rejected: ' . $value);
538 //error_log('MXChat Save: Allowed models: ' . print_r($allowed_models, true));
539 wp_send_json_error(['message' => esc_html__('Invalid model selected', 'mxchat')]);
540 return;
541 }
542 }
543 break;
544
545 case 'openrouter_selected_model':
546 //error_log('MXChat Save: Processing OpenRouter model: ' . $value);
547 $options['openrouter_selected_model'] = sanitize_text_field($value);
548 // Force immediate save for new keys
549 //error_log('MXChat Save: OpenRouter model saved immediately');
550 break;
551
552 case 'openrouter_selected_model_name':
553 //error_log('MXChat Save: Processing OpenRouter model name: ' . $value);
554 $options['openrouter_selected_model_name'] = sanitize_text_field($value);
555 // Force immediate save for new keys
556 //error_log('MXChat Save: OpenRouter model name saved immediately');
557 break;
558
559 case 'openrouter_api_key':
560 //error_log('MXChat Save: Processing OpenRouter API key');
561 $options['openrouter_api_key'] = sanitize_text_field($value);
562 break;
563
564 // REMOVED DUPLICATE case 'openrouter_selected_model_name' HERE!
565
566 case 'additional_popular_questions':
567 //error_log('MXChat Save: Processing additional_popular_questions');
568 $questions = json_decode($value, true); // No need for stripslashes here
569 if (is_array($questions)) {
570 $options[$field_name] = $questions;
571 // Also update old option for backwards compatibility
572 update_option('additional_popular_questions', $questions);
573 //error_log('MXChat Save: Saved ' . count($questions) . ' additional questions');
574 } else {
575 //error_log('MXChat Save: Failed to decode questions JSON');
576 }
577 break;
578 case 'email_blocker_header_content':
579 //error_log('MXChat Save: Processing email_blocker_header_content');
580 // Allow HTML content but sanitize it safely
581 $options[$field_name] = wp_kses_post($value);
582 break;
583 case 'intro_message':
584 // Stored-XSS hardening (Wordfence CWE-79, plan-3f8158): sanitize on save as
585 // defense in depth. wp_kses_post mirrors mxchat_sanitize() (the options.php
586 // save path) so both save routes treat intro_message identically and strip
587 // <script>/</textarea> breakout while keeping basic formatting + {visitor_name}.
588 $options[$field_name] = wp_kses_post($value);
589 break;
590 case 'email_blocker_button_text':
591 //error_log('MXChat Save: Processing email_blocker_button_text');
592 $options[$field_name] = sanitize_text_field($value);
593 break;
594 case 'name_field_placeholder':
595 //error_log('MXChat Save: Processing name_field_placeholder');
596 $options[$field_name] = sanitize_text_field($value);
597 break;
598 case 'consent_checkbox_label':
599 // b062c4 — same allowlist as the options.php save path and the
600 // widget render, so the stored label always equals the shown label.
601 $options[$field_name] = MxChat_Utils::sanitize_consent_label($value);
602 break;
603 case 'similarity_threshold':
604 //error_log('MXChat Save: Processing similarity_threshold');
605 // Validate and save - enforce min 20, max 85
606 $threshold = intval($value);
607 if ($threshold < 20) $threshold = 20;
608 if ($threshold > 85) $threshold = 85;
609 $options[$field_name] = $threshold;
610 break;
611 case 'rag_sources_limit':
612 //error_log('MXChat Save: Processing rag_sources_limit');
613 // Validate and save - enforce min 3, max 10, default 6
614 $rag_limit = intval($value);
615 if ($rag_limit < 3) $rag_limit = 3;
616 if ($rag_limit > 10) $rag_limit = 10;
617 $options[$field_name] = $rag_limit;
618 break;
619 case 'rag_chunks_limit':
620 // Validate and save - enforce min 8, max 20, default 15
621 $chunks_limit = intval($value);
622 if ($chunks_limit < 8) $chunks_limit = 8;
623 if ($chunks_limit > 20) $chunks_limit = 20;
624 $options[$field_name] = $chunks_limit;
625 break;
626 case 'live_agent_status':
627 //error_log('MXChat Save: Processing live_agent_status');
628 // Set the new value
629 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
630 break;
631 // Shared handoff channel (plan 1a2666): re-probe the channel's privacy
632 // at configuration time so the settings screen can warn about private
633 // channels (their inbound events arrive as message.groups, which the
634 // documented app setup never subscribes to). Only id-shaped values can
635 // be checked before the first handoff resolves a #name — the handoff
636 // path probes those when it caches the resolved id.
637 case 'live_agent_shared_channel':
638 $options[$field_name] = sanitize_text_field($value);
639 delete_option('mxchat_slack_shared_channel_privacy');
640 $shared_channel_target = ltrim(trim((string) $options[$field_name]), '#');
641 if ($shared_channel_target !== '' && preg_match('/^[CG][A-Z0-9]{6,}$/', $shared_channel_target)) {
642 if (!class_exists('MxChat_Integrator')) {
643 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-mxchat-integrator.php';
644 }
645 MxChat_Integrator::mxchat_probe_slack_channel_privacy(
646 $options['live_agent_bot_token'] ?? '',
647 $shared_channel_target,
648 trim((string) $options[$field_name])
649 );
650 }
651 break;
652 // Webhook handoff destination (plan d88e22). Status normalized like the
653 // other channel toggles; the URL is refused outright when it isn't
654 // https so the admin hears about it at save time instead of the
655 // handoff silently never firing.
656 case 'webhook_handoff_status':
657 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
658 break;
659 case 'webhook_handoff_url':
660 $wh_url = trim((string) $value);
661 if ($wh_url === '') {
662 $options[$field_name] = '';
663 break;
664 }
665 $wh_clean = esc_url_raw($wh_url, array('https'));
666 if ($wh_clean === '' || stripos($wh_clean, 'https://') !== 0) {
667 wp_send_json_error(['message' => esc_html__('Webhook URL must start with https://', 'mxchat')]);
668 return;
669 }
670 $options[$field_name] = $wh_clean;
671 break;
672 case 'enable_web_search':
673 //error_log('MXChat Save: Processing enable_web_search');
674 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
675 break;
676 case 'enable_woocommerce_integration':
677 //error_log('MXChat Save: Processing enable_woocommerce_integration');
678 // Handle values that used to be 1/0
679 $options[$field_name] = ($value === 'on' || $value === '1') ? 'on' : 'off';
680 break;
681 case 'post_type_visibility_mode':
682 // Validate mode value
683 $allowed_modes = array('all', 'include', 'exclude');
684 $options[$field_name] = in_array($value, $allowed_modes) ? $value : 'all';
685 break;
686 case 'post_type_visibility_list':
687 // Handle JSON array of post types
688 $post_types = json_decode($value, true);
689 if (is_array($post_types)) {
690 // Sanitize each post type slug
691 $options[$field_name] = array_map('sanitize_key', $post_types);
692 } else {
693 $options[$field_name] = array();
694 }
695 break;
696 case 'script_loading_strategy':
697 // Validate script loading strategy value
698 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
699 $options[$field_name] = in_array($value, $allowed_strategies) ? $value : 'default';
700 break;
701 case 'auto_retry_on_transient_error':
702 // Boolean toggle — accept 1/0/on/off, default to '1' if any truthy value.
703 $options[$field_name] = ($value === '1' || $value === 'on' || $value === 1 || $value === true) ? '1' : '0';
704 break;
705 default:
706 // Handle transcripts options
707 if (strpos($name, 'mxchat_transcripts_options') !== false) {
708 // Extract field name from mxchat_transcripts_options[field_name]
709 if (preg_match('/mxchat_transcripts_options\[([^\]]+)\]/', $name, $matches)) {
710 $field_name = $matches[1];
711
712 // Get current transcripts options
713 $transcripts_options = get_option('mxchat_transcripts_options', array());
714
715 // Ensure it's an array
716 if (!is_array($transcripts_options)) {
717 $transcripts_options = array();
718 }
719
720 // Handle checkbox values (convert 'on'/'off' to 1/0)
721 if ($field_name === 'mxchat_retention_days') {
722 // Number field, NOT a checkbox — without this branch a
723 // value of '1' would hit the 'on'/'1' coercion below
724 // (harmlessly) but nothing would clamp: this direct-DB
725 // path bypasses the registered sanitiser and its
726 // 0-3650 clamp entirely (plan-3c3338).
727 $transcripts_options[$field_name] = max(0, min(3650, (int) $value));
728 } else if ($value === 'on' || $value === '1') {
729 $transcripts_options[$field_name] = 1;
730 } else if ($value === 'off' || $value === '0' || $value === '') {
731 $transcripts_options[$field_name] = 0;
732 } else {
733 // For text/select fields, sanitize appropriately
734 if ($field_name === 'mxchat_notification_email') {
735 // sanitize_email() alone CANNOT validate this field: given
736 // "[email protected], [email protected]" it returns the single concatenated
737 // address "[email protected]", which is_email() then accepts.
738 // That is how two addresses used to be stored as one dead
739 // one, silently. MxChat_Utils validates the raw parts first
740 // and refuses the whole list if any of them is bad.
741 $parsed = MxChat_Utils::parse_notification_emails($value);
742 if ($parsed['error'] !== '') {
743 // Reject: the previously stored value stays untouched.
744 wp_send_json_error(array('message' => $parsed['error']));
745 }
746 $transcripts_options[$field_name] = implode(', ', $parsed['emails']);
747 } else {
748 $transcripts_options[$field_name] = sanitize_text_field($value);
749 }
750 }
751
752 // Use direct database update to bypass any filters
753 global $wpdb;
754
755 // Serialize the options array
756 $serialized = maybe_serialize($transcripts_options);
757
758 // Check if the option already exists in the database
759 $existing = $wpdb->get_var("SELECT option_id FROM {$wpdb->options} WHERE option_name = 'mxchat_transcripts_options'");
760
761 if ($existing) {
762 // Option exists, do an update
763 $result = $wpdb->update(
764 $wpdb->options,
765 array('option_value' => $serialized),
766 array('option_name' => 'mxchat_transcripts_options'),
767 array('%s'),
768 array('%s')
769 );
770 } else {
771 // Option doesn't exist (new install), do an insert
772 $result = $wpdb->insert(
773 $wpdb->options,
774 array(
775 'option_name' => 'mxchat_transcripts_options',
776 'option_value' => $serialized,
777 'autoload' => 'yes'
778 ),
779 array('%s', '%s', '%s')
780 );
781 }
782
783 // Clear all caches after direct DB update
784 wp_cache_delete('mxchat_transcripts_options', 'options');
785 wp_cache_delete('alloptions', 'options');
786 wp_cache_flush();
787
788 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
789 return;
790 }
791 }
792 // Whole-chatbot global cap (sits in mxchat_options['rate_limits_global']).
793 // Field names: mxchat_options[rate_limits_global][limit|timeframe|limit_custom]
794 else if (strpos($name, 'mxchat_options[rate_limits_global]') !== false) {
795 preg_match('/\[rate_limits_global\]\[(.*?)\]/', $name, $matches);
796 if (isset($matches[1])) {
797 $setting_key = $matches[1];
798 if (!isset($options['rate_limits_global']) || !is_array($options['rate_limits_global'])) {
799 $options['rate_limits_global'] = array('limit' => 'unlimited', 'timeframe' => 'daily');
800 }
801 if ($setting_key === 'limit') {
802 // Selection from the preset dropdown. If __custom__, resolve from limit_custom; otherwise store directly.
803 if ($value === '__custom__') {
804 $custom = isset($options['rate_limits_global']['limit_custom']) ? (string) $options['rate_limits_global']['limit_custom'] : '';
805 if ($custom !== '' && ctype_digit($custom) && (int) $custom >= 1) {
806 $options['rate_limits_global']['limit'] = $custom;
807 }
808 // else leave existing limit untouched until the custom value arrives
809 } else {
810 $options['rate_limits_global']['limit'] = $value;
811 }
812 } elseif ($setting_key === 'limit_custom') {
813 $clean = preg_replace('/[^0-9]/', '', (string) $value);
814 $options['rate_limits_global']['limit_custom'] = $clean;
815 // Mirror a valid custom value into limit UNCONDITIONALLY (plan-74eb86).
816 // The custom number input is only editable when the dropdown is on
817 // "Custom…" (the toggle JS hides it for presets/unlimited) and autosave
818 // sends one field per change event, so a limit_custom change only fires
819 // in custom mode — there is no preset to clobber. The old guard required
820 // limit to already be non-preset, which it isn't on a first-time custom
821 // entry (the limit=__custom__ event arrives before limit_custom is set),
822 // so the value never landed in limit on the first save and reverted on refresh.
823 if ($clean !== '' && (int) $clean >= 1) {
824 $options['rate_limits_global']['limit'] = $clean;
825 }
826 } elseif ($setting_key === 'timeframe') {
827 $allowed_tf = array('hourly','daily','weekly','monthly');
828 $options['rate_limits_global']['timeframe'] = in_array($value, $allowed_tf, true) ? $value : 'daily';
829 }
830 }
831 }
832 // First check for rate limits settings
833 else if (strpos($name, 'mxchat_options[rate_limits]') !== false) {
834 //error_log('MXChat Save: Detected rate_limits field: ' . $name);
835
836 // Extract role ID and setting from the name
837 preg_match('/\[rate_limits\]\[(.*?)\]\[(.*?)\]/', $name, $matches);
838 //error_log('MXChat Save: Regex matches: ' . print_r($matches, true));
839
840 if (isset($matches[1]) && isset($matches[2])) {
841 $role_id = $matches[1];
842 $setting_key = $matches[2]; // limit, timeframe, message, or limit_custom
843
844 //error_log('MXChat Save: Role ID = ' . $role_id . ', Setting Key = ' . $setting_key);
845
846 // Initialize rate_limits if it doesn't exist
847 if (!isset($options['rate_limits'])) {
848 // //error_log('MXChat Save: Initializing rate_limits array');
849 $options['rate_limits'] = [];
850 }
851
852 // Initialize role settings if it doesn't exist
853 if (!isset($options['rate_limits'][$role_id])) {
854 //error_log('MXChat Save: Initializing rate_limits for role: ' . $role_id);
855 $options['rate_limits'][$role_id] = [
856 'limit' => ($role_id === 'logged_out') ? '10' : '100',
857 'timeframe' => 'daily',
858 'message' => 'Rate limit exceeded. Please try again later.'
859 ];
860 }
861
862 if ($setting_key === 'limit') {
863 if ($value === '__custom__') {
864 // Pull the integer from limit_custom that may have arrived (or will arrive).
865 $custom = isset($options['rate_limits'][$role_id]['limit_custom']) ? (string) $options['rate_limits'][$role_id]['limit_custom'] : '';
866 if ($custom !== '' && ctype_digit($custom) && (int) $custom >= 1) {
867 $options['rate_limits'][$role_id]['limit'] = $custom;
868 }
869 } else {
870 $options['rate_limits'][$role_id]['limit'] = $value;
871 }
872 } elseif ($setting_key === 'limit_custom') {
873 $clean = preg_replace('/[^0-9]/', '', (string) $value);
874 $options['rate_limits'][$role_id]['limit_custom'] = $clean;
875 // Mirror a valid custom value into limit UNCONDITIONALLY — same reasoning
876 // as the global branch above (plan-74eb86). The per-role custom input is
877 // only editable in custom mode and autosave is one-field-per-change, so
878 // this never clobbers a preset; it fixes the first-time-save revert.
879 if ($clean !== '' && (int) $clean >= 1) {
880 $options['rate_limits'][$role_id]['limit'] = $clean;
881 }
882 } else {
883 // Update the specific setting (timeframe, message)
884 $options['rate_limits'][$role_id][$setting_key] = $value;
885 }
886 //error_log('MXChat Save: Updated rate_limits[' . $role_id . '][' . $setting_key . '] = ' . $value);
887 } else {
888 //error_log('MXChat Save: Failed to parse rate_limits pattern: ' . $name);
889 }
890 }
891 // Then check for role rate limits (old format)
892 else if (strpos($name, 'mxchat_options[role_rate_limits]') !== false) {
893 //error_log('MXChat Save: Processing role_rate_limits field: ' . $name);
894 // Extract role ID from the name
895 preg_match('/\[role_rate_limits\]\[(.*?)\]/', $name, $matches);
896 //error_log('MXChat Save: Regex matches: ' . print_r($matches, true));
897
898 if (isset($matches[1])) {
899 $role_id = $matches[1];
900 // Initialize role_rate_limits if it doesn't exist
901 if (!isset($options['role_rate_limits'])) {
902 //error_log('MXChat Save: Initializing role_rate_limits array');
903 $options['role_rate_limits'] = [];
904 }
905 // Update the specific role's rate limit
906 $options['role_rate_limits'][$role_id] = sanitize_text_field($value);
907 //error_log('MXChat Save: Updated role_rate_limits[' . $role_id . '] = ' . $value);
908 } else {
909 //error_log('MXChat Save: Failed to parse role_rate_limits pattern: ' . $name);
910 }
911 }
912 // Handle toggles - check both extracted field_name and original name for toggle detection
913 else if (strpos($field_name, 'toggle') !== false || in_array($field_name, [
914 'chat_persistence_toggle',
915 'privacy_toggle',
916 'complianz_toggle',
917 'chat_toolbar_toggle',
918 'show_pdf_upload_button',
919 'show_word_upload_button',
920 'enable_streaming_toggle',
921 'contextual_awareness_toggle',
922 'citation_links_toggle',
923 'enable_email_block',
924 'enable_name_field',
925 'enable_consent_checkbox',
926 'consent_checkbox_required',
927 'custom_provider_for_embeddings',
928 'custom_provider_for_images',
929 'print_button_enabled',
930 'reset_chat_enabled'
931 ])) {
932 //error_log('MXChat Save: Processing toggle: ' . $field_name);
933 $options[$field_name] = ($value === 'on') ? 'on' : 'off';
934 } else {
935 //error_log('MXChat Save: Processing standard field: ' . $field_name);
936 // Store all other values directly using the extracted field name
937 $options[$field_name] = $value;
938 }
939 break;
940 }
941
942 // Save all updates to the options array
943 $updated = update_option('mxchat_options', $options);
944 //error_log('MXChat Save: Update result: ' . ($updated ? 'success' : 'unchanged') . ' for field: ' . $name);
945 //error_log('MXChat Save: Updated options array: ' . print_r($options, true));
946
947 // Log the save action if debug mode is enabled
948 if ( class_exists( 'MxChat_Admin' ) ) {
949 MxChat_Admin::mxchat_log_debug(
950 'settings_save',
951 sprintf( 'Field saved: %s', $field_name ),
952 array(
953 'field' => $field_name,
954 'updated' => $updated,
955 )
956 );
957 }
958
959 // Always return success even if WordPress says nothing changed
960 // (which happens when the value is the same as before)
961 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
962 }
963
964 /**
965 * Save the selected bot for knowledge base operations
966 */
967 public function mxchat_save_selected_bot() {
968 // Check nonce
969 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'mxchat_save_setting_nonce')) {
970 wp_send_json_error('Invalid nonce');
971 }
972
973 // Check permissions
974 if (!current_user_can('manage_options')) {
975 wp_send_json_error('Unauthorized');
976 }
977
978 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
979
980 // Save as user meta for the current user
981 $user_id = get_current_user_id();
982 update_user_meta($user_id, 'mxchat_selected_knowledge_bot', $bot_id);
983
984 // Also save as an option for site-wide default
985 update_option('mxchat_current_knowledge_bot', $bot_id);
986
987 // No cache clearing needed since we removed caching
988
989 wp_send_json_success(array(
990 'message' => 'Bot selection saved',
991 'bot_id' => $bot_id
992 ));
993 }
994
995 /**
996 * Handles AJAX request for saving chat settings
997 */
998 public function mxchat_save_prompts_setting_callback() {
999 check_ajax_referer('mxchat_prompts_setting_nonce');
1000
1001 if (!current_user_can('manage_options')) {
1002 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')]);
1003 }
1004
1005 $name = isset($_POST['name']) ? $_POST['name'] : '';
1006 $value = isset($_POST['value']) ? stripslashes($_POST['value']) : '';
1007
1008 //error_log('[MXCHAT-PROMPTS] Saving setting: ' . $name . ' = ' . $value);
1009
1010 if (empty($name)) {
1011 wp_send_json_error(['message' => esc_html__('Invalid field name', 'mxchat')]);
1012 }
1013
1014 // Handle Pinecone settings - BYPASS WORDPRESS SANITIZATION
1015 if (strpos($name, 'mxchat_pinecone_addon_options') !== false) {
1016 //error_log('[MXCHAT-PROMPTS] Processing Pinecone setting: ' . $name);
1017
1018 // Extract the field name
1019 if (preg_match('/mxchat_pinecone_addon_options\[([^\]]+)\]/', $name, $matches)) {
1020 $field_name = $matches[1];
1021 //error_log('[MXCHAT-PROMPTS] Extracted field name: ' . $field_name);
1022
1023 // Get current options directly from database - NO WordPress filters
1024 global $wpdb;
1025 $current_options_raw = $wpdb->get_var(
1026 $wpdb->prepare(
1027 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1028 'mxchat_pinecone_addon_options'
1029 )
1030 );
1031
1032 // FIX: Handle the case where the option doesn't exist yet
1033 if ($current_options_raw === null) {
1034 // Option doesn't exist, create it with default values
1035 $current_options = array(
1036 'mxchat_use_pinecone' => '0',
1037 'mxchat_pinecone_api_key' => '',
1038 'mxchat_pinecone_host' => '',
1039 'mxchat_pinecone_index' => '',
1040 'mxchat_pinecone_environment' => ''
1041 );
1042 //error_log('[MXCHAT-PROMPTS] Option does not exist, creating with defaults');
1043 } else {
1044 // Unserialize the raw data
1045 $current_options = maybe_unserialize($current_options_raw);
1046 if (!is_array($current_options)) {
1047 // Fallback to defaults if unserialization fails
1048 $current_options = array(
1049 'mxchat_use_pinecone' => '0',
1050 'mxchat_pinecone_api_key' => '',
1051 'mxchat_pinecone_host' => '',
1052 'mxchat_pinecone_index' => '',
1053 'mxchat_pinecone_environment' => ''
1054 );
1055 //error_log('[MXCHAT-PROMPTS] Failed to unserialize, using defaults');
1056 }
1057 }
1058
1059 //error_log('[MXCHAT-PROMPTS] Current options from DB: ' . print_r($current_options, true));
1060
1061 // Update the specific field with proper sanitization
1062 switch ($field_name) {
1063 case 'mxchat_use_pinecone':
1064 $new_value = ($value === '1') ? '1' : '0';
1065 break;
1066 case 'mxchat_pinecone_api_key':
1067 case 'mxchat_pinecone_host':
1068 case 'mxchat_pinecone_index':
1069 case 'mxchat_pinecone_environment':
1070 $new_value = sanitize_text_field($value);
1071 if ($field_name === 'mxchat_pinecone_host') {
1072 $new_value = str_replace(['https://', 'http://'], '', $new_value);
1073 }
1074 break;
1075 case 'mxchat_pinecone_index_type':
1076 // 362c31: document only once the host has been checked (see sanitize_pinecone_settings).
1077 $new_value = ($value === 'document') ? 'document' : 'vector';
1078 if ($new_value === 'document' && strtolower(trim((string) ($current_options['mxchat_pinecone_host'] ?? ''), '/')) !== strtolower((string) ($current_options['mxchat_pinecone_docs_verified_host'] ?? ''))) {
1079 wp_send_json_error(['message' => esc_html__('Check the document index host first (Check index or Create index for me).', 'mxchat')]);
1080 }
1081 // 3e83e4: same gate as the form save — no switch onto a half-copied index.
1082 if ($new_value === 'document' && (($current_options['mxchat_pinecone_index_type'] ?? 'vector') !== 'document') && class_exists('MxChat_Pinecone_Documents')) {
1083 $running = MxChat_Pinecone_Documents::migration_blocks_switch((string) ($current_options['mxchat_pinecone_host'] ?? ''));
1084 if ($running !== null) {
1085 wp_send_json_error(['message' => MxChat_Pinecone_Documents::switch_blocked_message($running)]);
1086 }
1087 }
1088 break;
1089 case 'mxchat_pinecone_vector_host':
1090 $new_value = trim(str_replace(['https://', 'http://'], '', sanitize_text_field($value)), '/');
1091 break;
1092 case 'mxchat_pinecone_vector_index':
1093 case 'mxchat_pinecone_docs_region':
1094 $new_value = sanitize_text_field($value);
1095 break;
1096 case 'mxchat_pinecone_docs_cloud':
1097 $new_value = in_array($value, array('aws', 'gcp', 'azure'), true) ? $value : 'aws';
1098 break;
1099 case 'mxchat_pinecone_docs_language':
1100 $lang_value = sanitize_key($value);
1101 $new_value = (class_exists('MxChat_Pinecone_Documents') && array_key_exists($lang_value, MxChat_Pinecone_Documents::languages())) ? $lang_value : 'en';
1102 break;
1103 case 'mxchat_pinecone_top_k':
1104 // d0cae1: out-of-range and junk normalize to the default 50 —
1105 // same clamp the read site applies.
1106 $top_k = absint($value);
1107 $new_value = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50);
1108 break;
1109 default:
1110 wp_send_json_error(['message' => esc_html__('Unknown Pinecone field', 'mxchat')]);
1111 }
1112
1113 $current_options[$field_name] = $new_value;
1114 //error_log('[MXCHAT-PROMPTS] New value for ' . $field_name . ': "' . $new_value . '"');
1115 //error_log('[MXCHAT-PROMPTS] Updated options: ' . print_r($current_options, true));
1116
1117 // Save directly to database to bypass WordPress sanitization
1118 $serialized_options = maybe_serialize($current_options);
1119
1120 // FIX: Use INSERT ... ON DUPLICATE KEY UPDATE or separate INSERT/UPDATE logic
1121 $option_exists = $wpdb->get_var(
1122 $wpdb->prepare(
1123 "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
1124 'mxchat_pinecone_addon_options'
1125 )
1126 );
1127
1128 if ($option_exists > 0) {
1129 // Update existing option
1130 $save_result = $wpdb->update(
1131 $wpdb->options,
1132 array('option_value' => $serialized_options),
1133 array('option_name' => 'mxchat_pinecone_addon_options'),
1134 array('%s'),
1135 array('%s')
1136 );
1137 //error_log('[MXCHAT-PROMPTS] Updated existing option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED'));
1138 } else {
1139 // Insert new option
1140 // Credential option — must NOT autoload (holds the Pinecone secret;
1141 // autoloaded rows are read into memory on every request).
1142 $save_result = $wpdb->insert(
1143 $wpdb->options,
1144 array(
1145 'option_name' => 'mxchat_pinecone_addon_options',
1146 'option_value' => $serialized_options,
1147 'autoload' => 'off'
1148 ),
1149 array('%s', '%s', '%s')
1150 );
1151 //error_log('[MXCHAT-PROMPTS] Inserted new option, result: ' . ($save_result !== false ? 'SUCCESS' : 'FAILED'));
1152 }
1153
1154 // Clear any WordPress option cache to ensure get_option() returns fresh data
1155 wp_cache_delete('mxchat_pinecone_addon_options', 'options');
1156
1157 // IMPROVED VERIFICATION - Check if the database operation succeeded
1158 if ($save_result !== false) {
1159 // Double-check by reading fresh from database
1160 $verification_raw = $wpdb->get_var(
1161 $wpdb->prepare(
1162 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1163 'mxchat_pinecone_addon_options'
1164 )
1165 );
1166 $verification_options = maybe_unserialize($verification_raw);
1167 $verified_value = isset($verification_options[$field_name]) ? $verification_options[$field_name] : 'NOT_FOUND';
1168
1169 //error_log('[MXCHAT-PROMPTS] Final verification - Expected: "' . $new_value . '", Got: "' . $verified_value . '"');
1170
1171 // Use loose comparison (==) instead of strict (===) to avoid type issues
1172 if ($verified_value == $new_value || $save_result > 0) {
1173 wp_send_json_success(['message' => esc_html__('Pinecone setting saved', 'mxchat')]);
1174 } else {
1175 // Still return success if the DB operation worked, even if verification is quirky
1176 //error_log('[MXCHAT-PROMPTS] Verification mismatch but DB operation succeeded');
1177 wp_send_json_success(['message' => esc_html__('Pinecone setting saved (DB success)', 'mxchat')]);
1178 }
1179 } else {
1180 wp_send_json_error(['message' => esc_html__('Database save failed', 'mxchat')]);
1181 }
1182 } else {
1183 wp_send_json_error(['message' => esc_html__('Invalid field name format', 'mxchat')]);
1184 }
1185
1186 return; // Exit here for Pinecone settings
1187 }
1188 // Handle auto-sync settings (existing functionality)
1189 if (strpos($name, 'mxchat_auto_sync_') === 0) {
1190 $value = ($value === 'on' || $value === '1') ? '1' : '0';
1191 $updated = update_option($name, $value);
1192
1193 if ($updated || get_option($name) === $value) {
1194 wp_send_json_success(['message' => esc_html__('Auto-sync setting saved', 'mxchat')]);
1195 } else {
1196 wp_send_json_error(['message' => esc_html__('No changes detected', 'mxchat')]);
1197 }
1198 }
1199
1200 // Handle chunking settings - use direct DB access to bypass WordPress filters
1201 if (strpos($name, 'mxchat_chunk') === 0 || $name === 'mxchat_chunking_enabled') {
1202 global $wpdb;
1203
1204 // Get current options directly from database
1205 $current_options_raw = $wpdb->get_var(
1206 $wpdb->prepare(
1207 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1208 'mxchat_options'
1209 )
1210 );
1211
1212 $options = $current_options_raw !== null ? maybe_unserialize($current_options_raw) : array();
1213 if (!is_array($options)) {
1214 $options = array();
1215 }
1216
1217 // Update the specific chunking field
1218 if ($name === 'mxchat_chunking_enabled') {
1219 $options['chunking_enabled'] = in_array($value, array('on', '1', 'true', true), true);
1220 } elseif ($name === 'mxchat_chunk_size') {
1221 $options['chunk_size'] = max(1000, min(10000, intval($value)));
1222 }
1223
1224 // Save directly to database
1225 $serialized_options = maybe_serialize($options);
1226
1227 $option_exists = $wpdb->get_var(
1228 $wpdb->prepare(
1229 "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
1230 'mxchat_options'
1231 )
1232 );
1233
1234 if ($option_exists > 0) {
1235 $save_result = $wpdb->update(
1236 $wpdb->options,
1237 array('option_value' => $serialized_options),
1238 array('option_name' => 'mxchat_options'),
1239 array('%s'),
1240 array('%s')
1241 );
1242 } else {
1243 $save_result = $wpdb->insert(
1244 $wpdb->options,
1245 array(
1246 'option_name' => 'mxchat_options',
1247 'option_value' => $serialized_options,
1248 'autoload' => 'yes'
1249 ),
1250 array('%s', '%s', '%s')
1251 );
1252 }
1253
1254 // Clear object cache for this option
1255 wp_cache_delete('mxchat_options', 'options');
1256
1257 if ($save_result !== false) {
1258 wp_send_json_success(['message' => esc_html__('Chunking setting saved', 'mxchat')]);
1259 } else {
1260 wp_send_json_error(['message' => esc_html__('Failed to save chunking setting', 'mxchat')]);
1261 }
1262 return;
1263 }
1264
1265 // Handle ACF field exclusion toggles
1266 if (strpos($name, 'mxchat_acf_field_') === 0) {
1267 // The identifier after the prefix is the ACF field KEY (unique per
1268 // field), not the field name — names are shared across groups and
1269 // collide (plan 30e81f). Reject anything that isn't key-shaped so a
1270 // stale pre-3.2.20 page (or its exit beacon) posting a bare name
1271 // can't write junk into the key-based list.
1272 $field_key = str_replace('mxchat_acf_field_', '', $name);
1273 if (!preg_match('/^field_[A-Za-z0-9_\-]+$/', $field_key)) {
1274 wp_send_json_error(['message' => esc_html__('Invalid ACF field identifier', 'mxchat')]);
1275 return;
1276 }
1277 $is_enabled = ($value === 'on' || $value === '1');
1278
1279 // Get current excluded fields
1280 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
1281 if (!is_array($excluded_fields)) {
1282 $excluded_fields = array();
1283 }
1284
1285 if ($is_enabled) {
1286 // Remove from exclusion list (field should be included)
1287 $excluded_fields = array_values(array_diff($excluded_fields, array($field_key)));
1288 // Lazy legacy-name conversion: if this field's NAME is still
1289 // stored (its group was inactive when the 30e81f migration
1290 // ran), including this one field must not silently include
1291 // its same-named twins — swap the name entry for the keys of
1292 // every OTHER field currently wearing that name.
1293 $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field_key);
1294 } else {
1295 // Add to exclusion list (field should be excluded)
1296 if (!in_array($field_key, $excluded_fields, true)) {
1297 $excluded_fields[] = $field_key;
1298 }
1299 }
1300
1301 $updated = update_option('mxchat_acf_excluded_fields', $excluded_fields);
1302
1303 if ($updated || true) { // Always report success since the state may already be correct
1304 wp_send_json_success([
1305 'message' => $is_enabled
1306 ? esc_html__('Field will be included in imports', 'mxchat')
1307 : esc_html__('Field will be excluded from imports', 'mxchat')
1308 ]);
1309 } else {
1310 wp_send_json_error(['message' => esc_html__('Failed to save ACF field setting', 'mxchat')]);
1311 }
1312 return;
1313 }
1314
1315 // Handle custom post meta whitelist
1316 if ($name === 'mxchat_custom_meta_whitelist') {
1317 $updated = update_option('mxchat_custom_meta_whitelist', sanitize_textarea_field($value));
1318
1319 if ($updated || true) { // Always report success since the state may already be correct
1320 wp_send_json_success([
1321 'message' => esc_html__('Custom meta whitelist saved', 'mxchat')
1322 ]);
1323 } else {
1324 wp_send_json_error(['message' => esc_html__('Failed to save custom meta whitelist', 'mxchat')]);
1325 }
1326 return;
1327 }
1328
1329 // Handle other prompts options (autoload false — can hold the Pinecone secret)
1330 $options = get_option('mxchat_prompts_options', []);
1331 $options[$name] = $value;
1332 $updated = update_option('mxchat_prompts_options', $options, false);
1333
1334 if ($updated) {
1335 wp_send_json_success(['message' => esc_html__('Setting saved', 'mxchat')]);
1336 } else {
1337 wp_send_json_error(['message' => esc_html__('No changes detected', 'mxchat')]);
1338 }
1339 }
1340
1341 /**
1342 * If the field behind $included_key still has its NAME stored in the
1343 * exclusion list (a legacy entry the 30e81f migration could not resolve
1344 * because the group was inactive), replace that name with the keys of
1345 * every OTHER current field wearing it. Including one field must never
1346 * silently include its same-named twins — that would be the original
1347 * collision bug in reverse, in the unsafe (privacy-losing) direction.
1348 */
1349 private function mxchat_expand_legacy_acf_name_entry($excluded_fields, $included_key) {
1350 if (!function_exists('acf_get_field')) {
1351 return $excluded_fields;
1352 }
1353 $field = acf_get_field($included_key);
1354 if (!$field || empty($field['name'])) {
1355 return $excluded_fields;
1356 }
1357 $field_name = $field['name'];
1358 if (!in_array($field_name, $excluded_fields, true)) {
1359 return $excluded_fields;
1360 }
1361 $excluded_fields = array_values(array_diff($excluded_fields, array($field_name)));
1362 foreach ($this->mxchat_acf_keys_for_name($field_name) as $twin_key) {
1363 if ($twin_key !== $included_key && !in_array($twin_key, $excluded_fields, true)) {
1364 $excluded_fields[] = $twin_key;
1365 }
1366 }
1367 return $excluded_fields;
1368 }
1369
1370 /**
1371 * Keys of every currently-registered top-level ACF field with this name.
1372 */
1373 private function mxchat_acf_keys_for_name($field_name) {
1374 $keys = array();
1375 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
1376 return $keys;
1377 }
1378 foreach (acf_get_field_groups() as $group) {
1379 $group_fields = acf_get_fields($group['key']);
1380 if (empty($group_fields)) {
1381 continue;
1382 }
1383 foreach ($group_fields as $field) {
1384 if (isset($field['name'], $field['key']) && $field['name'] === $field_name) {
1385 $keys[] = $field['key'];
1386 }
1387 }
1388 }
1389 return $keys;
1390 }
1391
1392 /**
1393 * Group-level ACF toggle (plan bf57e0): sets every field in one ACF field
1394 * group included or excluded in a SINGLE option write. The client must
1395 * never loop the per-field endpoint for this — get_option → modify →
1396 * update_option once per field from twenty concurrent requests is a
1397 * lost-update race that silently drops most of the group.
1398 */
1399 public function mxchat_acf_toggle_group_callback() {
1400 check_ajax_referer('mxchat_prompts_setting_nonce');
1401
1402 if (!current_user_can('manage_options')) {
1403 wp_send_json_error(['message' => esc_html__('Insufficient permissions', 'mxchat')], 403);
1404 return;
1405 }
1406
1407 if (!function_exists('acf_get_fields')) {
1408 wp_send_json_error(['message' => esc_html__('ACF is not active', 'mxchat')]);
1409 return;
1410 }
1411
1412 $group_key = isset($_POST['group_key']) ? sanitize_text_field(wp_unslash($_POST['group_key'])) : '';
1413 if (!preg_match('/^group_[A-Za-z0-9_\-]+$/', $group_key)) {
1414 wp_send_json_error(['message' => esc_html__('Invalid ACF group identifier', 'mxchat')]);
1415 return;
1416 }
1417 $state = isset($_POST['state']) ? sanitize_text_field(wp_unslash($_POST['state'])) : '';
1418 $include = ($state === 'on' || $state === '1');
1419
1420 // Resolve the group's fields SERVER-side — a client-supplied key list
1421 // is not trusted. This is the same call the settings UI lists from,
1422 // so the toggle covers exactly the rendered set (top-level fields).
1423 $group_fields = acf_get_fields($group_key);
1424 if (empty($group_fields)) {
1425 wp_send_json_error(['message' => esc_html__('No fields found for this group', 'mxchat')]);
1426 return;
1427 }
1428
1429 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
1430 if (!is_array($excluded_fields)) {
1431 $excluded_fields = array();
1432 }
1433
1434 $touched = array();
1435 foreach ($group_fields as $field) {
1436 if (empty($field['key'])) {
1437 continue;
1438 }
1439 if ($include) {
1440 $excluded_fields = array_values(array_diff($excluded_fields, array($field['key'])));
1441 $excluded_fields = $this->mxchat_expand_legacy_acf_name_entry($excluded_fields, $field['key']);
1442 } elseif (!in_array($field['key'], $excluded_fields, true)) {
1443 $excluded_fields[] = $field['key'];
1444 }
1445 $touched[] = array(
1446 'name' => 'mxchat_acf_field_' . $field['key'],
1447 'value' => $include ? 'on' : 'off',
1448 );
1449 }
1450
1451 // The one write — the whole point of this endpoint.
1452 update_option('mxchat_acf_excluded_fields', array_values($excluded_fields));
1453
1454 wp_send_json_success([
1455 'message' => $include
1456 ? esc_html__('All fields in this group will be included in imports', 'mxchat')
1457 : esc_html__('All fields in this group will be excluded from imports', 'mxchat'),
1458 'fields' => $touched,
1459 ]);
1460 }
1461
1462 /**
1463 * Handles AJAX request for Pinecone settings migration
1464 */
1465 public function ajax_migrate_pinecone_settings() {
1466 // Verify nonce
1467 if (!wp_verify_nonce($_POST['_ajax_nonce'] ?? '', 'mxchat_save_setting_nonce')) {
1468 wp_send_json_error('Invalid nonce');
1469 }
1470
1471 // Check permissions
1472 if (!current_user_can('manage_options')) {
1473 wp_send_json_error('Unauthorized access');
1474 }
1475
1476 // Check if old Pinecone addon options exist
1477 $old_options = get_option('mxchat_pinecone_addon_options', array());
1478
1479 if (empty($old_options)) {
1480 wp_send_json_success(array('migrated' => false, 'message' => 'No old settings found'));
1481 }
1482
1483 // Get current core plugin options
1484 $current_options = get_option('mxchat_pinecone_addon_options', array());
1485
1486 // Only migrate if core options are empty or if explicitly requested
1487 $should_migrate = empty($current_options) ||
1488 (empty($current_options['mxchat_pinecone_api_key']) && !empty($old_options['mxchat_pinecone_api_key']));
1489
1490 if ($should_migrate) {
1491 // Migrate settings with proper sanitization
1492 $migrated_options = array(
1493 'mxchat_use_pinecone' => $old_options['mxchat_use_pinecone'] ?? '0',
1494 'mxchat_pinecone_api_key' => sanitize_text_field($old_options['mxchat_pinecone_api_key'] ?? ''),
1495 'mxchat_pinecone_host' => sanitize_text_field($old_options['mxchat_pinecone_host'] ?? ''),
1496 'mxchat_pinecone_index' => sanitize_text_field($old_options['mxchat_pinecone_index'] ?? ''),
1497 'mxchat_pinecone_environment' => sanitize_text_field($old_options['mxchat_pinecone_environment'] ?? '')
1498 );
1499
1500 update_option('mxchat_pinecone_addon_options', $migrated_options, false);
1501
1502 wp_send_json_success(array(
1503 'migrated' => true,
1504 'message' => 'Settings migrated successfully from Pinecone add-on'
1505 ));
1506 } else {
1507 wp_send_json_success(array(
1508 'migrated' => false,
1509 'message' => 'Settings already exist in core plugin'
1510 ));
1511 }
1512 }
1513
1514
1515 // ========================================
1516 // LICENSE AJAX HANDLERS
1517 // ========================================
1518
1519 /**
1520 * Validates and activates chat license via AJAX
1521 */
1522 public function mxchat_handle_activate_license() {
1523 // Check nonce
1524 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1525 wp_send_json_error(esc_html__('Invalid security token', 'mxchat'));
1526 return;
1527 }
1528
1529 // Verify user capabilities
1530 if (!current_user_can('manage_options')) {
1531 wp_send_json_error(esc_html__('Unauthorized access', 'mxchat'));
1532 return;
1533 }
1534
1535 $license_key = isset($_POST['mxchat_activation_key']) ? sanitize_text_field($_POST['mxchat_activation_key']) : '';
1536 $customer_email = isset($_POST['mxchat_pro_email']) ? sanitize_email($_POST['mxchat_pro_email']) : '';
1537
1538 if (empty($license_key) || empty($customer_email)) {
1539 wp_send_json_error(esc_html__('Email or License Key is missing', 'mxchat'));
1540 return;
1541 }
1542
1543 $product_id = 'MxChatPRO';
1544 $domain = parse_url(home_url(), PHP_URL_HOST); // Get the current domain
1545 $was_active = (get_option('mxchat_license_status', 'inactive') === 'active');
1546
1547 // Call WooCommerce Software API for activation (not just validation)
1548 $response = wp_remote_get(
1549 add_query_arg(
1550 array(
1551 'wc-api' => 'software-api',
1552 'request' => 'activation',
1553 'email' => $customer_email,
1554 'license_key' => $license_key,
1555 'product_id' => $product_id,
1556 'instance' => $domain, // THIS IS KEY - include the domain as instance
1557 'platform' => 'wordpress' // Optional but good to include
1558 ),
1559 'https://mxchat.ai/'
1560 ),
1561 array(
1562 'timeout' => 60,
1563 'sslverify' => true
1564 )
1565 );
1566
1567 if (is_wp_error($response)) {
1568 $error_message = $response->get_error_message();
1569 //error_log('MxChat License Activation Error: ' . $error_message);
1570 wp_send_json_error(esc_html__('Activation failed due to a server error: ', 'mxchat') . $error_message);
1571 return;
1572 }
1573
1574 $response_code = wp_remote_retrieve_response_code($response);
1575 $body = wp_remote_retrieve_body($response);
1576
1577 // Log response for debugging
1578 //error_log('MxChat License Response Code: ' . $response_code);
1579 //error_log('MxChat License Response Body: ' . $body);
1580
1581 if ($response_code !== 200) {
1582 wp_send_json_error(esc_html__('Server returned error code: ', 'mxchat') . $response_code);
1583 return;
1584 }
1585
1586 $data = json_decode($body);
1587
1588 if ($data && isset($data->activated) && $data->activated) {
1589 // Success - save local options
1590 update_option('mxchat_license_status', 'active');
1591 update_option('mxchat_pro_email', $customer_email);
1592 update_option('mxchat_activation_key', $license_key);
1593 delete_option('mxchat_license_error');
1594
1595 // A fresh activation starts the licence domain check (c93ede) from a
1596 // clean slate: no grace streak carried over, first daily check at a
1597 // random time within the day.
1598 if (class_exists('MxChat_License_Check')) {
1599 MxChat_License_Check::reset(true);
1600 }
1601
1602 // Also track on your website (this is your existing domain tracking)
1603 $this->track_domain_on_website($license_key, $customer_email, $domain);
1604
1605 wp_send_json_success(array('message' => esc_html__('License activated successfully', 'mxchat')));
1606 } else {
1607 $error_message = isset($data->error) ? $data->error : esc_html__('Activation failed', 'mxchat');
1608 update_option('mxchat_license_status', 'inactive');
1609 update_option('mxchat_license_error', $error_message);
1610
1611 // c93ede: a refusal by the licence owner (domain switched off, new sites
1612 // blocked) or a revoked key is an explicit "off" from mxchat.ai. Remember
1613 // who controls it so Pro & Extensions says so, and keep checking so the
1614 // owner's switch-on restores Pro without another click here.
1615 if (class_exists('MxChat_License_Check')) {
1616 MxChat_License_Check::note_activation_refusal(isset($data->code) ? (string) $data->code : '', (string) $error_message, $was_active);
1617 }
1618
1619 //error_log('MxChat Activation failed: ' . $error_message);
1620 wp_send_json_error($error_message);
1621 }
1622 }
1623
1624 /**
1625 * Track domain on your website (separate from WooCommerce activation)
1626 */
1627 private function track_domain_on_website($license_key, $email, $domain) {
1628 // This calls your website's tracking API
1629 wp_remote_post('https://mxchat.ai/mxchat-api/activate-license', array(
1630 'body' => array(
1631 'mxchat_pro_email' => $email,
1632 'mxchat_activation_key' => $license_key,
1633 'domain' => $domain
1634 ),
1635 'timeout' => 10,
1636 'sslverify' => true
1637 ));
1638 }
1639
1640 /**
1641 * Validates license via AJAX with email and key
1642 */
1643 public function mxchat_check_license_status() {
1644 // Verify nonce
1645 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1646 wp_send_json_error('Security check failed');
1647 return;
1648 }
1649
1650 // Add isset checks for safety
1651 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1652 $key = isset($_POST['key']) ? sanitize_text_field($_POST['key']) : '';
1653
1654 // Check if this license is actually active in your system
1655 $is_active = (get_option('mxchat_license_status') === 'active' &&
1656 get_option('mxchat_pro_email') === $email &&
1657 get_option('mxchat_activation_key') === $key);
1658
1659 wp_send_json(array(
1660 'is_active' => $is_active
1661 ));
1662 }
1663
1664 /**
1665 * Handle license deactivation - Complete version for plugin
1666 */
1667 function mxchat_deactivate_license() {
1668 // Add debugging
1669 //error_log('MxChat deactivate function called');
1670
1671 // Check nonce
1672 if (!check_ajax_referer('mxchat_activate_license_nonce', 'security', false)) {
1673 //error_log('MxChat deactivate: Nonce check failed');
1674 wp_send_json_error('Security check failed.');
1675 return;
1676 }
1677
1678 // plan-mxchat-20260731-c63fb6 — nonce is not authorization. Without this,
1679 // any authenticated user holding the nonce could revoke the site's PRO
1680 // licence. Every sibling handler in this file already checks.
1681 if (!current_user_can('manage_options')) {
1682 wp_send_json_error(esc_html__('Unauthorized', 'mxchat'), 403);
1683 return;
1684 }
1685
1686 //error_log('MxChat deactivate: Nonce check passed');
1687
1688 $license_key = get_option('mxchat_activation_key');
1689 $email = get_option('mxchat_pro_email');
1690 $domain = parse_url(home_url(), PHP_URL_HOST);
1691
1692 //error_log('MxChat deactivate: License: ' . $license_key . ', Email: ' . $email . ', Domain: ' . $domain);
1693
1694 if (empty($license_key) || empty($email)) {
1695 //error_log('MxChat deactivate: No active license found');
1696 wp_send_json_error('No active license found.');
1697 return;
1698 }
1699
1700 // Clear local license data first
1701 delete_option('mxchat_license_status');
1702 delete_option('mxchat_pro_email');
1703 delete_option('mxchat_activation_key');
1704 delete_option('mxchat_license_error');
1705
1706 // Nothing left to check: drop the licence domain check's state and its
1707 // scheduled event (c93ede).
1708 if (class_exists('MxChat_License_Check')) {
1709 MxChat_License_Check::reset(false);
1710 }
1711
1712 //error_log('MxChat deactivate: Local data cleared');
1713
1714 // Notify your website's API to properly deactivate
1715 $response = wp_remote_post('https://mxchat.ai/mxchat-api/deactivate-license', array(
1716 'body' => array(
1717 'license_key' => $license_key,
1718 'email' => $email,
1719 'domain' => $domain
1720 ),
1721 'timeout' => 15,
1722 'sslverify' => true
1723 ));
1724
1725 if (is_wp_error($response)) {
1726 //error_log('MxChat deactivate: Server error - ' . $response->get_error_message());
1727 wp_send_json_success(array(
1728 'message' => 'License deactivated locally. Server could not be contacted to free activation slot.',
1729 'server_notified' => false
1730 ));
1731 return;
1732 }
1733
1734 $response_body = wp_remote_retrieve_body($response);
1735 $response_data = json_decode($response_body, true);
1736
1737 //error_log('MxChat deactivate: Server response - ' . $response_body);
1738
1739 if (isset($response_data['success']) && $response_data['success']) {
1740 //error_log('MxChat deactivate: Success with server notification');
1741 wp_send_json_success(array(
1742 'message' => 'License deactivated successfully. Activation slot has been freed up.',
1743 'server_notified' => true
1744 ));
1745 } else {
1746 //error_log('MxChat deactivate: Server responded but deactivation may have failed');
1747 wp_send_json_success(array(
1748 'message' => 'License deactivated locally. Please check your account dashboard to verify the activation was freed.',
1749 'server_notified' => false
1750 ));
1751 }
1752 }
1753
1754
1755 // ========================================
1756 // ACTIONS & INTENTS AJAX HANDLERS
1757 // ========================================
1758
1759 /**
1760 * Validates nonce and returns JSON error on failure
1761 */
1762 public function mxchat_toggle_action() {
1763 // Check nonce
1764 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_actions_nonce')) {
1765 wp_send_json_error(array('message' => 'Security check failed'));
1766 return;
1767 }
1768
1769 // Check permissions
1770 if (!current_user_can('manage_options')) {
1771 wp_send_json_error(array('message' => 'Permission denied'));
1772 return;
1773 }
1774
1775 // Validate params
1776 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
1777 $enabled = isset($_POST['enabled']) ? (bool)$_POST['enabled'] : false;
1778
1779 if (!$intent_id) {
1780 wp_send_json_error(array('message' => 'Invalid action ID'));
1781 return;
1782 }
1783
1784 // Update the intent/action status in the database
1785 global $wpdb;
1786 $table_name = $wpdb->prefix . 'mxchat_intents';
1787
1788 // Using the 'enabled' field - add this field if it doesn't exist
1789 $result = $wpdb->update(
1790 $table_name,
1791 array('enabled' => $enabled ? 1 : 0),
1792 array('id' => $intent_id),
1793 array('%d'),
1794 array('%d')
1795 );
1796
1797 if ($result === false) {
1798 wp_send_json_error(array('message' => 'Database error'));
1799 return;
1800 }
1801
1802 wp_send_json_success();
1803 }
1804
1805
1806 /**
1807 * Validates permissions for AJAX request handling
1808 */
1809 public function mxchat_update_intent_threshold() {
1810 // Check permissions
1811 if (!current_user_can('manage_options')) {
1812 if (wp_doing_ajax()) {
1813 wp_send_json_error(array('message' => 'Unauthorized user'));
1814 return;
1815 }
1816 wp_die(esc_html__('Unauthorized user', 'mxchat'));
1817 }
1818
1819 // Verify nonce
1820 check_admin_referer('mxchat_update_intent_threshold_nonce');
1821
1822 // Process the update if we have valid data
1823 if (isset($_POST['intent_id'], $_POST['intent_threshold'])) {
1824 global $wpdb;
1825 $table_name = $wpdb->prefix . 'mxchat_intents';
1826 $intent_id = intval($_POST['intent_id']);
1827 $threshold_percentage = max(70, min(95, intval($_POST['intent_threshold'])));
1828 $similarity_threshold = $threshold_percentage / 100;
1829
1830 $result = $wpdb->update(
1831 $table_name,
1832 ['similarity_threshold' => $similarity_threshold],
1833 ['id' => $intent_id],
1834 ['%f'],
1835 ['%d']
1836 );
1837
1838 // Handle AJAX requests
1839 if (wp_doing_ajax()) {
1840 if ($result === false) {
1841 wp_send_json_error(array('message' => 'Failed to update threshold'));
1842 } else {
1843 wp_send_json_success(array('threshold' => $threshold_percentage));
1844 }
1845 return;
1846 }
1847 }
1848
1849 // Redirect for regular form submissions
1850 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions&updated=true'));
1851 exit;
1852 }
1853
1854 // ========================================
1855 // HELPER METHODS
1856 // ========================================
1857
1858 /**
1859 * Returns a specific nonce action string
1860 */
1861 private function mxchat_get_nonce_action() {
1862 return 'mxchat_license_nonce';
1863 }
1864
1865 /**
1866 * Check API key status for all providers
1867 */
1868 public function mxchat_check_api_keys() {
1869 // Check nonce
1870 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'mxchat_save_setting_nonce')) {
1871 wp_send_json_error('Invalid nonce');
1872 }
1873
1874 // Check permissions
1875 if (!current_user_can('manage_options')) {
1876 wp_send_json_error('Unauthorized');
1877 }
1878
1879 // Get current options
1880 $options = get_option('mxchat_options', array());
1881
1882 // Check which API keys are present
1883 $api_key_status = array(
1884 'openai' => !empty($options['api_key']),
1885 'claude' => !empty($options['claude_api_key']),
1886 'xai' => !empty($options['xai_api_key']),
1887 'deepseek' => !empty($options['deepseek_api_key']),
1888 'gemini' => !empty($options['gemini_api_key']),
1889 'openrouter' => !empty($options['openrouter_api_key']),
1890 'voyage' => !empty($options['voyage_api_key'])
1891 );
1892
1893 wp_send_json_success($api_key_status);
1894 }
1895
1896 // ========================================
1897 // DEBUG & OPTIMIZATION AJAX HANDLERS
1898 // ========================================
1899
1900 /**
1901 * Toggle debug mode on/off
1902 */
1903 public function mxchat_toggle_debug_mode_callback() {
1904 // Verify nonce
1905 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1906 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1907 }
1908
1909 // Check permissions
1910 if ( ! current_user_can( 'manage_options' ) ) {
1911 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1912 }
1913
1914 $enabled = isset( $_POST['enabled'] ) && $_POST['enabled'] === 'on';
1915
1916 $options = get_option( 'mxchat_options', array() );
1917
1918 if ( $enabled ) {
1919 $options['debug_mode'] = 'on';
1920 update_option( 'mxchat_options', $options );
1921 MxChat_Admin::mxchat_log_debug( 'debug_mode', 'Debug mode enabled' );
1922 } else {
1923 // Log before disabling
1924 MxChat_Admin::mxchat_log_debug( 'debug_mode', 'Debug mode disabled' );
1925 $options['debug_mode'] = 'off';
1926 update_option( 'mxchat_options', $options );
1927 }
1928
1929 wp_send_json_success( array(
1930 'message' => $enabled ? esc_html__( 'Debug mode enabled', 'mxchat' ) : esc_html__( 'Debug mode disabled', 'mxchat' ),
1931 'enabled' => $enabled,
1932 ) );
1933 }
1934
1935 /**
1936 * Get the debug log entries
1937 */
1938 public function mxchat_get_debug_log_callback() {
1939 // Verify nonce
1940 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1941 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1942 }
1943
1944 // Check permissions
1945 if ( ! current_user_can( 'manage_options' ) ) {
1946 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1947 }
1948
1949 $log = MxChat_Admin::mxchat_get_debug_log();
1950
1951 wp_send_json_success( array(
1952 'log' => $log,
1953 'count' => count( $log ),
1954 ) );
1955 }
1956
1957 /**
1958 * Clear the debug log
1959 */
1960 public function mxchat_clear_debug_log_callback() {
1961 // Verify nonce
1962 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1963 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1964 }
1965
1966 // Check permissions
1967 if ( ! current_user_can( 'manage_options' ) ) {
1968 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1969 }
1970
1971 MxChat_Admin::mxchat_clear_debug_log();
1972
1973 // Log that the log was cleared (this will be the first entry in the new log)
1974 MxChat_Admin::mxchat_log_debug( 'debug_log', 'Debug log cleared by user' );
1975
1976 wp_send_json_success( array( 'message' => esc_html__( 'Debug log cleared', 'mxchat' ) ) );
1977 }
1978
1979 /**
1980 * Export settings as JSON
1981 */
1982 public function mxchat_export_settings_callback() {
1983 // Verify nonce
1984 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
1985 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
1986 }
1987
1988 // Check permissions
1989 if ( ! current_user_can( 'manage_options' ) ) {
1990 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
1991 }
1992
1993 $export = MxChat_Admin::mxchat_export_settings();
1994
1995 // Log the export
1996 MxChat_Admin::mxchat_log_debug( 'settings_export', 'Settings exported by user' );
1997
1998 wp_send_json_success( array(
1999 'settings' => $export,
2000 'filename' => 'mxchat-settings-' . gmdate( 'Y-m-d-His' ) . '.json',
2001 ) );
2002 }
2003
2004 /**
2005 * Reset all settings to defaults
2006 */
2007 public function mxchat_reset_all_settings_callback() {
2008 // Verify nonce
2009 if ( ! check_ajax_referer( 'mxchat_save_setting_nonce', '_ajax_nonce', false ) ) {
2010 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
2011 }
2012
2013 // Check permissions
2014 if ( ! current_user_can( 'manage_options' ) ) {
2015 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
2016 }
2017
2018 // Require confirmation code
2019 $confirmation = isset( $_POST['confirmation'] ) ? sanitize_text_field( wp_unslash( $_POST['confirmation'] ) ) : '';
2020
2021 if ( strtoupper( $confirmation ) !== 'RESET' ) {
2022 wp_send_json_error( array( 'message' => esc_html__( 'Invalid confirmation code. Please type RESET to confirm.', 'mxchat' ) ) );
2023 }
2024
2025 // Perform the reset
2026 MxChat_Admin::mxchat_reset_all_settings();
2027
2028 wp_send_json_success( array( 'message' => esc_html__( 'All settings have been reset to defaults. The page will reload.', 'mxchat' ) ) );
2029 }
2030
2031 /**
2032 * Reset the global rate-limit usage counter to zero on demand.
2033 *
2034 * Zeroes the WP option mxchat_chat_limit_<bot>_global that the integrator
2035 * increments per message, then returns a freshly-formatted readout string
2036 * so the settings page can update without a reload. Does NOT change any
2037 * enforcement config — purely clears the running counter.
2038 */
2039 public function mxchat_reset_global_rate_limit_callback() {
2040 // Verify nonce
2041 if ( ! check_ajax_referer( 'mxchat_reset_global_usage', '_ajax_nonce', false ) ) {
2042 wp_send_json_error( array( 'message' => esc_html__( 'Security check failed', 'mxchat' ) ) );
2043 }
2044
2045 // Check permissions
2046 if ( ! current_user_can( 'manage_options' ) ) {
2047 wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized', 'mxchat' ) ) );
2048 }
2049
2050 // Resolve the per-bot counter key the same way the integrator does.
2051 $bot_id = isset( $_POST['bot_id'] ) ? sanitize_key( wp_unslash( $_POST['bot_id'] ) ) : 'default';
2052 $safe_bot = preg_replace( '/[^a-zA-Z0-9_]/', '_', $bot_id );
2053 if ( $safe_bot === '' ) {
2054 $safe_bot = 'default';
2055 }
2056 $option_key = 'mxchat_chat_limit_' . $safe_bot . '_global';
2057
2058 $now = time();
2059 update_option( $option_key, array( 'count' => 0, 'timestamp' => $now ) );
2060
2061 // Recompute the display string so the front-end can update in place.
2062 $all_options = get_option( 'mxchat_options', array() );
2063 $global_cfg = isset( $all_options['rate_limits_global'] ) && is_array( $all_options['rate_limits_global'] )
2064 ? $all_options['rate_limits_global']
2065 : array();
2066 $limit_raw = isset( $global_cfg['limit'] ) ? (string) $global_cfg['limit'] : 'unlimited';
2067 // Defensive: if a raw __custom__ ever slips through, fall back to the custom value.
2068 if ( ! ctype_digit( $limit_raw ) && isset( $global_cfg['limit_custom'] ) && ctype_digit( (string) $global_cfg['limit_custom'] ) ) {
2069 $limit_raw = (string) $global_cfg['limit_custom'];
2070 }
2071 $timeframe = isset( $global_cfg['timeframe'] ) ? (string) $global_cfg['timeframe'] : 'daily';
2072 $windows = array( 'hourly' => 3600, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2592000 );
2073 $window = isset( $windows[ $timeframe ] ) ? $windows[ $timeframe ] : 86400;
2074 $reset_at = $now + $window;
2075 $limit_int = ctype_digit( $limit_raw ) ? (int) $limit_raw : 0;
2076
2077 $text = sprintf(
2078 /* translators: 1: used count, 2: limit, 3: remaining, 4: human-readable time until reset */
2079 esc_html__( '%1$s of %2$s used · %3$s left · resets in %4$s', 'mxchat' ),
2080 number_format_i18n( 0 ),
2081 number_format_i18n( $limit_int ),
2082 number_format_i18n( $limit_int ),
2083 human_time_diff( $now, $reset_at )
2084 );
2085
2086 wp_send_json_success( array(
2087 'count' => 0,
2088 'limit' => $limit_int,
2089 'left' => $limit_int,
2090 'reset_at' => $reset_at,
2091 'pct' => 0,
2092 'text' => $text,
2093 'message' => esc_html__( 'Usage counter reset.', 'mxchat' ),
2094 ) );
2095 }
2096
2097 }
2098
2099 // Initialize the AJAX handler
2100 new MxChat_Ajax_Handler();
2101