PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.16
MxChat – AI Chatbot & Content Generation for WordPress v3.2.16
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-admin.php

class-mxchat-admin.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.16, at includes/class-mxchat-admin.php

10,912 lines 463.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit; // Exit if accessed directly
4 }
5
6 class MxChat_Admin {
7 private $options;
8 private $chat_count;
9 private $is_activated;
10 private $knowledge_manager;
11
12 /**
13 * Get whether the license is activated
14 * @return bool
15 */
16 public function is_activated() {
17 return $this->is_activated;
18 }
19
20 /**
21 * Get plugin options
22 * @return array
23 */
24 public function get_options() {
25 return $this->options;
26 }
27
28 public function __construct($knowledge_manager = null) {
29 $this->options = get_option('mxchat_options');
30 $this->chat_count = get_option('mxchat_chat_count', 0);
31 $this->is_activated = $this->is_license_active();
32 $this->knowledge_manager = $knowledge_manager;
33
34 // Initialize default options if they are not set
35 if (!$this->options) {
36 $this->initialize_default_options();
37 }
38
39 // Add admin menu and initialize settings
40 add_action('admin_menu', array($this, 'mxchat_add_plugin_page'));
41 // Pro & Extensions registers at priority 30 so it always lands last in
42 // the sidebar — below API Access (priority 20) and below any other
43 // submenu that hooks at default priority 10.
44 add_action('admin_menu', array($this, 'mxchat_add_pro_extensions_page'), 30);
45 // Onboarding visibility — runs LAST so it can remove the submenu after every
46 // other add_submenu_page() call. The page stays reachable by direct URL.
47 add_action('admin_menu', array($this, 'mxchat_apply_onboarding_visibility'), 999);
48 add_action('admin_init', array($this, 'mxchat_page_init'));
49 add_action('admin_init', array($this, 'mxchat_prompts_page_init'));
50 add_action('admin_enqueue_scripts', array($this, 'mxchat_enqueue_admin_assets'));
51 // Add body class for the Onboarding wizard (plan-905439) so the
52 // chrome-surgery CSS in admin-onboarding-wizard.css can scope its
53 // WP-sidebar collapse to this page only.
54 add_filter('admin_body_class', array($this, 'mxchat_add_onboarding_body_class'));
55 add_action('wp_ajax_mxchat_delete_chat_history', array($this, 'mxchat_delete_chat_history'));
56 add_action('admin_post_mxchat_delete_prompt', array($this, 'mxchat_handle_delete_prompt'));
57 add_action('wp_ajax_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
58 add_action('wp_ajax_nopriv_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
59 add_action('wp_ajax_mxchat_fetch_conversation', array($this, 'mxchat_fetch_conversation'));
60 add_action('wp_footer', array($this, 'mxchat_append_chatbot_to_body'));
61 add_action('admin_head-mxchat-prompts', array($this, 'mxchat_enqueue_admin_assets'));
62 add_action('admin_head-toplevel_page_mxchat-max', array($this, 'mxchat_enqueue_admin_assets'));
63 add_action('admin_notices', array($this, 'mxchat_display_admin_notice'));
64 add_action('admin_post_mxchat_delete_all_prompts', array($this, 'mxchat_handle_delete_all_prompts'));
65 add_action('admin_post_mxchat_add_intent', array($this, 'mxchat_handle_add_intent'));
66 add_action('admin_post_mxchat_delete_intent', array($this, 'mxchat_handle_delete_intent'));
67 add_action('admin_post_mxchat_edit_intent', array($this, 'mxchat_handle_edit_intent'));
68 add_action('wp_ajax_mxchat_export_transcripts', array($this, 'export_chat_transcripts'));
69
70 // Leads tab (inside Transcripts)
71 add_action('wp_ajax_mxchat_fetch_leads', array($this, 'mxchat_fetch_leads'));
72 add_action('wp_ajax_mxchat_delete_leads', array($this, 'mxchat_delete_leads'));
73 add_action('wp_ajax_mxchat_export_leads', array($this, 'mxchat_export_leads'));
74
75 add_action('admin_init', array($this, 'mxchat_transcripts_page_init'));
76 add_action('wp_ajax_dismiss_live_agent_notice', array($this, 'dismiss_live_agent_notice'));
77 add_action('wp_ajax_dismiss_theme_migration_notice', array($this, 'dismiss_theme_migration_notice'));
78 add_action('mxchat_cleanup_old_transcripts', array($this, 'cleanup_old_transcripts'));
79 // Self-heal: the cleanup event is otherwise only scheduled at activation or
80 // when the retention setting CHANGES — if it is ever lost (deactivate cycle,
81 // cron-option wipe, DB restore) nothing re-registers it and retention goes
82 // silently inert while the UI still says it is on (plan-bc08a6).
83 add_action('admin_init', array($this, 'ensure_transcript_cleanup_scheduled'));
84
85 add_action('admin_init', array($this, 'register_pinecone_settings'));
86 add_action('admin_init', array($this, 'register_openai_vectorstore_settings'));
87
88 add_action('admin_notices', array($this, 'display_admin_notices'));
89
90 add_action('wp_ajax_mxchat_test_streaming_actual', [$this, 'mxchat_handle_test_streaming_actual']);
91 add_action('wp_ajax_mxchat_test_streaming', [$this, 'mxchat_handle_test_streaming']); // Keep existing as fallback
92 add_action('wp_ajax_mxchat_test_vectorstore_connection', array($this, 'mxchat_test_vectorstore_connection'));
93
94 // wp_ajax_mxchat_save_selected_bot is registered (and handled) in
95 // admin/class-ajax-handler.php — a duplicate registration here pointed
96 // at a method this class never defined, a load-order-dependent fatal
97 // waiting to fire (plan 52bcad).
98 add_action('wp_ajax_mxchat_fetch_openrouter_models', array($this, 'fetch_openrouter_models'));
99 add_action('wp_ajax_mxchat_get_rag_context', array($this, 'mxchat_get_rag_context'));
100
101 // Actions page AJAX handlers
102 add_action('wp_ajax_mxchat_fetch_actions_list', array($this, 'mxchat_fetch_actions_list'));
103 add_action('wp_ajax_mxchat_toggle_action_status', array($this, 'mxchat_toggle_action_status'));
104 add_action('wp_ajax_mxchat_bulk_delete_actions', array($this, 'mxchat_bulk_delete_actions'));
105 add_action('wp_ajax_mxchat_add_intent_ajax', array($this, 'mxchat_add_intent_ajax'));
106 add_action('wp_ajax_mxchat_edit_intent_ajax', array($this, 'mxchat_edit_intent_ajax'));
107 add_action('wp_ajax_mxchat_delete_intent_ajax', array($this, 'mxchat_delete_intent_ajax'));
108 add_action('wp_ajax_mxchat_add_phrase', array($this, 'mxchat_add_phrase_ajax'));
109 add_action('wp_ajax_mxchat_delete_phrase', array($this, 'mxchat_delete_phrase_ajax'));
110 add_action('wp_ajax_mxchat_get_phrases', array($this, 'mxchat_get_phrases_ajax'));
111 add_action('wp_ajax_mxchat_delete_legacy_phrases', array($this, 'mxchat_delete_legacy_phrases_ajax'));
112
113 // Slack test connection
114 add_action('wp_ajax_mxchat_test_slack_connection', array($this, 'mxchat_test_slack_connection'));
115
116 // Translation handlers
117 add_action('wp_ajax_mxchat_translate_messages', array($this, 'mxchat_translate_messages'));
118 add_action('wp_ajax_mxchat_get_transcript_translation', array($this, 'mxchat_get_transcript_translation'));
119
120 // 3.2.3: Embedding model switch protection
121 add_action('wp_ajax_mxchat_check_embedding_switch', array($this, 'mxchat_check_embedding_switch_ajax'));
122 add_action('wp_ajax_mxchat_dismiss_embedding_mismatch', array($this, 'mxchat_dismiss_embedding_mismatch_ajax'));
123 add_action('wp_ajax_mxchat_dismiss_telegram_secret_notice', array($this, 'mxchat_dismiss_telegram_secret_notice_ajax'));
124 add_action('admin_notices', array($this, 'mxchat_embedding_mismatch_notice'));
125 add_action('admin_notices', array($this, 'mxchat_telegram_secret_notice'));
126 }
127
128 /**
129 * Nudge admins to configure a Telegram webhook secret (plan-0c17b5).
130 *
131 * Shown only when a Telegram bot token is configured (integration active)
132 * AND no webhook secret is set. Without a secret the webhook falls back to a
133 * Telegram source-IP check — safer than the old fail-open, but a real secret
134 * is the recommended protection. Dismissible; does not block anything.
135 */
136 public function mxchat_telegram_secret_notice() {
137 if (!current_user_can('manage_options')) {
138 return;
139 }
140 $options = get_option('mxchat_options', array());
141 $has_token = !empty($options['telegram_bot_token']);
142 $has_secret = !empty($options['telegram_webhook_secret']);
143 if (!$has_token || $has_secret) {
144 return;
145 }
146 if (get_option('mxchat_dismissed_telegram_secret_notice', '') === '1') {
147 return;
148 }
149 $settings_url = admin_url('admin.php?page=mxchat-max');
150 ?>
151 <div class="notice notice-warning is-dismissible mxchat-telegram-secret-notice">
152 <p><strong><?php esc_html_e('MxChat: Telegram webhook is running without a secret token', 'mxchat'); ?></strong></p>
153 <p>
154 <?php esc_html_e('Your Telegram integration is active but no webhook secret is configured. Requests are currently verified only by origin IP. For full protection, set a webhook secret token in the Telegram settings and re-register your webhook.', 'mxchat'); ?>
155 </p>
156 <p>
157 <a href="<?php echo esc_url($settings_url); ?>" class="button button-primary"><?php esc_html_e('Open Telegram settings', 'mxchat'); ?></a>
158 </p>
159 </div>
160 <script>
161 (function(){
162 var n = document.querySelector('.mxchat-telegram-secret-notice');
163 if (!n) return;
164 n.addEventListener('click', function(e){
165 if (e.target && e.target.classList.contains('notice-dismiss')) {
166 var d = new FormData();
167 d.append('action', 'mxchat_dismiss_telegram_secret_notice');
168 d.append('nonce', '<?php echo esc_js(wp_create_nonce('mxchat_dismiss_telegram_secret')); ?>');
169 fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', { method: 'POST', body: d, credentials: 'same-origin' });
170 }
171 });
172 })();
173 </script>
174 <?php
175 }
176
177 /**
178 * AJAX: persist dismissal of the Telegram-secret admin notice (plan-0c17b5).
179 */
180 public function mxchat_dismiss_telegram_secret_notice_ajax() {
181 if (!current_user_can('manage_options')
182 || !check_ajax_referer('mxchat_dismiss_telegram_secret', 'nonce', false)) {
183 wp_send_json_error();
184 }
185 update_option('mxchat_dismissed_telegram_secret_notice', '1', 'no');
186 wp_send_json_success();
187 }
188
189 /**
190 * 3.2.3: Preflight check before allowing the embedding model dropdown to
191 * switch. Pure option comparison — no counts, no DB queries beyond the
192 * cached options. The dialog is shown whenever the user has previously
193 * embedded with a different model than the one they're switching to.
194 */
195 public function mxchat_check_embedding_switch_ajax() {
196 check_ajax_referer('mxchat_admin_nonce', 'security');
197 if (!current_user_can('manage_options')) {
198 wp_send_json_error(__('Unauthorized', 'mxchat'));
199 }
200
201 $new_model = isset($_POST['new_model']) ? sanitize_text_field(wp_unslash($_POST['new_model'])) : '';
202 $active_model = MxChat_Utils::get_active_embedding_model();
203
204 $is_mismatch = !empty($active_model) && !empty($new_model) && $active_model !== $new_model;
205
206 $active_dims = MxChat_Utils::embedding_model_dimensions($active_model);
207 $new_dims = MxChat_Utils::embedding_model_dimensions($new_model);
208
209 wp_send_json_success(array(
210 'is_mismatch' => $is_mismatch,
211 'active_model' => $active_model,
212 'active_label' => MxChat_Utils::embedding_model_label($active_model),
213 'new_model' => $new_model,
214 'new_label' => MxChat_Utils::embedding_model_label($new_model),
215 'dims_differ' => ($active_dims > 0 && $new_dims > 0 && $active_dims !== $new_dims),
216 'active_dims' => $active_dims,
217 'new_dims' => $new_dims,
218 ));
219 }
220
221 /**
222 * 3.2.3: Dismiss the persistent mismatch banner. Tied to the active+selected
223 * pair so the banner reappears on the next switch event.
224 */
225 public function mxchat_dismiss_embedding_mismatch_ajax() {
226 check_ajax_referer('mxchat_admin_nonce', 'security');
227 if (!current_user_can('manage_options')) {
228 wp_send_json_error(__('Unauthorized', 'mxchat'));
229 }
230
231 $options = get_option('mxchat_options', array());
232 $selected = $options['embedding_model'] ?? '';
233 $active = MxChat_Utils::get_active_embedding_model();
234 update_option('mxchat_dismissed_embedding_mismatch', $active . '|' . $selected, false);
235 wp_send_json_success();
236 }
237
238 /**
239 * 3.2.3: Persistent admin banner shown whenever the active embedding model
240 * (last used to actually embed something) differs from the currently
241 * selected model. Pure option comparison — no DB queries on every page
242 * load. The banner auto-clears once both match again, i.e. after a delete
243 * + re-embed cycle.
244 */
245 public function mxchat_embedding_mismatch_notice() {
246 if (!current_user_can('manage_options')) {
247 return;
248 }
249
250 $options = get_option('mxchat_options', array());
251 $selected = $options['embedding_model'] ?? '';
252 $active = MxChat_Utils::get_active_embedding_model();
253
254 if (empty($active) || empty($selected) || $active === $selected) {
255 return;
256 }
257
258 $dismissed = get_option('mxchat_dismissed_embedding_mismatch', '');
259 if ($dismissed === $active . '|' . $selected) {
260 return;
261 }
262
263 $active_label = MxChat_Utils::embedding_model_label($active);
264 $selected_label = MxChat_Utils::embedding_model_label($selected);
265 $active_dims = MxChat_Utils::embedding_model_dimensions($active);
266 $selected_dims = MxChat_Utils::embedding_model_dimensions($selected);
267 $dims_differ = ($active_dims > 0 && $selected_dims > 0 && $active_dims !== $selected_dims);
268
269 $kb_url = admin_url('admin.php?page=mxchat-prompts');
270 $actions_url = admin_url('admin.php?page=mxchat-actions');
271
272 ?>
273 <div class="notice notice-error is-dismissible mxchat-embedding-mismatch-notice"
274 data-active="<?php echo esc_attr($active); ?>"
275 data-selected="<?php echo esc_attr($selected); ?>">
276 <p><strong><?php esc_html_e('MxChat: Embedding model mismatch detected', 'mxchat'); ?></strong></p>
277 <p>
278 <?php
279 printf(
280 /* translators: 1: previously-used model name, 2: currently-selected model name */
281 esc_html__('Your knowledge base and actions were embedded with %1$s, but %2$s is now selected. Similarity matching will return inaccurate or empty results until you delete all existing embeddings and re-embed your content with the new model.', 'mxchat'),
282 '<code>' . esc_html($active_label) . '</code>',
283 '<code>' . esc_html($selected_label) . '</code>'
284 );
285 ?>
286 </p>
287 <?php if ($dims_differ) : ?>
288 <p>
289 <strong><?php esc_html_e('Dimension mismatch:', 'mxchat'); ?></strong>
290 <?php
291 printf(
292 /* translators: 1: old dim count, 2: new dim count */
293 esc_html__('Existing vectors are %1$d-dimensional but the new model produces %2$d-dimensional vectors. If you use Pinecone, your index will reject queries entirely until re-embedded.', 'mxchat'),
294 (int) $active_dims,
295 (int) $selected_dims
296 );
297 ?>
298 </p>
299 <?php endif; ?>
300 <p>
301 <?php esc_html_e('To fix this:', 'mxchat'); ?>
302 <a href="<?php echo esc_url($kb_url); ?>"><?php esc_html_e('Delete all knowledge base entries', 'mxchat'); ?></a> ·
303 <a href="<?php echo esc_url($actions_url); ?>"><?php esc_html_e('Delete all actions', 'mxchat'); ?></a> ·
304 <?php esc_html_e('then re-import / re-add them with the new model selected.', 'mxchat'); ?>
305 </p>
306 </div>
307 <script>
308 (function($){
309 $(document).on('click', '.mxchat-embedding-mismatch-notice .notice-dismiss', function(){
310 $.post(ajaxurl, {
311 action: 'mxchat_dismiss_embedding_mismatch',
312 security: '<?php echo esc_js(wp_create_nonce('mxchat_admin_nonce')); ?>'
313 });
314 });
315 })(jQuery);
316 </script>
317 <?php
318 }
319
320 private function is_license_active() {
321 $license_status = get_option('mxchat_license_status', 'inactive');
322 return ($license_status === 'active');
323 }
324
325 private function initialize_default_options() {
326 $default_options = array(
327 'api_key' => '',
328 'xai_api_key' => '',
329 'claude_api_key' => '',
330 'deepseek_api_key' => '',
331 'voyage_api_key' => '',
332 'gemini_api_key' => '',
333 'enable_streaming_toggle' => 'off',
334 'enable_web_search' => 'off',
335 'embedding_model' => 'text-embedding-ada-002',
336 'system_prompt_instructions' => 'You are an AI Chatbot assistant for this website. Your main goal is to assist visitors with questions and provide helpful information. Here are your key guidelines:
337
338 # Response Style - CRITICALLY IMPORTANT
339 - MAXIMUM LENGTH: 1-3 short sentences per response
340 - Ultra-concise: Get straight to the answer with no filler
341 - No introductions like "Sure!" or "I\'d be happy to help"
342 - No phrases like "based on my knowledge" or "according to information"
343 - No explanatory text before giving the answer
344 - No summaries or repetition
345 - Hyperlink all URLs
346 - Respond in user\'s language
347 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
348
349 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
350 - ONLY answer questions using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
351 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
352 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
353 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
354 - Better to admit insufficient information than provide inaccurate answers',
355 'model' => esc_html__('gpt-5.6-sol', 'mxchat'),
356 'rate_limit_logged_out' => esc_html__('100', 'mxchat'),
357 'role_rate_limits' => array(),
358 'rate_limit_message' => esc_html__('Rate limit exceeded. Please try again later.', 'mxchat'),
359 'enable_email_block' => '',
360 'email_blocker_header_content' => __("<h2>Welcome to Our Chat!</h2>\n<p>Let's get started. Enter your email to begin chatting with us.</p>", 'mxchat'),
361 'email_blocker_button_text' => esc_html__('Start Chat', 'mxchat'),
362 'enable_name_field' => 'off', // NEW
363 'name_field_placeholder' => esc_html__('Enter your name', 'mxchat'), // NEW
364 'top_bar_title' => esc_html__('MxChat', 'mxchat'),
365 'intro_message' => __('Hello! How can I assist you today?', 'mxchat'),
366 'ai_agent_text' => esc_html__('AI Agent', 'mxchat'),
367 'input_copy' => esc_html__('How can I assist?', 'mxchat'),
368 'append_to_body' => esc_html__('off', 'mxchat'),
369 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude'
370 'post_type_visibility_list' => array(), // Array of post type slugs
371 'contextual_awareness_toggle' => 'off',
372 'citation_links_toggle' => 'on',
373 'satisfaction_rating_enabled' => 'off',
374 'satisfaction_rating_idle_seconds' => 60,
375 'satisfaction_rating_question' => '',
376 'satisfaction_rating_thanks' => '',
377 'satisfaction_rating_placeholder' => '',
378 'satisfaction_rating_saved' => '',
379 'close_button_color' => esc_html__('#fff', 'mxchat'),
380 'chatbot_bg_color' => esc_html__('#fff', 'mxchat'),
381 'user_message_bg_color' => esc_html__('#fff', 'mxchat'),
382 'user_message_font_color' => esc_html__('#212121', 'mxchat'),
383 'bot_message_bg_color' => esc_html__('#212121', 'mxchat'),
384 'bot_message_font_color' => esc_html__('#fff', 'mxchat'),
385 'top_bar_bg_color' => esc_html__('#212121', 'mxchat'),
386 'send_button_font_color' => esc_html__('#212121', 'mxchat'),
387 'chat_input_font_color' => esc_html__('#212121', 'mxchat'),
388 'chatbot_background_color' => esc_html__('#212121', 'mxchat'),
389 'icon_color' => esc_html__('#fff', 'mxchat'),
390 'enable_woocommerce_integration' => esc_html__('0', 'mxchat'),
391 'link_target_toggle' => esc_html__('off', 'mxchat'),
392 'pre_chat_message' => esc_html__('Hey there! Ask me anything!', 'mxchat'),
393
394 // New fields for Loops Integration
395 'loops_api_key' => '',
396 'loops_mailing_list' => '',
397 'triggered_phrase_response' => __('Would you like to join our mailing list? Please provide your email below.', 'mxchat'),
398 'email_capture_response' => __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat'),
399 'popular_question_1' => '',
400 'popular_question_2' => '',
401 'popular_question_3' => '',
402 'pdf_intent_trigger_text' => __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat'),
403 'pdf_intent_success_text' => __("I've processed the PDF. What questions do you have about it?", 'mxchat'),
404 'pdf_intent_error_text' => __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat'),
405 'pdf_max_pages' => 69,
406 'show_pdf_upload_button' => 'on',
407 'show_word_upload_button' => 'on',
408
409 // Live Agent Integration (Slack)
410 'live_agent_webhook_url' => '',
411 'live_agent_secret_key' => '',
412 'live_agent_bot_token' => '',
413 'live_agent_message_bg_color' => esc_html__('#ffffff', 'mxchat'),
414 'live_agent_message_font_color' => esc_html__('#333333', 'mxchat'),
415
416 // Telegram Integration
417 'telegram_status' => 'off',
418 'telegram_bot_token' => '',
419 'telegram_group_id' => '',
420 'telegram_webhook_secret' => '',
421 'telegram_notification_message' => __("I've notified a support agent. Please allow a moment for them to respond. If you'd like to continue with AI, just type \"Switch to AI\" at any time.", 'mxchat'),
422 'telegram_away_message' => __("I just checked, and it looks like our support team isn't personally available at the moment. If you'd like, you can leave your email address, and they'll get back to you as soon as possible. In the meantime, you can keep chatting with me — just let me know how I can help!", 'mxchat'),
423
424 'chat_toolbar_toggle' => esc_html__('off', 'mxchat'),
425 'mode_indicator_bg_color' => esc_html__('#767676', 'mxchat'),
426 'mode_indicator_font_color' => esc_html__('#ffffff', 'mxchat'),
427 'toolbar_icon_color' => esc_html__('#212121', 'mxchat'),
428
429 // Optimization settings
430 'script_loading_strategy' => 'default',
431
432 // Debug settings
433 'debug_mode' => 'off',
434 );
435
436
437 // Merge existing options with defaults
438 $existing_options = get_option('mxchat_options', array());
439 $merged_options = wp_parse_args($existing_options, $default_options);
440
441 // Update the options if they have changed
442 if ($existing_options !== $merged_options) {
443 update_option('mxchat_options', $merged_options);
444 }
445
446 // Add default limits for each role
447 $roles = wp_roles()->get_names();
448 foreach ($roles as $role_id => $role_name) {
449 $default_options['role_rate_limits'][$role_id] = esc_html__('100', 'mxchat');
450 }
451
452 return $default_options;
453
454 // Update the $this->options property
455 $this->options = $merged_options;
456 }
457
458 public function mxchat_add_plugin_page() {
459 // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file).
460 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
461
462 // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu
463 // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now
464 // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding).
465 add_menu_page(
466 esc_html__('MxChat', 'mxchat'),
467 esc_html__('MxChat', 'mxchat'),
468 'manage_options',
469 'mxchat-max',
470 array($this, 'mxchat_create_dashboard_page'),
471 'dashicons-testimonial',
472 6
473 );
474
475 // Onboarding submenu — first child under MxChat (plan-d14e89).
476 // First registration uses menu_slug === parent slug 'mxchat-max' →
477 // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the
478 // first child shows as "Onboarding" instead of a redundant pair.
479 add_submenu_page(
480 'mxchat-max',
481 esc_html__('MxChat Onboarding', 'mxchat'),
482 esc_html__('Onboarding', 'mxchat'),
483 'manage_options',
484 'mxchat-max',
485 array($this, 'mxchat_create_dashboard_page')
486 );
487 // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link
488 // + legacy redirects still target this slug). Parent === null keeps it
489 // out of the menu while remaining accessible by URL.
490 add_submenu_page(
491 null,
492 esc_html__('MxChat Onboarding', 'mxchat'),
493 esc_html__('Onboarding', 'mxchat'),
494 'manage_options',
495 'mxchat-onboarding',
496 array($this, 'mxchat_create_dashboard_page')
497 );
498
499 // Settings submenu — same callback as before, just at a new slug.
500 add_submenu_page(
501 'mxchat-max',
502 esc_html__('MxChat Settings', 'mxchat'),
503 esc_html__('Settings', 'mxchat'),
504 'manage_options',
505 'mxchat-settings',
506 array($this, 'mxchat_create_admin_page')
507 );
508
509 // Submenu page for Knowledge
510 add_submenu_page(
511 'mxchat-max',
512 esc_html__('Prompts', 'mxchat'),
513 esc_html__('Knowledge', 'mxchat'),
514 'manage_options',
515 'mxchat-prompts',
516 array($this, 'mxchat_create_prompts_page')
517 );
518
519 add_submenu_page(
520 'mxchat-max',
521 esc_html__('Chat Transcripts', 'mxchat'),
522 esc_html__('Transcripts', 'mxchat'),
523 'manage_options',
524 'mxchat-transcripts',
525 array($this, 'mxchat_create_transcripts_page')
526 );
527
528 add_submenu_page(
529 'mxchat-max',
530 esc_html__('MxChat Actions', 'mxchat'),
531 esc_html__('Actions', 'mxchat'),
532 'manage_options',
533 'mxchat-actions',
534 array($this, 'mxchat_actions_page_html')
535 );
536
537 // Content Generator page
538 add_submenu_page(
539 'mxchat-max',
540 esc_html__('Content', 'mxchat'),
541 esc_html__('Content', 'mxchat'),
542 'manage_options',
543 'mxchat-content',
544 array($this, 'mxchat_create_content_page')
545 );
546
547 }
548
549 /**
550 * Register the Pro & Extensions submenu on a later admin_menu priority so it
551 * always renders as the bottom-most item in the MxChat sidebar — below
552 * configuration pages like API Access (priority 20).
553 */
554 public function mxchat_add_pro_extensions_page() {
555 add_submenu_page(
556 'mxchat-max',
557 esc_html__('Pro & Extensions', 'mxchat'),
558 esc_html__('Pro & Extensions', 'mxchat'),
559 'manage_options',
560 'mxchat-activation',
561 array($this, 'mxchat_create_activation_page')
562 );
563 }
564
565 public function mxchat_create_addons_page() {
566 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
567 $addons_page = new MxChat_Addons();
568 $addons_page->render_page();
569 }
570
571 /**
572 * Render the Content Generator admin page
573 */
574 public function mxchat_create_content_page() {
575 require_once plugin_dir_path(__FILE__) . 'admin-content-page.php';
576 mxchat_render_content_page($this);
577 }
578
579 /**
580 * Test actual streaming functionality in the WordPress environment
581 */
582 public function mxchat_handle_test_streaming_actual() {
583 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
584
585 // A nonce is not authorization (plan-mxchat-20260731-c63fb6). This handler
586 // reads the site's provider keys and spends them on an outbound call, so it
587 // is billable abuse for any logged-in user who obtained the nonce.
588 if (!current_user_can('manage_options')) {
589 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
590 }
591
592 // Check if headers have already been sent
593 if (headers_sent()) {
594 wp_send_json_error(['message' => 'Headers already sent - streaming not possible']);
595 return;
596 }
597
598 // Check for required functions
599 if (!function_exists('curl_init')) {
600 wp_send_json_error(['message' => 'cURL not available - streaming requires cURL']);
601 return;
602 }
603
604 // Get user's selected model and API key
605 $options = get_option('mxchat_options', []);
606 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
607
608 // Get the provider from the model
609 $model_parts = explode('-', $selected_model);
610 $provider = strtolower($model_parts[0]);
611
612 // Get the appropriate API key
613 $api_key = '';
614 switch ($provider) {
615 case 'gpt':
616 case 'o1':
617 $api_key = $options['api_key'] ?? '';
618 break;
619 case 'claude':
620 $api_key = $options['claude_api_key'] ?? '';
621 break;
622 case 'grok':
623 $api_key = $options['xai_api_key'] ?? '';
624 break;
625 case 'deepseek':
626 $api_key = $options['deepseek_api_key'] ?? '';
627 break;
628 case 'gemini':
629 $api_key = $options['gemini_api_key'] ?? '';
630 break;
631 default:
632 // Default to OpenAI for unknown models
633 $api_key = $options['api_key'] ?? '';
634 $provider = 'gpt';
635 break;
636 }
637
638 if (empty($api_key)) {
639 wp_send_json_error(['message' => "API key not configured for {$provider} provider"]);
640 return;
641 }
642
643 // Test streaming with the selected model and provider
644 try {
645 $this->perform_streaming_test($provider, $selected_model, $api_key);
646 } catch (Exception $e) {
647 wp_send_json_error(['message' => 'Streaming test exception: ' . $e->getMessage()]);
648 }
649 }
650
651 /**
652 * Perform the actual streaming test
653 */
654 private function perform_streaming_test($provider, $model, $api_key) {
655 // Set streaming headers
656 header('Content-Type: text/event-stream');
657 header('Cache-Control: no-cache');
658 header('X-Accel-Buffering: no'); // Disable nginx buffering
659
660 // Prepare test message
661 $test_message = "Please respond with exactly: 'Streaming test successful!' - send this as a short response for testing.";
662
663 // Configure API request based on provider
664 $url = '';
665 $headers = [];
666 $body = [];
667
668 switch ($provider) {
669 case 'gpt':
670 case 'o1':
671 $url = 'https://api.openai.com/v1/chat/completions';
672 $headers = [
673 'Content-Type: application/json',
674 'Authorization: Bearer ' . $api_key
675 ];
676 $body = [
677 'model' => $model,
678 'messages' => [['role' => 'user', 'content' => $test_message]],
679 'max_tokens' => 50,
680 'temperature' => 0.3,
681 'stream' => true
682 ];
683 break;
684
685 case 'claude':
686 $url = 'https://api.anthropic.com/v1/messages';
687 $headers = [
688 'Content-Type: application/json',
689 'x-api-key: ' . $api_key,
690 'anthropic-version: 2023-06-01'
691 ];
692 $body = [
693 'model' => $model,
694 'messages' => [['role' => 'user', 'content' => $test_message]],
695 'max_tokens' => 50,
696 'temperature' => 0.3,
697 'stream' => true
698 ];
699 // Claude flagships from Opus 4.7 onward reject the temperature
700 // param outright (400). Reuse the catalog's decision — this path
701 // previously sent temperature unconditionally, so the streaming
702 // test was broken for Opus 4.7/4.8, Fable 5 and Sonnet 5.
703 if (class_exists('MxChat_Model_Catalog')
704 && method_exists('MxChat_Model_Catalog', 'supports_temperature')
705 && !MxChat_Model_Catalog::supports_temperature($model)) {
706 unset($body['temperature']);
707 }
708 break;
709
710 case 'grok':
711 $url = 'https://api.x.ai/v1/chat/completions';
712 $headers = [
713 'Content-Type: application/json',
714 'Authorization: Bearer ' . $api_key
715 ];
716 $body = [
717 'model' => $model,
718 'messages' => [['role' => 'user', 'content' => $test_message]],
719 'max_tokens' => 50,
720 'temperature' => 0.3,
721 'stream' => true
722 ];
723 break;
724
725 case 'deepseek':
726 $url = 'https://api.deepseek.com/v1/chat/completions';
727 $headers = [
728 'Content-Type: application/json',
729 'Authorization: Bearer ' . $api_key
730 ];
731 $body = [
732 'model' => $model,
733 'messages' => [['role' => 'user', 'content' => $test_message]],
734 'max_tokens' => 50,
735 'temperature' => 0.3,
736 'stream' => true,
737 // DeepSeek V4 defaults to thinking mode ON — reasoning would
738 // consume the 50-token test budget and return no visible text.
739 'thinking' => ['type' => 'disabled']
740 ];
741 break;
742
743 default:
744 echo "data: " . json_encode(['error' => 'Unsupported provider for streaming test: ' . $provider]) . "\n\n";
745 flush();
746 return;
747 }
748
749 // Initialize cURL
750 $ch = curl_init();
751 curl_setopt($ch, CURLOPT_URL, $url);
752 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
753 curl_setopt($ch, CURLOPT_POST, true);
754 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
755 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
756 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
757 curl_setopt($ch, CURLOPT_TIMEOUT, 30);
758 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($provider) {
759 return $this->process_streaming_test_data($data, $provider);
760 });
761
762 // Send initial test message
763 echo "data: " . json_encode(['content' => '[Starting streaming test...]']) . "\n\n";
764 flush();
765
766 $result = curl_exec($ch);
767 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
768 $curl_error = curl_error($ch);
769 curl_close($ch);
770
771 if ($curl_error) {
772 echo "data: " . json_encode(['error' => 'cURL Error: ' . $curl_error]) . "\n\n";
773 flush();
774 return;
775 }
776
777 if ($http_code !== 200) {
778 echo "data: " . json_encode(['error' => 'API returned HTTP ' . $http_code]) . "\n\n";
779 flush();
780 return;
781 }
782
783 // Send completion signal
784 echo "data: [DONE]\n\n";
785 flush();
786 }
787
788 /**
789 * Process streaming data for the test
790 */
791 private function process_streaming_test_data($data, $provider) {
792 static $chunk_count = 0;
793
794 $lines = explode("\n", $data);
795
796 foreach ($lines as $line) {
797 if (trim($line) === '') {
798 continue;
799 }
800
801 // Handle different provider formats
802 if ($provider === 'claude') {
803 // Claude uses event: and data: format
804 if (strpos($line, 'data: ') === 0) {
805 $json_str = substr($line, 6);
806 $json = json_decode($json_str, true);
807
808 if (isset($json['type']) && $json['type'] === 'content_block_delta') {
809 if (isset($json['delta']['text'])) {
810 $chunk_count++;
811 echo "data: " . json_encode([
812 'content' => $json['delta']['text'],
813 'test_chunk' => $chunk_count
814 ]) . "\n\n";
815 flush();
816 }
817 }
818 }
819 } else {
820 // OpenAI, X.AI, DeepSeek format
821 if (strpos($line, 'data: ') === 0) {
822 $json_str = substr($line, 6);
823
824 if ($json_str === '[DONE]') {
825 // Don't echo [DONE] here, let the main function handle it
826 continue;
827 }
828
829 $json = json_decode($json_str, true);
830 if (isset($json['choices'][0]['delta']['content'])) {
831 $chunk_count++;
832 echo "data: " . json_encode([
833 'content' => $json['choices'][0]['delta']['content'],
834 'test_chunk' => $chunk_count
835 ]) . "\n\n";
836 flush();
837 }
838 }
839 }
840 }
841
842 return strlen($data);
843 }
844
845 /**
846 * Updated version of your existing test method (keep this as a fallback)
847 */
848 public function mxchat_handle_test_streaming() {
849 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
850
851 // plan-mxchat-20260731-c63fb6 — the delegate below checks this too, but
852 // fail here so the alias never becomes a bypass if that call is refactored.
853 if (!current_user_can('manage_options')) {
854 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
855 }
856
857 // Use the actual streaming test instead
858 $this->mxchat_handle_test_streaming_actual();
859 }
860
861
862 public function register_pinecone_settings() {
863 register_setting(
864 'mxchat_pinecone_addon_options',
865 'mxchat_pinecone_addon_options',
866 array(
867 'type' => 'array',
868 'sanitize_callback' => array($this, 'sanitize_pinecone_settings'),
869 'default' => array(
870 'mxchat_use_pinecone' => '0',
871 'mxchat_pinecone_api_key' => '',
872 'mxchat_pinecone_host' => '',
873 'mxchat_pinecone_index' => '',
874 'mxchat_pinecone_environment' => ''
875 )
876 )
877 );
878 }
879
880 public function sanitize_pinecone_settings($input) {
881 $sanitized = array();
882
883 $sanitized['mxchat_use_pinecone'] = isset($input['mxchat_use_pinecone']) ? '1' : '0';
884 $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
885 $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
886 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
887 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
888
889 // Remove https:// from host if present
890 $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']);
891
892 return $sanitized;
893 }
894
895 public function register_openai_vectorstore_settings() {
896 register_setting(
897 'mxchat_openai_vectorstore_options',
898 'mxchat_openai_vectorstore_options',
899 array(
900 'type' => 'array',
901 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'),
902 'default' => array(
903 'mxchat_use_openai_vectorstore' => '0',
904 'mxchat_vectorstore_ids' => '',
905 'mxchat_vectorstore_max_results' => 5
906 )
907 )
908 );
909 }
910
911 public function sanitize_openai_vectorstore_settings($input) {
912 $sanitized = array();
913
914 $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0';
915 $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? '');
916 $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5);
917
918 // Ensure max results is within reasonable range
919 if ($sanitized['mxchat_vectorstore_max_results'] < 1) {
920 $sanitized['mxchat_vectorstore_max_results'] = 1;
921 }
922 if ($sanitized['mxchat_vectorstore_max_results'] > 20) {
923 $sanitized['mxchat_vectorstore_max_results'] = 20;
924 }
925
926 return $sanitized;
927 }
928
929 /**
930 * AJAX handler to test OpenAI Vector Store connection
931 */
932 public function mxchat_test_vectorstore_connection() {
933 check_ajax_referer('mxchat_admin_nonce', 'nonce');
934
935 if (!current_user_can('manage_options')) {
936 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
937 return;
938 }
939
940 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
941 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
942 $mxchat_options = get_option('mxchat_options', array());
943 $api_key = $mxchat_options['api_key'] ?? '';
944
945 if (empty($api_key)) {
946 wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat')));
947 return;
948 }
949
950 if (empty($vectorstore_ids)) {
951 wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat')));
952 return;
953 }
954
955 // Get the first Vector Store ID for testing
956 $ids_array = array_map('trim', explode(',', $vectorstore_ids));
957 $test_id = $ids_array[0];
958
959 // Test by retrieving the Vector Store info
960 $response = wp_remote_get(
961 'https://api.openai.com/v1/vector_stores/' . $test_id,
962 array(
963 'headers' => array(
964 'Authorization' => 'Bearer ' . $api_key,
965 'Content-Type' => 'application/json',
966 'OpenAI-Beta' => 'assistants=v2'
967 ),
968 'timeout' => 30
969 )
970 );
971
972 if (is_wp_error($response)) {
973 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message()));
974 return;
975 }
976
977 $status_code = wp_remote_retrieve_response_code($response);
978 $body = json_decode(wp_remote_retrieve_body($response), true);
979
980 if ($status_code === 200 && isset($body['id'])) {
981 $file_count = $body['file_counts']['completed'] ?? 0;
982 $name = $body['name'] ?? $test_id;
983 wp_send_json_success(array(
984 'message' => sprintf(
985 __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'),
986 esc_html($name),
987 $file_count
988 )
989 ));
990 } elseif ($status_code === 404) {
991 wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat')));
992 } elseif ($status_code === 401) {
993 wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat')));
994 } else {
995 $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat');
996 wp_send_json_error(array('message' => $error_message));
997 }
998 }
999
1000 /**
1001 * AJAX handler to test Slack connection and validate scopes
1002 */
1003 public function mxchat_test_slack_connection() {
1004 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1005
1006 if (!current_user_can('manage_options')) {
1007 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1008 return;
1009 }
1010
1011 $bot_token = $this->options['live_agent_bot_token'] ?? '';
1012
1013 if (empty($bot_token)) {
1014 wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat')));
1015 return;
1016 }
1017
1018 // Test 1: Validate bot token with auth.test
1019 $auth_response = wp_remote_post('https://slack.com/api/auth.test', array(
1020 'headers' => array(
1021 'Authorization' => 'Bearer ' . $bot_token,
1022 'Content-Type' => 'application/json'
1023 ),
1024 'timeout' => 15
1025 ));
1026
1027 if (is_wp_error($auth_response)) {
1028 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message()));
1029 return;
1030 }
1031
1032 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1033
1034 if (!isset($auth_body['ok']) || !$auth_body['ok']) {
1035 $error = $auth_body['error'] ?? 'unknown_error';
1036 $error_messages = array(
1037 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'),
1038 'not_authed' => __('No authentication token provided.', 'mxchat'),
1039 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'),
1040 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'),
1041 );
1042 $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error);
1043 wp_send_json_error(array('message' => $message));
1044 return;
1045 }
1046
1047 $team_name = $auth_body['team'] ?? 'Unknown Workspace';
1048 $bot_name = $auth_body['user'] ?? 'Unknown Bot';
1049
1050 // Test 2: Check if we can list channels (tests channels:read scope)
1051 $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array(
1052 'headers' => array(
1053 'Authorization' => 'Bearer ' . $bot_token,
1054 'Content-Type' => 'application/json'
1055 ),
1056 'body' => json_encode(array('limit' => 1)),
1057 'timeout' => 15
1058 ));
1059
1060 $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true);
1061 $can_read_channels = isset($channels_body['ok']) && $channels_body['ok'];
1062
1063 // Test 3: Check if we can create channels (tests channels:manage scope)
1064 // We'll just check the error message without actually creating
1065 $create_response = wp_remote_post('https://slack.com/api/conversations.create', array(
1066 'headers' => array(
1067 'Authorization' => 'Bearer ' . $bot_token,
1068 'Content-Type' => 'application/json'
1069 ),
1070 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)),
1071 'timeout' => 15
1072 ));
1073
1074 $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
1075
1076 // If channel was created, delete it immediately
1077 if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) {
1078 wp_remote_post('https://slack.com/api/conversations.archive', array(
1079 'headers' => array(
1080 'Authorization' => 'Bearer ' . $bot_token,
1081 'Content-Type' => 'application/json'
1082 ),
1083 'body' => json_encode(array('channel' => $create_body['channel']['id'])),
1084 'timeout' => 15
1085 ));
1086 $can_create_channels = true;
1087 } else {
1088 $create_error = $create_body['error'] ?? '';
1089 // name_taken means we have permission but channel exists
1090 $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok']));
1091
1092 // Check for missing scope errors
1093 if ($create_error === 'missing_scope') {
1094 $can_create_channels = false;
1095 }
1096 }
1097
1098 // Build result message
1099 $results = array();
1100 $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name));
1101 $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name));
1102 $results[] = '';
1103 $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat');
1104 $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat');
1105
1106 $missing_scopes = array();
1107 if (!$can_read_channels) $missing_scopes[] = 'channels:read';
1108 if (!$can_create_channels) $missing_scopes[] = 'channels:manage';
1109
1110 if (!empty($missing_scopes)) {
1111 wp_send_json_error(array(
1112 'message' => implode("\n", $results),
1113 'missing_scopes' => $missing_scopes,
1114 'partial' => true
1115 ));
1116 } else {
1117 wp_send_json_success(array(
1118 'message' => implode("\n", $results)
1119 ));
1120 }
1121 }
1122
1123 public function mxchat_display_admin_notice() {
1124 // Success notice
1125 if ($message = get_transient('mxchat_admin_notice_success')) {
1126 ?>
1127 <div class="notice notice-success is-dismissible">
1128 <p><?php echo esc_html($message); ?></p>
1129 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1130 </div>
1131 <?php
1132 delete_transient('mxchat_admin_notice_success'); // Clear the transient after displaying
1133 }
1134
1135 // Error notice
1136 if ($message = get_transient('mxchat_admin_notice_error')) {
1137 ?>
1138 <div class="notice notice-error is-dismissible">
1139 <p><?php echo esc_html($message); ?></p>
1140 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1141 </div>
1142 <?php
1143 delete_transient('mxchat_admin_notice_error'); // Clear the transient after displaying
1144 }
1145 }
1146
1147 public function show_live_agent_disabled_banner() {
1148 $show_disabled_notice = get_option('mxchat_show_live_agent_disabled_notice', false);
1149
1150 if ($show_disabled_notice) {
1151 ?>
1152 <div class="mxchat-live-agent-disabled-notice" id="mxchat-disabled-notice">
1153 <div class="mxchat-pro-notification">
1154 <button type="button" class="mxchat-dismiss-btn" onclick="dismissLiveAgentNotice()" aria-label="Dismiss notification">
1155 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1156 <line x1="18" y1="6" x2="6" y2="18"></line>
1157 <line x1="6" y1="6" x2="18" y2="18"></line>
1158 </svg>
1159 </button>
1160 <div class="mxchat-live-agent-content">
1161 <h3>🔧 Live Agent Integration Updated!</h3>
1162 <p>We've temporarily disabled your Live Agent integration due to recent enhancements that have made it much better! You can easily turn it back on by going to <strong>Toolbar & Components → Live Agent Settings</strong> and reviewing the new configuration options.</p>
1163 </div>
1164 </div>
1165 </div>
1166 <?php
1167 }
1168 }
1169 /**
1170 * Render the Onboarding page. Delegates to the procedural renderer in
1171 * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1172 * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1173 * Onboarding submenu).
1174 *
1175 * When the user has dismissed onboarding and lands on `mxchat-max` (the
1176 * legacy URL, since the Onboarding submenu has been removed), redirect to
1177 * Settings instead — the page is "graduated" and we shouldn't dump them
1178 * back onto it. They can still navigate here directly via the unhide link.
1179 */
1180 public function mxchat_create_dashboard_page() {
1181 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1182
1183 $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1184 if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1185 wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1186 exit;
1187 }
1188
1189 if (function_exists('mxchat_render_onboarding_page')) {
1190 mxchat_render_onboarding_page();
1191 return;
1192 }
1193 // Defensive: if the include failed to load, fall back to the old Settings page
1194 // so the top-level menu never lands on an empty screen.
1195 $this->mxchat_create_admin_page();
1196 }
1197
1198 /**
1199 * Hide the Onboarding submenu when the user has dismissed it (either
1200 * manually or via auto-graduation). The page itself remains routable so
1201 * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1202 */
1203 public function mxchat_apply_onboarding_visibility() {
1204 if (!function_exists('mxchat_onboarding_is_dismissed')) {
1205 return;
1206 }
1207 if (mxchat_onboarding_is_dismissed()) {
1208 // The first MxChat child is the same-slug-as-parent registration
1209 // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1210 // Remove it so the menu opens straight to Settings after dismiss.
1211 remove_submenu_page('mxchat-max', 'mxchat-max');
1212 }
1213 }
1214
1215 public function mxchat_create_admin_page() {
1216 $this->add_live_agent_nonce();
1217 $this->add_theme_migration_nonce();
1218
1219 // Include and render the new sidebar-based settings page
1220 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
1221 mxchat_render_settings_page($this);
1222 }
1223
1224 public function dismiss_live_agent_notice() {
1225 // Add debugging
1226 //error_log('dismiss_live_agent_notice called');
1227 //error_log('POST data: ' . print_r($_POST, true));
1228
1229 // Verify nonce
1230 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) {
1231 //error_log('Nonce verification failed');
1232 wp_die('Security check failed');
1233 }
1234
1235 // Remove the notice flag
1236 $deleted = delete_option('mxchat_show_live_agent_disabled_notice');
1237 //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no'));
1238
1239 wp_send_json_success();
1240 }
1241 public function add_live_agent_nonce() {
1242 if (get_option('mxchat_show_live_agent_disabled_notice', false)) {
1243 // Make sure your admin script is enqueued and localize the data
1244 wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array(
1245 'nonce' => wp_create_nonce('dismiss_live_agent_notice'),
1246 'ajaxurl' => admin_url('admin-ajax.php')
1247 ));
1248 }
1249 }
1250
1251 /**
1252 * Show theme migration notice for Pro users with AI-generated themes
1253 * Only shown once - dismissible and stored in options
1254 */
1255 public function show_theme_migration_banner() {
1256 // Only show if Pro is activated
1257 if (!$this->is_activated) {
1258 return;
1259 }
1260
1261 // Check if notice should be shown
1262 $show_notice = get_option('mxchat_show_theme_migration_notice', false);
1263
1264 if ($show_notice) {
1265 ?>
1266 <div class="mxchat-theme-migration-notice" id="mxchat-theme-migration-notice">
1267 <div class="mxchat-pro-notification">
1268 <button type="button" class="mxchat-dismiss-btn" onclick="dismissThemeMigrationNotice()" aria-label="Dismiss notification">
1269 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1270 <line x1="18" y1="6" x2="6" y2="18"></line>
1271 <line x1="6" y1="6" x2="18" y2="18"></line>
1272 </svg>
1273 </button>
1274 <div class="mxchat-theme-migration-content">
1275 <h3>🎨 AI Theme Migration Required</h3>
1276 <p>If you're using an AI-generated chatbot theme, you'll need to migrate it to match the new CSS structure. Go to <strong>Theme Settings</strong>, select your theme from the sidebar, and click the <strong>Migrate</strong> button.</p>
1277 </div>
1278 </div>
1279 </div>
1280 <?php
1281 }
1282 }
1283
1284 /**
1285 * Dismiss theme migration notice via AJAX
1286 */
1287 public function dismiss_theme_migration_notice() {
1288 // Verify nonce
1289 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_theme_migration_notice')) {
1290 wp_die('Security check failed');
1291 }
1292
1293 // Remove the notice flag
1294 delete_option('mxchat_show_theme_migration_notice');
1295
1296 wp_send_json_success();
1297 }
1298
1299 /**
1300 * Add nonce for theme migration notice dismiss
1301 */
1302 public function add_theme_migration_nonce() {
1303 if (get_option('mxchat_show_theme_migration_notice', false) && $this->is_activated) {
1304 wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array(
1305 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'),
1306 'ajaxurl' => admin_url('admin-ajax.php')
1307 ));
1308 }
1309 }
1310
1311 public function mxchat_create_transcripts_page() {
1312 global $wpdb;
1313 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1314
1315 // Get basic stats
1316 $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0;
1317 $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0;
1318
1319 // Count unique users with detailed breakdown
1320 $total_users = $wpdb->get_var("
1321 SELECT COUNT(DISTINCT
1322 CASE
1323 WHEN user_email != '' AND user_email IS NOT NULL THEN user_email
1324 WHEN user_id != 0 THEN CONCAT('user_', user_id)
1325 WHEN user_identifier NOT LIKE 'Tech-Savvy User'
1326 AND user_identifier NOT LIKE 'Detail-Oriented User'
1327 AND user_identifier NOT LIKE 'Language Learner'
1328 AND user_identifier NOT LIKE 'Casual Browser'
1329 AND user_identifier NOT LIKE 'Policy Enforcer'
1330 AND user_identifier NOT LIKE 'Researcher'
1331 AND user_identifier NOT LIKE 'Loyalty Member'
1332 AND user_identifier NOT LIKE 'Gift Buyer'
1333 AND user_identifier NOT LIKE 'Parent or Caregiver'
1334 THEN user_identifier
1335 ELSE session_id
1336 END
1337 )
1338 FROM $table_name
1339 WHERE role != 'assistant'
1340 ");
1341
1342 // Get user type breakdown
1343 $registered_users = $wpdb->get_var("
1344 SELECT COUNT(DISTINCT user_email)
1345 FROM $table_name
1346 WHERE user_email != '' AND user_email IS NOT NULL
1347 ");
1348
1349 $guest_users = $wpdb->get_var("
1350 SELECT COUNT(DISTINCT user_identifier)
1351 FROM $table_name
1352 WHERE (user_email = '' OR user_email IS NULL)
1353 AND role != 'assistant'
1354 AND user_identifier NOT LIKE 'Tech-Savvy User'
1355 AND user_identifier NOT LIKE 'Detail-Oriented User'
1356 AND user_identifier NOT LIKE 'Language Learner'
1357 AND user_identifier NOT LIKE 'Casual Browser'
1358 AND user_identifier NOT LIKE 'Policy Enforcer'
1359 AND user_identifier NOT LIKE 'Researcher'
1360 AND user_identifier NOT LIKE 'Loyalty Member'
1361 AND user_identifier NOT LIKE 'Gift Buyer'
1362 AND user_identifier NOT LIKE 'Parent or Caregiver'
1363 ");
1364
1365 // Get agent test messages count
1366 $agent_tests = $wpdb->get_var("
1367 SELECT COUNT(DISTINCT session_id)
1368 FROM $table_name
1369 WHERE user_identifier IN (
1370 'Tech-Savvy User',
1371 'Detail-Oriented User',
1372 'Language Learner',
1373 'Casual Browser',
1374 'Policy Enforcer',
1375 'Researcher',
1376 'Loyalty Member',
1377 'Gift Buyer',
1378 'Parent or Caregiver'
1379 )
1380 ");
1381 // Get activity metrics
1382 $today_chats = $wpdb->get_var("
1383 SELECT COUNT(DISTINCT session_id)
1384 FROM $table_name
1385 WHERE DATE(timestamp) = CURDATE()
1386 ");
1387
1388 $week_chats = $wpdb->get_var("
1389 SELECT COUNT(DISTINCT session_id)
1390 FROM $table_name
1391 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1392 ");
1393
1394 $month_chats = $wpdb->get_var("
1395 SELECT COUNT(DISTINCT session_id)
1396 FROM $table_name
1397 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
1398 ");
1399
1400 // Get daily chat data for last 7 days
1401 $daily_stats = $wpdb->get_results("
1402 SELECT
1403 DATE(timestamp) as date,
1404 COUNT(DISTINCT session_id) as chats,
1405 COUNT(*) as messages
1406 FROM $table_name
1407 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1408 GROUP BY DATE(timestamp)
1409 ORDER BY date ASC
1410 ");
1411
1412 // Get average messages per chat
1413 $avg_messages = $wpdb->get_var("
1414 SELECT AVG(message_count)
1415 FROM (
1416 SELECT session_id, COUNT(*) as message_count
1417 FROM $table_name
1418 GROUP BY session_id
1419 ) as chat_counts
1420 ");
1421 $avg_messages = $avg_messages ? round($avg_messages, 1) : 0;
1422
1423 // Get busiest hour
1424 $busiest_hour = $wpdb->get_row("
1425 SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count
1426 FROM $table_name
1427 GROUP BY HOUR(timestamp)
1428 ORDER BY chat_count DESC
1429 LIMIT 1
1430 ");
1431
1432 // Prepare chart data
1433 $chart_labels = array();
1434 $chart_chats = array();
1435 $chart_messages = array();
1436
1437 // Fill last 7 days with data
1438 for ($i = 6; $i >= 0; $i--) {
1439 $date = date('Y-m-d', strtotime("-$i days"));
1440 $day_name = date('D', strtotime("-$i days"));
1441 $chart_labels[] = $day_name;
1442
1443 $found = false;
1444 foreach ($daily_stats as $stat) {
1445 if ($stat->date === $date) {
1446 $chart_chats[] = (int)$stat->chats;
1447 $chart_messages[] = (int)$stat->messages;
1448 $found = true;
1449 break;
1450 }
1451 }
1452 if (!$found) {
1453 $chart_chats[] = 0;
1454 $chart_messages[] = 0;
1455 }
1456 }
1457
1458 // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1459 $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1460
1461 // Prepare page data for the template
1462 $page_data = array(
1463 'total_chats' => $total_chats,
1464 'total_messages' => $total_messages,
1465 'total_users' => $total_users,
1466 'registered_users' => $registered_users,
1467 'guest_users' => $guest_users,
1468 'agent_tests' => $agent_tests,
1469 'today_chats' => $today_chats,
1470 'week_chats' => $week_chats,
1471 'month_chats' => $month_chats,
1472 'avg_messages' => $avg_messages,
1473 'busiest_hour' => $busiest_hour,
1474 'chart_labels' => $chart_labels,
1475 'chart_chats' => $chart_chats,
1476 'chart_messages' => $chart_messages,
1477 'satisfaction_stats' => $satisfaction_stats,
1478 );
1479
1480 // Include and render the new template
1481 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
1482 mxchat_render_transcripts_page($this, $page_data);
1483 }
1484
1485 /**
1486 * Per-bot satisfaction rating rollup over the last $days days. Used by the
1487 * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1488 *
1489 * @param int $days Window in days.
1490 * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1491 */
1492 public function get_satisfaction_rating_stats($days = 30) {
1493 global $wpdb;
1494 $table = $wpdb->prefix . 'mxchat_session_ratings';
1495 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1496 return array();
1497 }
1498 $days = max(1, (int) $days);
1499 $rows = $wpdb->get_results($wpdb->prepare(
1500 "SELECT bot_id,
1501 COUNT(*) AS total,
1502 SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1503 SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1504 FROM {$table}
1505 WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1506 GROUP BY bot_id
1507 ORDER BY total DESC",
1508 $days
1509 ));
1510 $out = array();
1511 foreach ((array) $rows as $row) {
1512 $total = (int) $row->total;
1513 $positive = (int) $row->positive;
1514 $negative = (int) $row->negative;
1515 $out[] = array(
1516 'bot_id' => $row->bot_id ?: 'default',
1517 'total' => $total,
1518 'positive' => $positive,
1519 'negative' => $negative,
1520 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1521 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1522 );
1523 }
1524 return $out;
1525 }
1526
1527 /**
1528 * Get chart data for transcripts page
1529 * Used by both page render and script localization
1530 *
1531 * @return array Chart data with labels, chats, and messages arrays
1532 */
1533 private function get_transcripts_chart_data() {
1534 global $wpdb;
1535 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1536
1537 // Get daily chat data for last 7 days - same query as mxchat_transcripts_page()
1538 $daily_stats = $wpdb->get_results("
1539 SELECT
1540 DATE(timestamp) as date,
1541 COUNT(DISTINCT session_id) as chats,
1542 COUNT(*) as messages
1543 FROM $table_name
1544 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1545 GROUP BY DATE(timestamp)
1546 ORDER BY date ASC
1547 ");
1548
1549 // Prepare chart data
1550 $chart_labels = array();
1551 $chart_chats = array();
1552 $chart_messages = array();
1553
1554 // Fill last 7 days with data - same logic as mxchat_transcripts_page()
1555 for ($i = 6; $i >= 0; $i--) {
1556 $date = date('Y-m-d', strtotime("-$i days"));
1557 $day_name = date('D', strtotime("-$i days"));
1558 $chart_labels[] = $day_name;
1559
1560 $found = false;
1561 if ($daily_stats) {
1562 foreach ($daily_stats as $stat) {
1563 if ($stat->date === $date) {
1564 $chart_chats[] = (int)$stat->chats;
1565 $chart_messages[] = (int)$stat->messages;
1566 $found = true;
1567 break;
1568 }
1569 }
1570 }
1571 if (!$found) {
1572 $chart_chats[] = 0;
1573 $chart_messages[] = 0;
1574 }
1575 }
1576
1577 return array(
1578 'labels' => $chart_labels,
1579 'chats' => $chart_chats,
1580 'messages' => $chart_messages
1581 );
1582 }
1583
1584 public function mxchat_transcripts_notification_section_callback() {
1585 echo '<p>' . esc_html__('Configure email notifications for new chat transcripts. You will receive an email notification when a new chat session begins.', 'mxchat') . '</p>';
1586 }
1587 public function mxchat_enable_notifications_callback() {
1588 $options = get_option('mxchat_transcripts_options', array());
1589 $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0;
1590 ?>
1591 <label for="mxchat_enable_notifications">
1592 <input type="checkbox" id="mxchat_enable_notifications"
1593 name="mxchat_transcripts_options[mxchat_enable_notifications]"
1594 value="1" <?php checked(1, $enabled); ?>>
1595 <?php esc_html_e('Send email notification when a new chat session starts', 'mxchat'); ?>
1596 </label>
1597 <p class="description">
1598 <?php esc_html_e('Enable this option to receive email notifications for new chat sessions.', 'mxchat'); ?>
1599 </p>
1600 <?php
1601 }
1602 public function mxchat_notification_email_callback() {
1603 $options = get_option('mxchat_transcripts_options', array());
1604 $email = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : get_option('admin_email');
1605 ?>
1606 <input type="email" id="mxchat_notification_email"
1607 name="mxchat_transcripts_options[mxchat_notification_email]"
1608 value="<?php echo esc_attr($email); ?>"
1609 class="regular-text">
1610 <p class="description">
1611 <?php esc_html_e('Enter the email address where notifications should be sent. Defaults to the admin email address.', 'mxchat'); ?>
1612 </p>
1613 <?php
1614 }
1615
1616 public function mxchat_auto_delete_transcripts_callback() {
1617 $options = get_option('mxchat_transcripts_options', array());
1618 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1619 ?>
1620 <select id="mxchat_auto_delete_transcripts"
1621 name="mxchat_transcripts_options[mxchat_auto_delete_transcripts]">
1622 <option value="never" <?php selected($interval, 'never'); ?>>
1623 <?php esc_html_e('Never (Keep All Transcripts)', 'mxchat'); ?>
1624 </option>
1625 <option value="1week" <?php selected($interval, '1week'); ?>>
1626 <?php esc_html_e('After 1 Week', 'mxchat'); ?>
1627 </option>
1628 <option value="2weeks" <?php selected($interval, '2weeks'); ?>>
1629 <?php esc_html_e('After 2 Weeks', 'mxchat'); ?>
1630 </option>
1631 <option value="1month" <?php selected($interval, '1month'); ?>>
1632 <?php esc_html_e('After 1 Month', 'mxchat'); ?>
1633 </option>
1634 </select>
1635 <p class="description">
1636 <?php esc_html_e('Automatically delete old chat transcripts after the selected time period. This helps manage database size and privacy.', 'mxchat'); ?>
1637 </p>
1638 <?php
1639 }
1640
1641 /**
1642 * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1643 * and deletes transcripts older than the given number of days. Set to 0 to fall
1644 * back to the dropdown (or "Never" if the dropdown is also Never).
1645 *
1646 * Devs can override the final day count via the `mxchat_transcript_retention_days`
1647 * filter — runs in `cleanup_old_transcripts()` after this option is read.
1648 *
1649 * (plan-mxchat-20260509-9b80b1)
1650 */
1651 public function mxchat_retention_days_callback() {
1652 $options = get_option('mxchat_transcripts_options', array());
1653 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1654 ?>
1655 <input type="number"
1656 id="mxchat_retention_days"
1657 name="mxchat_transcripts_options[mxchat_retention_days]"
1658 value="<?php echo esc_attr($days); ?>"
1659 min="0"
1660 max="3650"
1661 step="1"
1662 style="width: 90px;" />
1663 <p class="description">
1664 <?php esc_html_e('Number of days to retain transcripts. When set to a value greater than 0, this overrides the dropdown above. Set to 0 to use the dropdown. Maximum 3650 (10 years). A daily wp-cron task removes anything older, cascading to translations and click-tracking rows.', 'mxchat'); ?>
1665 <br>
1666 <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1667 <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1668 </p>
1669 <?php
1670 }
1671
1672 public function mxchat_auto_email_transcript_callback() {
1673 $options = get_option('mxchat_transcripts_options', array());
1674 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1675 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
1676 $require_contact = isset($options['mxchat_auto_email_transcript_require_contact']) ? $options['mxchat_auto_email_transcript_require_contact'] : 0;
1677 ?>
1678 <label>
1679 <input type="checkbox"
1680 id="mxchat_auto_email_transcript_enabled"
1681 name="mxchat_transcripts_options[mxchat_auto_email_transcript_enabled]"
1682 value="1"
1683 <?php checked($enabled, 1); ?>>
1684 <?php esc_html_e('Enable Auto-Email of Full Transcript', 'mxchat'); ?>
1685 </label>
1686 <br><br>
1687 <label for="mxchat_auto_email_transcript_delay">
1688 <?php esc_html_e('Send transcript after:', 'mxchat'); ?>
1689 </label>
1690 <select id="mxchat_auto_email_transcript_delay"
1691 name="mxchat_transcripts_options[mxchat_auto_email_transcript_delay]">
1692 <option value="15" <?php selected($delay, '15'); ?>>
1693 <?php esc_html_e('15 minutes', 'mxchat'); ?>
1694 </option>
1695 <option value="30" <?php selected($delay, '30'); ?>>
1696 <?php esc_html_e('30 minutes', 'mxchat'); ?>
1697 </option>
1698 <option value="60" <?php selected($delay, '60'); ?>>
1699 <?php esc_html_e('1 hour', 'mxchat'); ?>
1700 </option>
1701 </select>
1702 <br><br>
1703 <label>
1704 <input type="checkbox"
1705 id="mxchat_auto_email_transcript_require_contact"
1706 name="mxchat_transcripts_options[mxchat_auto_email_transcript_require_contact]"
1707 value="1"
1708 <?php checked($require_contact, 1); ?>>
1709 <?php esc_html_e('Only send if visitor provided contact info', 'mxchat'); ?>
1710 </label>
1711 <p class="description">
1712 <?php esc_html_e('Automatically email the full transcript as a .txt file after the specified time has passed since the last user message. The scheduled email will be cancelled if a new message is received.', 'mxchat'); ?>
1713 <br>
1714 <?php esc_html_e('When "Only send if visitor provided contact info" is enabled, transcripts will only be emailed if the visitor shared an email address or phone number (including WhatsApp) in the chat.', 'mxchat'); ?>
1715 </p>
1716 <?php
1717 }
1718
1719
1720
1721 public function sanitize_transcripts_options($input) {
1722 $sanitized = array();
1723
1724 $sanitized['mxchat_enable_notifications'] = isset($input['mxchat_enable_notifications']) ? 1 : 0;
1725
1726 if (isset($input['mxchat_notification_email'])) {
1727 $sanitized['mxchat_notification_email'] = sanitize_email($input['mxchat_notification_email']);
1728 if (!is_email($sanitized['mxchat_notification_email'])) {
1729 add_settings_error(
1730 'mxchat_transcripts_options',
1731 'invalid_email',
1732 __('Please enter a valid email address for notifications.', 'mxchat'),
1733 'error'
1734 );
1735 $sanitized['mxchat_notification_email'] = get_option('admin_email');
1736 }
1737 }
1738
1739 // Sanitize auto-delete setting
1740 $valid_intervals = array('never', '1week', '2weeks', '1month');
1741 if (isset($input['mxchat_auto_delete_transcripts'])) {
1742 $sanitized['mxchat_auto_delete_transcripts'] = in_array($input['mxchat_auto_delete_transcripts'], $valid_intervals)
1743 ? $input['mxchat_auto_delete_transcripts']
1744 : 'never';
1745 } else {
1746 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1747 }
1748
1749 // Sanitize custom retention-days override (plan-9b80b1).
1750 if (isset($input['mxchat_retention_days'])) {
1751 $days = (int) $input['mxchat_retention_days'];
1752 $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1753 } else {
1754 $sanitized['mxchat_retention_days'] = 0;
1755 }
1756
1757 // Get old values to check if auto-delete or retention-days changed.
1758 $old_options = get_option('mxchat_transcripts_options');
1759 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1760 $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1761
1762 // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1763 // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1764 // reason to keep the daily cron registered.
1765 $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1766 $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1767 if ($interval_changed || $retention_changed) {
1768 $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1769 $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1770 }
1771
1772 // Sanitize auto-email transcript settings
1773 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
1774
1775 $valid_delays = array('15', '30', '60');
1776 if (isset($input['mxchat_auto_email_transcript_delay'])) {
1777 $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays)
1778 ? $input['mxchat_auto_email_transcript_delay']
1779 : '30';
1780 } else {
1781 $sanitized['mxchat_auto_email_transcript_delay'] = '30';
1782 }
1783
1784 // Sanitize require contact info setting
1785 $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0;
1786
1787 return $sanitized;
1788 }
1789
1790 /**
1791 * Schedule or unschedule the transcript cleanup cron job
1792 */
1793 public function schedule_transcript_cleanup($interval) {
1794 // Clear any existing scheduled event
1795 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1796 if ($timestamp) {
1797 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1798 }
1799
1800 // Schedule new event whenever retention is active. "active" is the canonical
1801 // value passed by sanitize_transcripts_options when either the dropdown != never
1802 // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1803 // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1804 if ($interval !== 'never') {
1805 // Schedule to run daily at 3 AM
1806 $next_run = strtotime('tomorrow 3:00 AM');
1807 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
1808 }
1809 }
1810
1811 /**
1812 * Self-heal guard: keep the cleanup cron in sync with the retention setting.
1813 *
1814 * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and
1815 * one wp_next_scheduled() (reads the cached cron option) — no writes. It only
1816 * schedules when retention is active but the event is missing, and only
1817 * unschedules when retention is off but the event survived. Both directions
1818 * reuse schedule_transcript_cleanup() so there is exactly one scheduling path.
1819 * Legacy dropdown values (1week/2weeks/1month) count as active, matching the
1820 * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling
1821 * writes the cron option regardless of how cron is executed, so a server-side
1822 * cron runner still picks the event up.
1823 */
1824 public function ensure_transcript_cleanup_scheduled() {
1825 $options = get_option('mxchat_transcripts_options', array());
1826 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1827 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1828 $active = ($interval !== 'never') || ($days > 0);
1829 $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts');
1830
1831 if ($active && !$scheduled) {
1832 $this->schedule_transcript_cleanup('active');
1833 } elseif (!$active && $scheduled) {
1834 $this->schedule_transcript_cleanup('never');
1835 }
1836 }
1837
1838 /**
1839 * Delete old transcripts based on the configured interval
1840 */
1841 public function cleanup_old_transcripts() {
1842 $options = get_option('mxchat_transcripts_options', array());
1843 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1844 $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1845
1846 // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
1847 $days = 0;
1848 if ($custom_days > 0) {
1849 $days = $custom_days;
1850 } else {
1851 switch ($interval) {
1852 case '1week': $days = 7; break;
1853 case '2weeks': $days = 14; break;
1854 case '1month': $days = 30; break;
1855 case 'never':
1856 default:
1857 $days = 0;
1858 }
1859 }
1860
1861 // Devs can override the final day count programmatically.
1862 $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
1863
1864 if ($days <= 0) {
1865 return; // Retention disabled — bail.
1866 }
1867
1868 global $wpdb;
1869 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
1870 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
1871 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
1872
1873 // Defensive: if the main table doesn't exist (fresh-ish install), bail.
1874 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
1875 return;
1876 }
1877
1878 $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
1879
1880 // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
1881 // the cron will pick up where it left off on the next tick (within hours).
1882 $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
1883
1884 $sessions_to_delete = $wpdb->get_col(
1885 $wpdb->prepare(
1886 "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
1887 $cutoff_date,
1888 $batch_cap
1889 )
1890 );
1891
1892 if (empty($sessions_to_delete)) {
1893 return;
1894 }
1895
1896 $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
1897
1898 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1899 // $placeholders is a server-built list of literal "%s" tokens.
1900 $deleted_transcripts = (int) $wpdb->query(
1901 $wpdb->prepare(
1902 "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
1903 $sessions_to_delete
1904 )
1905 );
1906
1907 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
1908 $wpdb->query(
1909 $wpdb->prepare(
1910 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
1911 $sessions_to_delete
1912 )
1913 );
1914 }
1915
1916 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
1917 $wpdb->query(
1918 $wpdb->prepare(
1919 "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
1920 $sessions_to_delete
1921 )
1922 );
1923 }
1924 // phpcs:enable
1925
1926 update_option('mxchat_retention_last_swept_at', time(), false);
1927 update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
1928 }
1929
1930 public function export_chat_transcripts() {
1931 if (!current_user_can('manage_options')) {
1932 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
1933 }
1934
1935 check_ajax_referer('mxchat_export_transcripts', 'security');
1936
1937 global $wpdb;
1938 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1939
1940 // Get all transcripts ordered by session and timestamp
1941 $results = $wpdb->get_results(
1942 "SELECT session_id, user_email, user_identifier, role, message, timestamp
1943 FROM {$table_name}
1944 ORDER BY session_id, timestamp ASC"
1945 );
1946
1947 if (empty($results)) {
1948 wp_send_json_error(array('message' => 'No transcripts found.'));
1949 wp_die();
1950 }
1951
1952 // Set headers for CSV download
1953 header('Content-Type: text/csv');
1954 header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"');
1955 header('Pragma: no-cache');
1956 header('Expires: 0');
1957
1958 // Create output stream
1959 $output = fopen('php://output', 'w');
1960
1961 // Add UTF-8 BOM for proper Excel encoding
1962 fputs($output, "\xEF\xBB\xBF");
1963
1964 // Add CSV headers
1965 fputcsv($output, array(
1966 'Session ID',
1967 'Email',
1968 'User Identifier',
1969 'Role',
1970 'Message',
1971 'Timestamp'
1972 ));
1973
1974 // Add data rows
1975 foreach ($results as $row) {
1976 fputcsv($output, array(
1977 $row->session_id,
1978 $row->user_email,
1979 $row->user_identifier,
1980 $row->role,
1981 $row->message,
1982 $row->timestamp
1983 ));
1984 }
1985
1986 fclose($output);
1987 wp_die();
1988 }
1989
1990 // ============================================================================
1991 // Leads tab (inside Transcripts)
1992 //
1993 // Leads are derived from existing data — no dedicated table. Primary source:
1994 // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
1995 // source: wp_options entries `mxchat_email_{session_id}` / `mxchat_name_{sid}`
1996 // for "orphan" leads who submitted the pre-chat form but never chatted.
1997 // ============================================================================
1998
1999 /**
2000 * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
2001 */
2002 public function mxchat_fetch_leads() {
2003 if (!current_user_can('manage_options')) {
2004 wp_send_json_error(['message' => 'Insufficient permissions']);
2005 wp_die();
2006 }
2007
2008 global $wpdb;
2009 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2010
2011 $page = isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1;
2012 $per_page = isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25;
2013 $offset = ($page - 1) * $per_page;
2014 $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
2015 $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
2016 $status = isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all';
2017 $page_filter = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
2018 $sort = isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen';
2019 $sort_dir = (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
2020
2021 $date_cutoff = self::mxchat_leads_date_cutoff($date_range);
2022 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2023
2024 // Base WHERE for transcripts leads.
2025 $where_clauses = ["user_email IS NOT NULL", "user_email != ''"];
2026 $where_params = [];
2027
2028 if ($date_cutoff) {
2029 $where_clauses[] = 'timestamp >= %s';
2030 $where_params[] = $date_cutoff;
2031 }
2032 if ($page_filter && $has_page_url_column) {
2033 $where_clauses[] = 'originating_page_url = %s';
2034 $where_params[] = $page_filter;
2035 }
2036 if ($search !== '') {
2037 $like = '%' . $wpdb->esc_like($search) . '%';
2038 $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
2039 $where_params[] = $like;
2040 $where_params[] = $like;
2041 }
2042 $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
2043
2044 // Aggregate query grouped by email.
2045 $select_sql = $has_page_url_column
2046 ? "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2047 COUNT(DISTINCT session_id) AS conversation_count"
2048 : "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2049 COUNT(DISTINCT session_id) AS conversation_count";
2050
2051 $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
2052 $group_order_limit = " GROUP BY user_email ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
2053
2054 $transcripts_sql = $wpdb->prepare(
2055 "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
2056 array_merge($where_params, [$per_page, $offset])
2057 );
2058 $transcript_rows = $wpdb->get_results($transcripts_sql);
2059
2060 // Count of unique transcript-based leads under the same filters.
2061 $count_sql = $wpdb->prepare(
2062 "SELECT COUNT(DISTINCT user_email) FROM {$table} {$where_sql}",
2063 $where_params
2064 );
2065 $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
2066
2067 // Hydrate each row: name, latest_session_id, top page.
2068 $leads = [];
2069 foreach ($transcript_rows as $row) {
2070 $detail = $has_page_url_column
2071 ? $wpdb->get_row($wpdb->prepare(
2072 "SELECT session_id, user_name, originating_page_url, originating_page_title
2073 FROM {$table} WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2074 $row->user_email
2075 ))
2076 : $wpdb->get_row($wpdb->prepare(
2077 "SELECT session_id, user_name FROM {$table}
2078 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2079 $row->user_email
2080 ));
2081
2082 $leads[] = [
2083 'email' => $row->user_email,
2084 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2085 'conversation_count' => (int) $row->conversation_count,
2086 'last_seen' => $row->last_seen,
2087 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
2088 'first_seen' => $row->first_seen,
2089 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
2090 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2091 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
2092 'is_orphan' => false,
2093 'status' => 'active',
2094 ];
2095 }
2096
2097 // Non-transcript lead sources. Built once here, then filtered/merged based on the
2098 // status filter below. Deduplication priority when the same email appears in multiple
2099 // sources: transcripts > chat_deleted > orphan.
2100 $transcripts_emails_seen = array_flip(array_map(
2101 function ($r) { return strtolower($r['email']); },
2102 $leads
2103 ));
2104
2105 // Chat-deleted leads: had a conversation that an admin removed. Preserved via
2106 // mxchat_lead_del_* options, with timestamps so they respect date filters.
2107 $chat_deleted_leads_all = [];
2108 if ($status === 'all' || $status === 'chat_deleted') {
2109 $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
2110 // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
2111 $chat_deleted_leads_all = array_values(array_filter(
2112 $chat_deleted_leads_all,
2113 function ($row) use ($transcripts_emails_seen) {
2114 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2115 }
2116 ));
2117 foreach ($chat_deleted_leads_all as $row) {
2118 $transcripts_emails_seen[strtolower($row['email'])] = true;
2119 }
2120 }
2121
2122 // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2123 // when a date filter is active.
2124 $orphan_leads_all = [];
2125 if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2126 $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2127 $orphan_leads_all = array_values(array_filter(
2128 $orphan_leads_all,
2129 function ($row) use ($transcripts_emails_seen) {
2130 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2131 }
2132 ));
2133 }
2134
2135 // Apply status filter to the transcripts-derived list.
2136 if ($status === 'orphan' || $status === 'chat_deleted') {
2137 $leads = [];
2138 $transcripts_lead_count = 0;
2139 }
2140
2141 // Stitch the current page from the three buckets in priority order.
2142 $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2143 $remaining_slots = $per_page - count($leads);
2144
2145 if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2146 $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2147 if ($start < count($chat_deleted_leads_all)) {
2148 $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2149 $remaining_slots = $per_page - count($leads);
2150 }
2151 }
2152
2153 if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2154 $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2155 $start = max(0, ($page - 1) * $per_page - $before);
2156 if ($start < count($orphan_leads_all)) {
2157 $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2158 }
2159 }
2160
2161 $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2162
2163 // Stats strip: always computed over full dataset, unaffected by filters.
2164 $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2165
2166 // Top pages: top 5 by distinct emails captured.
2167 $top_pages = [];
2168 if ($has_page_url_column) {
2169 $top_pages_rows = $wpdb->get_results(
2170 "SELECT originating_page_url AS url,
2171 MAX(originating_page_title) AS title,
2172 COUNT(DISTINCT user_email) AS lead_count
2173 FROM {$table}
2174 WHERE user_email IS NOT NULL AND user_email != ''
2175 AND originating_page_url IS NOT NULL AND originating_page_url != ''
2176 GROUP BY originating_page_url
2177 ORDER BY lead_count DESC, url ASC
2178 LIMIT 5"
2179 );
2180 foreach ($top_pages_rows as $p) {
2181 $top_pages[] = [
2182 'url' => $p->url,
2183 'title' => $p->title ?: $p->url,
2184 'lead_count' => (int) $p->lead_count,
2185 ];
2186 }
2187 }
2188
2189 wp_send_json([
2190 'success' => true,
2191 'leads' => $leads,
2192 'page' => $page,
2193 'per_page' => $per_page,
2194 'total_count' => $total_count,
2195 'total_pages' => $total_pages,
2196 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2197 'showing_end' => min($offset + $per_page, $total_count),
2198 'stats' => $stats,
2199 'top_pages' => $top_pages,
2200 ]);
2201 wp_die();
2202 }
2203
2204 /**
2205 * Delete one or more leads by email. Removes every transcripts row for that
2206 * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2207 * mxchat_history_{sid}) and any orphan option entries matching the email.
2208 */
2209 public function mxchat_delete_leads() {
2210 if (!current_user_can('manage_options')) {
2211 wp_send_json_error(['message' => 'Insufficient permissions']);
2212 wp_die();
2213 }
2214 check_ajax_referer('mxchat_delete_leads', 'security');
2215
2216 $emails_raw = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2217 $emails = [];
2218 foreach ($emails_raw as $e) {
2219 $clean = sanitize_email((string) $e);
2220 if ($clean) {
2221 $emails[] = $clean;
2222 }
2223 }
2224 if (empty($emails)) {
2225 wp_send_json_error(['message' => 'No emails provided']);
2226 wp_die();
2227 }
2228
2229 $summary = self::mxchat_wipe_leads_by_email($emails);
2230
2231 wp_send_json([
2232 'success' => true,
2233 'deleted_leads' => count($emails),
2234 'deleted_sessions' => $summary['deleted_sessions'],
2235 'deleted_rows' => $summary['deleted_rows'],
2236 ]);
2237 wp_die();
2238 }
2239
2240 /**
2241 * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2242 * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2243 *
2244 * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2245 * Input emails must already be sanitized with sanitize_email().
2246 */
2247 private static function mxchat_wipe_leads_by_email(array $emails) {
2248 global $wpdb;
2249 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2250 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2251 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2252
2253 $deleted_sessions = 0;
2254 $deleted_rows = 0;
2255 $emails_lc = array_map('strtolower', $emails);
2256
2257 foreach ($emails as $email) {
2258 $session_ids = $wpdb->get_col($wpdb->prepare(
2259 "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2260 $email
2261 ));
2262
2263 $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2264 if ($rows_removed !== false) {
2265 $deleted_rows += (int) $rows_removed;
2266 }
2267
2268 foreach ($session_ids as $sid) {
2269 $deleted_sessions++;
2270 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2271 delete_option('mxchat_history_' . $sid);
2272 delete_option('mxchat_email_' . $sid);
2273 delete_option('mxchat_name_' . $sid);
2274 delete_option('mxchat_agent_name_' . $sid);
2275 delete_option('mxchat_lead_del_email_' . $sid);
2276 delete_option('mxchat_lead_del_name_' . $sid);
2277 delete_option('mxchat_lead_del_ts_' . $sid);
2278 if ($has_translations) {
2279 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2280 }
2281 }
2282 }
2283
2284 // Clean up any lingering option entries (orphan pre-chat captures + chat_deleted
2285 // preservations) whose stored value matches one of the emails being wiped.
2286 $lingering = $wpdb->get_results(
2287 "SELECT option_name, option_value FROM {$wpdb->options}
2288 WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2289 );
2290 foreach ($lingering as $opt) {
2291 if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2292 continue;
2293 }
2294 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2295 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2296 delete_option('mxchat_lead_del_email_' . $sid);
2297 delete_option('mxchat_lead_del_name_' . $sid);
2298 delete_option('mxchat_lead_del_ts_' . $sid);
2299 } else {
2300 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2301 delete_option('mxchat_email_' . $sid);
2302 delete_option('mxchat_name_' . $sid);
2303 }
2304 }
2305
2306 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2307
2308 return [
2309 'deleted_sessions' => $deleted_sessions,
2310 'deleted_rows' => $deleted_rows,
2311 ];
2312 }
2313
2314 /**
2315 * Stream a leads CSV. scope=all exports every lead under current filters is not
2316 * supported to keep semantics simple; caller either exports all leads or a
2317 * specific set of selected emails.
2318 */
2319 public function mxchat_export_leads() {
2320 if (!current_user_can('manage_options')) {
2321 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2322 }
2323 check_ajax_referer('mxchat_export_leads', 'security');
2324
2325 $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2326 $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2327 $emails_in = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2328
2329 $emails_in_clean = [];
2330 foreach ($emails_in as $e) {
2331 $clean = sanitize_email((string) $e);
2332 if ($clean) {
2333 $emails_in_clean[] = $clean;
2334 }
2335 }
2336
2337 global $wpdb;
2338 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2339 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2340
2341 // Collect leads from transcripts.
2342 $transcripts_sql = "SELECT user_email AS email,
2343 MAX(timestamp) AS last_seen,
2344 COUNT(DISTINCT session_id) AS conversation_count
2345 FROM {$table}
2346 WHERE user_email IS NOT NULL AND user_email != ''";
2347 $params = [];
2348 if ($scope === 'selected' && !empty($emails_in_clean)) {
2349 $placeholders = implode(',', array_fill(0, count($emails_in_clean), '%s'));
2350 $transcripts_sql .= " AND user_email IN ({$placeholders})";
2351 $params = $emails_in_clean;
2352 }
2353 $transcripts_sql .= " GROUP BY user_email ORDER BY last_seen DESC";
2354
2355 $rows = !empty($params)
2356 ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2357 : $wpdb->get_results($transcripts_sql);
2358
2359 // Hydrate each row with name + top page.
2360 $export_rows = [];
2361 foreach ($rows as $row) {
2362 $detail = $has_page_url_column
2363 ? $wpdb->get_row($wpdb->prepare(
2364 "SELECT user_name, originating_page_url FROM {$table}
2365 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2366 $row->email
2367 ))
2368 : $wpdb->get_row($wpdb->prepare(
2369 "SELECT user_name FROM {$table}
2370 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2371 $row->email
2372 ));
2373 $export_rows[] = [
2374 'email' => $row->email,
2375 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2376 'conversation_count' => (int) $row->conversation_count,
2377 'last_seen' => $row->last_seen,
2378 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2379 ];
2380 }
2381
2382 // Include orphan + chat_deleted leads when exporting all.
2383 if ($scope === 'all') {
2384 $transcripts_emails_lc = array_flip(array_map(
2385 function ($r) { return strtolower($r['email']); },
2386 $export_rows
2387 ));
2388 foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2389 if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2390 $transcripts_emails_lc[strtolower($cd['email'])] = true;
2391 $export_rows[] = [
2392 'email' => $cd['email'],
2393 'name' => $cd['name'],
2394 'conversation_count' => 0,
2395 'last_seen' => $cd['last_seen'],
2396 'top_page_url' => '',
2397 ];
2398 }
2399 foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2400 if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2401 $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2402 $export_rows[] = [
2403 'email' => $orphan['email'],
2404 'name' => $orphan['name'],
2405 'conversation_count' => 0,
2406 'last_seen' => '',
2407 'top_page_url' => '',
2408 ];
2409 }
2410 }
2411
2412 if (empty($export_rows)) {
2413 wp_send_json_error(['message' => 'No leads to export.']);
2414 wp_die();
2415 }
2416
2417 $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2418 header('Content-Type: text/csv');
2419 header('Content-Disposition: attachment; filename="' . $filename . '"');
2420 header('Pragma: no-cache');
2421 header('Expires: 0');
2422
2423 $output = fopen('php://output', 'w');
2424 fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2425
2426 if ($fields_mode === 'email_only') {
2427 fputcsv($output, ['Email']);
2428 foreach ($export_rows as $r) {
2429 fputcsv($output, [$r['email']]);
2430 }
2431 } else {
2432 fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page']);
2433 foreach ($export_rows as $r) {
2434 fputcsv($output, [
2435 $r['email'],
2436 $r['name'],
2437 $r['conversation_count'],
2438 $r['last_seen'],
2439 $r['top_page_url'],
2440 ]);
2441 }
2442 }
2443
2444 fclose($output);
2445 wp_die();
2446 }
2447
2448 /**
2449 * Stats strip payload (independent of filters).
2450 */
2451 private static function mxchat_leads_stats($table, $has_page_url_column) {
2452 global $wpdb;
2453
2454 $total_transcripts_emails = (int) $wpdb->get_var(
2455 "SELECT COUNT(DISTINCT user_email) FROM {$table}
2456 WHERE user_email IS NOT NULL AND user_email != ''"
2457 );
2458
2459 $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2460 "SELECT COUNT(*) FROM (
2461 SELECT user_email FROM {$table}
2462 WHERE user_email IS NOT NULL AND user_email != ''
2463 GROUP BY user_email
2464 HAVING MIN(timestamp) >= %s
2465 ) AS new_leads",
2466 gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2467 ));
2468
2469 $total_convos = (int) $wpdb->get_var(
2470 "SELECT COUNT(DISTINCT session_id) FROM {$table}
2471 WHERE user_email IS NOT NULL AND user_email != ''"
2472 );
2473
2474 $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2475 $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2476
2477 // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2478 // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2479 $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2480
2481 $avg = $total_transcripts_emails > 0
2482 ? round($total_convos / $total_transcripts_emails, 1)
2483 : 0;
2484
2485 // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2486 // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2487 // inflate this number.
2488 $orphan_pct = $total_leads > 0
2489 ? (int) round(($orphan_count / $total_leads) * 100)
2490 : 0;
2491
2492 return [
2493 'total_leads' => $total_leads,
2494 'new_this_week' => $new_this_week,
2495 'avg_convos' => $avg,
2496 'orphan_pct' => $orphan_pct,
2497 'orphan_count' => $orphan_count,
2498 'chat_deleted_count' => $chat_deleted_count,
2499 ];
2500 }
2501
2502 /**
2503 * Collect leads who had a conversation that an admin later deleted (preserved via
2504 * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2505 * original last-seen timestamp so they still sort and filter sensibly.
2506 *
2507 * @param string $search Optional email/name substring filter.
2508 * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2509 * @return array
2510 */
2511 private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2512 global $wpdb;
2513 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2514
2515 $rows = $wpdb->get_results(
2516 "SELECT option_name, option_value FROM {$wpdb->options}
2517 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2518 );
2519 if (empty($rows)) {
2520 return [];
2521 }
2522
2523 // Emails that currently have transcripts rows should not appear as chat_deleted —
2524 // they've come back and chatted, so they're active leads again.
2525 $emails_in_transcripts = array_map(
2526 'strtolower',
2527 (array) $wpdb->get_col(
2528 "SELECT DISTINCT user_email FROM {$table}
2529 WHERE user_email IS NOT NULL AND user_email != ''"
2530 )
2531 );
2532 $emails_in_transcripts = array_flip($emails_in_transcripts);
2533
2534 $needle = strtolower(trim((string) $search));
2535 $by_email = [];
2536
2537 foreach ($rows as $opt) {
2538 $email = sanitize_email(trim((string) $opt->option_value));
2539 if (!$email) {
2540 continue;
2541 }
2542 if (isset($emails_in_transcripts[strtolower($email)])) {
2543 continue;
2544 }
2545 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2546 if (!$sid) {
2547 continue;
2548 }
2549 $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2550 $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2551
2552 if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2553 continue;
2554 }
2555 if ($needle !== '') {
2556 $hay = strtolower($email . ' ' . $name);
2557 if (strpos($hay, $needle) === false) {
2558 continue;
2559 }
2560 }
2561
2562 $key = strtolower($email);
2563 if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2564 $by_email[$key] = [
2565 'email' => $email,
2566 'name' => $name,
2567 'conversation_count' => 0,
2568 'last_seen' => $ts,
2569 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2570 'first_seen' => $ts,
2571 'latest_session_id' => '',
2572 'top_page_url' => '',
2573 'top_page_title' => '',
2574 'is_orphan' => false,
2575 'status' => 'chat_deleted',
2576 ];
2577 }
2578 }
2579
2580 // Newest chat_deleted first.
2581 usort($by_email, function ($a, $b) {
2582 return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2583 });
2584 return array_values($by_email);
2585 }
2586
2587 /**
2588 * Count unique emails preserved as "chat deleted" (for the stats strip).
2589 */
2590 private static function mxchat_count_chat_deleted_leads() {
2591 global $wpdb;
2592 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2593
2594 $emails = $wpdb->get_col(
2595 "SELECT DISTINCT option_value FROM {$wpdb->options}
2596 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2597 );
2598 if (empty($emails)) {
2599 return 0;
2600 }
2601
2602 $transcripts_emails = array_map(
2603 'strtolower',
2604 (array) $wpdb->get_col(
2605 "SELECT DISTINCT user_email FROM {$table}
2606 WHERE user_email IS NOT NULL AND user_email != ''"
2607 )
2608 );
2609 $transcripts_emails = array_flip($transcripts_emails);
2610
2611 $count = 0;
2612 $seen = [];
2613 foreach ($emails as $raw) {
2614 $email = strtolower(trim((string) $raw));
2615 if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2616 continue;
2617 }
2618 $seen[$email] = true;
2619 $count++;
2620 }
2621 return $count;
2622 }
2623
2624 /**
2625 * Find leads who submitted the pre-chat form but never produced a transcripts row.
2626 * Returned rows have no conversation_count, no timestamp.
2627 *
2628 * @param string $search Optional email/name substring filter.
2629 * @return array
2630 */
2631 private static function mxchat_collect_orphan_leads($search = '') {
2632 global $wpdb;
2633 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2634
2635 $option_rows = $wpdb->get_results(
2636 "SELECT option_name, option_value FROM {$wpdb->options}
2637 WHERE option_name LIKE 'mxchat_email_%'"
2638 );
2639 if (empty($option_rows)) {
2640 return [];
2641 }
2642
2643 // Collect all session_ids that have real transcripts rows so we can exclude them.
2644 $session_ids_with_rows = $wpdb->get_col(
2645 "SELECT DISTINCT session_id FROM {$table}
2646 WHERE user_email IS NOT NULL AND user_email != ''"
2647 );
2648 $session_ids_with_rows = array_flip($session_ids_with_rows);
2649
2650 // Seen emails in transcripts (so orphans only include truly never-chatted leads).
2651 $emails_in_transcripts = array_map(
2652 'strtolower',
2653 (array) $wpdb->get_col(
2654 "SELECT DISTINCT user_email FROM {$table}
2655 WHERE user_email IS NOT NULL AND user_email != ''"
2656 )
2657 );
2658 $emails_in_transcripts = array_flip($emails_in_transcripts);
2659
2660 $orphans_by_email = [];
2661 $needle = strtolower(trim((string) $search));
2662
2663 foreach ($option_rows as $opt) {
2664 $email = sanitize_email(trim((string) $opt->option_value));
2665 if (!$email) {
2666 continue;
2667 }
2668 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2669 if (!$sid) {
2670 continue;
2671 }
2672 // Exclude leads who have any transcripts rows (they appear in the main list).
2673 if (isset($emails_in_transcripts[strtolower($email)])) {
2674 continue;
2675 }
2676 if (isset($session_ids_with_rows[$sid])) {
2677 continue;
2678 }
2679
2680 $name_option = get_option('mxchat_name_' . $sid, '');
2681 $name = is_string($name_option) ? trim($name_option) : '';
2682
2683 if ($needle !== '') {
2684 $hay = strtolower($email . ' ' . $name);
2685 if (strpos($hay, $needle) === false) {
2686 continue;
2687 }
2688 }
2689
2690 $key = strtolower($email);
2691 if (!isset($orphans_by_email[$key])) {
2692 $orphans_by_email[$key] = [
2693 'email' => $email,
2694 'name' => $name,
2695 'conversation_count' => 0,
2696 'last_seen' => '',
2697 'last_seen_display' => __('No conversation yet', 'mxchat'),
2698 'first_seen' => '',
2699 'latest_session_id' => '',
2700 'top_page_url' => '',
2701 'top_page_title' => '',
2702 'is_orphan' => true,
2703 'status' => 'orphan',
2704 ];
2705 }
2706 }
2707
2708 return array_values($orphans_by_email);
2709 }
2710
2711 /**
2712 * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
2713 */
2714 private static function mxchat_leads_date_cutoff($date_range) {
2715 switch ($date_range) {
2716 case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
2717 case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
2718 case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
2719 case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
2720 case 'all':
2721 default: return '';
2722 }
2723 }
2724
2725 /**
2726 * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
2727 */
2728 private static function mxchat_leads_format_relative($timestamp) {
2729 if (!$timestamp) {
2730 return '';
2731 }
2732 $ts = strtotime($timestamp . ' UTC');
2733 if (!$ts) {
2734 return '';
2735 }
2736 $diff = time() - $ts;
2737 if ($diff < 60) return __('just now', 'mxchat');
2738 if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
2739 if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
2740 if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
2741 return wp_date('M j', $ts);
2742 }
2743
2744 /**
2745 * Handle translation of chat messages via AJAX
2746 */
2747 public function mxchat_translate_messages() {
2748 if (!current_user_can('manage_options')) {
2749 wp_send_json_error(['error' => 'Insufficient permissions']);
2750 wp_die();
2751 }
2752
2753 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
2754 $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en';
2755 $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]';
2756 $messages = json_decode($messages_json, true);
2757
2758 if (empty($session_id)) {
2759 wp_send_json_error(['error' => 'No session ID provided']);
2760 wp_die();
2761 }
2762
2763 if (empty($messages) || !is_array($messages)) {
2764 wp_send_json_error(['error' => 'No messages to translate']);
2765 wp_die();
2766 }
2767
2768 // Language names for prompting
2769 $languages = [
2770 'en' => 'English',
2771 'es' => 'Spanish',
2772 'fr' => 'French',
2773 'de' => 'German',
2774 'it' => 'Italian',
2775 'pt' => 'Portuguese',
2776 'nl' => 'Dutch',
2777 'ru' => 'Russian',
2778 'zh' => 'Chinese',
2779 'ja' => 'Japanese',
2780 'ko' => 'Korean',
2781 'ar' => 'Arabic',
2782 'hi' => 'Hindi',
2783 'tr' => 'Turkish',
2784 'pl' => 'Polish',
2785 'vi' => 'Vietnamese',
2786 'th' => 'Thai',
2787 'id' => 'Indonesian',
2788 'sv' => 'Swedish',
2789 'da' => 'Danish'
2790 ];
2791
2792 $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English';
2793
2794 // Build combined text for translation (numbered for parsing)
2795 $numbered_messages = [];
2796 foreach ($messages as $i => $msg) {
2797 $content = isset($msg['content']) ? trim($msg['content']) : '';
2798 if (!empty($content)) {
2799 $numbered_messages[] = "[MSG" . $i . "]" . $content . "[/MSG" . $i . "]";
2800 }
2801 }
2802
2803 if (empty($numbered_messages)) {
2804 wp_send_json_error(['error' => 'No valid messages to translate']);
2805 wp_die();
2806 }
2807
2808 $combined_text = implode("\n\n", $numbered_messages);
2809
2810 // Prepare the translation prompt
2811 $system_prompt = "You are a translator. Translate the following messages to {$target_lang_name}. Keep the [MSG#] and [/MSG#] tags exactly as they are - only translate the content between them. Maintain the original formatting, line breaks, and any HTML tags. Return ONLY the translated messages with the tags, no explanations.";
2812
2813 // Get user's selected model and determine provider
2814 $options = get_option('mxchat_options', []);
2815 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
2816
2817 // Check if using OpenRouter
2818 if ($selected_model === 'openrouter') {
2819 $provider = 'openrouter';
2820 $selected_model = $options['openrouter_selected_model'] ?? '';
2821 $api_key = $options['openrouter_api_key'] ?? '';
2822
2823 if (empty($selected_model)) {
2824 wp_send_json_error(['error' => 'No OpenRouter model selected']);
2825 wp_die();
2826 }
2827 } else {
2828 // Determine provider from model name
2829 $model_parts = explode('-', $selected_model);
2830 $provider = strtolower($model_parts[0]);
2831
2832 // Get the appropriate API key based on provider
2833 $api_key = '';
2834 switch ($provider) {
2835 case 'gpt':
2836 case 'o1':
2837 $api_key = $options['api_key'] ?? '';
2838 break;
2839 case 'claude':
2840 $api_key = $options['claude_api_key'] ?? '';
2841 break;
2842 case 'grok':
2843 $api_key = $options['xai_api_key'] ?? '';
2844 break;
2845 case 'deepseek':
2846 $api_key = $options['deepseek_api_key'] ?? '';
2847 break;
2848 case 'gemini':
2849 $api_key = $options['gemini_api_key'] ?? '';
2850 break;
2851 default:
2852 // Default to OpenAI
2853 $api_key = $options['api_key'] ?? '';
2854 $provider = 'gpt';
2855 break;
2856 }
2857 }
2858
2859 if (empty($api_key)) {
2860 wp_send_json_error(['error' => 'No API key configured for ' . $provider]);
2861 wp_die();
2862 }
2863
2864 // Make API request based on provider
2865 $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text);
2866
2867 if (is_wp_error($response)) {
2868 wp_send_json_error(['error' => $response->get_error_message()]);
2869 wp_die();
2870 }
2871
2872 // Parse the response to extract translated messages
2873 $translations = [];
2874 foreach ($messages as $msg) {
2875 $index = $msg['index'];
2876 $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s';
2877 if (preg_match($pattern, $response, $matches)) {
2878 $translations[] = [
2879 'index' => $index,
2880 'translated' => trim($matches[1])
2881 ];
2882 }
2883 }
2884
2885 // Save translations to database
2886 if (!empty($translations)) {
2887 $this->save_transcript_translation($session_id, $target_lang, $translations);
2888 }
2889
2890 wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]);
2891 wp_die();
2892 }
2893
2894 /**
2895 * Save transcript translation to database
2896 */
2897 private function save_transcript_translation($session_id, $language_code, $translations) {
2898 global $wpdb;
2899 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
2900
2901 // Check if table exists, create if not
2902 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
2903 mxchat_create_translations_table();
2904 }
2905
2906 $now = current_time('mysql');
2907 $translations_json = wp_json_encode($translations);
2908
2909 // Use REPLACE to insert or update
2910 $wpdb->query($wpdb->prepare(
2911 "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at)
2912 VALUES (%s, %s, %s, %s, %s)",
2913 $session_id,
2914 $language_code,
2915 $translations_json,
2916 $now,
2917 $now
2918 ));
2919 }
2920
2921 /**
2922 * Get saved translation for a session
2923 */
2924 public function mxchat_get_transcript_translation() {
2925 if (!current_user_can('manage_options')) {
2926 wp_send_json_error(['error' => 'Insufficient permissions']);
2927 wp_die();
2928 }
2929
2930 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
2931
2932 if (empty($session_id)) {
2933 wp_send_json_error(['error' => 'No session ID provided']);
2934 wp_die();
2935 }
2936
2937 global $wpdb;
2938 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
2939
2940 // Check if table exists
2941 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
2942 wp_send_json(['success' => true, 'has_translation' => false]);
2943 wp_die();
2944 }
2945
2946 // Get the most recent translation for this session
2947 $result = $wpdb->get_row($wpdb->prepare(
2948 "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1",
2949 $session_id
2950 ));
2951
2952 if ($result) {
2953 $translations = json_decode($result->translations, true);
2954 wp_send_json([
2955 'success' => true,
2956 'has_translation' => true,
2957 'language' => $result->language_code,
2958 'translations' => $translations
2959 ]);
2960 } else {
2961 wp_send_json(['success' => true, 'has_translation' => false]);
2962 }
2963 wp_die();
2964 }
2965
2966 /**
2967 * Translate text using the user's selected provider and model
2968 */
2969 private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) {
2970 switch ($provider) {
2971 case 'claude':
2972 return $this->translate_with_claude($api_key, $model, $system_prompt, $text);
2973 case 'grok':
2974 return $this->translate_with_xai($api_key, $model, $system_prompt, $text);
2975 case 'deepseek':
2976 return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text);
2977 case 'gemini':
2978 return $this->translate_with_gemini($api_key, $model, $system_prompt, $text);
2979 case 'openrouter':
2980 return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text);
2981 case 'gpt':
2982 case 'o1':
2983 default:
2984 return $this->translate_with_openai($api_key, $model, $system_prompt, $text);
2985 }
2986 }
2987
2988 /**
2989 * Translate text using OpenAI API
2990 */
2991 private function translate_with_openai($api_key, $model, $system_prompt, $text) {
2992 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
2993 'timeout' => 60,
2994 'headers' => [
2995 'Authorization' => 'Bearer ' . $api_key,
2996 'Content-Type' => 'application/json'
2997 ],
2998 'body' => wp_json_encode([
2999 'model' => $model,
3000 'messages' => [
3001 ['role' => 'system', 'content' => $system_prompt],
3002 ['role' => 'user', 'content' => $text]
3003 ],
3004 'temperature' => 0.3
3005 ])
3006 ]);
3007
3008 if (is_wp_error($response)) {
3009 return $response;
3010 }
3011
3012 $body = json_decode(wp_remote_retrieve_body($response), true);
3013
3014 if (isset($body['error'])) {
3015 return new WP_Error('api_error', $body['error']['message']);
3016 }
3017
3018 if (isset($body['choices'][0]['message']['content'])) {
3019 return $body['choices'][0]['message']['content'];
3020 }
3021
3022 return new WP_Error('api_error', 'Invalid API response');
3023 }
3024
3025 /**
3026 * Translate text using Claude API
3027 */
3028 private function translate_with_claude($api_key, $model, $system_prompt, $text) {
3029 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3030 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3031 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3032 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3033 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
3034 'timeout' => 60,
3035 'headers' => [
3036 'x-api-key' => $api_key,
3037 'anthropic-version' => '2023-06-01',
3038 'Content-Type' => 'application/json'
3039 ],
3040 'body' => wp_json_encode([
3041 'model' => $model,
3042 'max_tokens' => 4096,
3043 'system' => $system_prompt,
3044 'messages' => [
3045 ['role' => 'user', 'content' => $text]
3046 ]
3047 ])
3048 ]);
3049
3050 if (is_wp_error($response)) {
3051 return $response;
3052 }
3053
3054 $body = json_decode(wp_remote_retrieve_body($response), true);
3055
3056 if (isset($body['error'])) {
3057 return new WP_Error('api_error', $body['error']['message']);
3058 }
3059
3060 if (isset($body['content'][0]['text'])) {
3061 return $body['content'][0]['text'];
3062 }
3063
3064 return new WP_Error('api_error', 'Invalid API response');
3065 }
3066
3067 /**
3068 * Translate text using xAI (Grok) API
3069 */
3070 private function translate_with_xai($api_key, $model, $system_prompt, $text) {
3071 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [
3072 'timeout' => 60,
3073 'headers' => [
3074 'Authorization' => 'Bearer ' . $api_key,
3075 'Content-Type' => 'application/json'
3076 ],
3077 'body' => wp_json_encode([
3078 'model' => $model,
3079 'messages' => [
3080 ['role' => 'system', 'content' => $system_prompt],
3081 ['role' => 'user', 'content' => $text]
3082 ],
3083 'temperature' => 0.3
3084 ])
3085 ]);
3086
3087 if (is_wp_error($response)) {
3088 return $response;
3089 }
3090
3091 $body = json_decode(wp_remote_retrieve_body($response), true);
3092
3093 if (isset($body['error'])) {
3094 return new WP_Error('api_error', $body['error']['message']);
3095 }
3096
3097 if (isset($body['choices'][0]['message']['content'])) {
3098 return $body['choices'][0]['message']['content'];
3099 }
3100
3101 return new WP_Error('api_error', 'Invalid API response');
3102 }
3103
3104 /**
3105 * Translate text using DeepSeek API
3106 */
3107 private function translate_with_deepseek($api_key, $model, $system_prompt, $text) {
3108 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [
3109 'timeout' => 60,
3110 'headers' => [
3111 'Authorization' => 'Bearer ' . $api_key,
3112 'Content-Type' => 'application/json'
3113 ],
3114 'body' => wp_json_encode([
3115 'model' => $model,
3116 'messages' => [
3117 ['role' => 'system', 'content' => $system_prompt],
3118 ['role' => 'user', 'content' => $text]
3119 ],
3120 'temperature' => 0.3,
3121 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3122 // slow responses); translation wants non-thinking.
3123 'thinking' => ['type' => 'disabled']
3124 ])
3125 ]);
3126
3127 if (is_wp_error($response)) {
3128 return $response;
3129 }
3130
3131 $body = json_decode(wp_remote_retrieve_body($response), true);
3132
3133 if (isset($body['error'])) {
3134 return new WP_Error('api_error', $body['error']['message']);
3135 }
3136
3137 if (isset($body['choices'][0]['message']['content'])) {
3138 return $body['choices'][0]['message']['content'];
3139 }
3140
3141 return new WP_Error('api_error', 'Invalid API response');
3142 }
3143
3144 /**
3145 * Translate text using Google Gemini API
3146 */
3147 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3148 if ($model === 'gemini-3-pro-preview') {
3149 $model = 'gemini-3.1-pro-preview';
3150 }
3151 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3152
3153 $response = wp_remote_post($url, [
3154 'timeout' => 60,
3155 'headers' => [
3156 'Content-Type' => 'application/json'
3157 ],
3158 'body' => wp_json_encode([
3159 'contents' => [
3160 [
3161 'parts' => [
3162 ['text' => $system_prompt . "\n\n" . $text]
3163 ]
3164 ]
3165 ],
3166 'generationConfig' => [
3167 'temperature' => 0.3
3168 ]
3169 ])
3170 ]);
3171
3172 if (is_wp_error($response)) {
3173 return $response;
3174 }
3175
3176 $body = json_decode(wp_remote_retrieve_body($response), true);
3177
3178 if (isset($body['error'])) {
3179 return new WP_Error('api_error', $body['error']['message']);
3180 }
3181
3182 if (isset($body['candidates'][0]['content']['parts'][0]['text'])) {
3183 return $body['candidates'][0]['content']['parts'][0]['text'];
3184 }
3185
3186 return new WP_Error('api_error', 'Invalid API response');
3187 }
3188
3189 /**
3190 * Translate text using OpenRouter API
3191 */
3192 private function translate_with_openrouter($api_key, $model, $system_prompt, $text) {
3193 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
3194 'timeout' => 60,
3195 'headers' => [
3196 'Authorization' => 'Bearer ' . $api_key,
3197 'Content-Type' => 'application/json',
3198 'HTTP-Referer' => home_url(),
3199 'X-Title' => 'MxChat Translation'
3200 ],
3201 'body' => wp_json_encode([
3202 'model' => $model,
3203 'messages' => [
3204 ['role' => 'system', 'content' => $system_prompt],
3205 ['role' => 'user', 'content' => $text]
3206 ],
3207 'temperature' => 0.3
3208 ])
3209 ]);
3210
3211 if (is_wp_error($response)) {
3212 return $response;
3213 }
3214
3215 $body = json_decode(wp_remote_retrieve_body($response), true);
3216
3217 if (isset($body['error'])) {
3218 return new WP_Error('api_error', $body['error']['message']);
3219 }
3220
3221 if (isset($body['choices'][0]['message']['content'])) {
3222 return $body['choices'][0]['message']['content'];
3223 }
3224
3225 return new WP_Error('api_error', 'Invalid API response');
3226 }
3227
3228 public function mxchat_fetch_chat_history() {
3229 global $wpdb;
3230 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3231 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3232
3233 if (!current_user_can('manage_options')) {
3234 wp_send_json_error(['message' => 'Insufficient permissions']);
3235 wp_die();
3236 }
3237
3238 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3239 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3240 $offset = ($page - 1) * $per_page;
3241 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3242 $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3243 $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3244 if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3245 $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3246 $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3247 $ratings_join = '';
3248 $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3249 if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3250 // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3251 $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3252 $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3253 }
3254
3255 // Build search condition
3256 $search_condition = '';
3257 $search_params = [];
3258 if (!empty($search)) {
3259 $search_condition = "WHERE (
3260 session_id LIKE %s
3261 OR user_email LIKE %s
3262 OR user_name LIKE %s
3263 OR user_identifier LIKE %s
3264 OR message LIKE %s
3265 )";
3266 $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%');
3267 }
3268
3269 // Get total count
3270 $count_query = !empty($search)
3271 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3272 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3273 $total_sessions = (int) $wpdb->get_var($count_query);
3274
3275 // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3276 // and order by rating value (NULLs last) with recency as tiebreaker.
3277 if ($ratings_join) {
3278 $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3279 } else {
3280 $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3281 }
3282 $session_query = !empty($search)
3283 ? $wpdb->prepare(
3284 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3285 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3286 array_merge($search_params, [$per_page, $offset])
3287 )
3288 : $wpdb->prepare(
3289 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3290 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3291 $per_page, $offset
3292 );
3293 $session_ids = $wpdb->get_col($session_query);
3294
3295 $total_pages = ceil($total_sessions / $per_page);
3296
3297 if (empty($session_ids)) {
3298 wp_send_json([
3299 'success' => true,
3300 'sessions' => [],
3301 'page' => $page,
3302 'total_pages' => 0,
3303 'total_sessions' => 0,
3304 'showing_start' => 0,
3305 'showing_end' => 0
3306 ]);
3307 wp_die();
3308 }
3309
3310 // Check for optional columns/tables
3311 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3312 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3313
3314 // Batch-fetch session ratings for this page (plan-a5b006).
3315 $ratings_map = array();
3316 if ($rating_table_exists && !empty($session_ids)) {
3317 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3318 $rating_rows = $wpdb->get_results($wpdb->prepare(
3319 "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3320 $session_ids
3321 ));
3322 foreach ($rating_rows as $row) {
3323 $ratings_map[$row->session_id] = array(
3324 'value' => (int) $row->rating_value,
3325 'feedback' => (string) $row->rating_feedback,
3326 );
3327 }
3328 }
3329
3330 // Build session list data
3331 $sessions = [];
3332 foreach ($session_ids as $session_id) {
3333 // Get session metadata
3334 $session_data = $originating_columns_exist
3335 ? $wpdb->get_row($wpdb->prepare(
3336 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3337 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3338 $session_id
3339 ))
3340 : $wpdb->get_row($wpdb->prepare(
3341 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3342 WHERE session_id = %s LIMIT 1",
3343 $session_id
3344 ));
3345
3346 // Get message count and latest timestamp
3347 $message_stats = $wpdb->get_row($wpdb->prepare(
3348 "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s",
3349 $session_id
3350 ));
3351
3352 // Get first user message as preview
3353 $first_user_msg = $wpdb->get_var($wpdb->prepare(
3354 "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1",
3355 $session_id
3356 ));
3357 $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages';
3358
3359 // Build display name
3360 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3361 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3362 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3363
3364 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3365 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3366
3367 // Format time - show relative for recent, date for older
3368 $timestamp = strtotime($message_stats->latest . ' UTC');
3369 $now = time();
3370 $diff = $now - $timestamp;
3371 if ($diff < 3600) {
3372 $time_display = floor($diff / 60) . 'm ago';
3373 } elseif ($diff < 86400) {
3374 $time_display = floor($diff / 3600) . 'h ago';
3375 } elseif ($diff < 604800) {
3376 $time_display = floor($diff / 86400) . 'd ago';
3377 } else {
3378 $time_display = wp_date('M j', $timestamp);
3379 }
3380
3381 // Get initials for avatar
3382 $initials = strtoupper(substr($display_name, 0, 2));
3383 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3384 $parts = explode(' ', $display_name);
3385 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3386 }
3387
3388 $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3389 $rating_value = $rating_entry ? $rating_entry['value'] : null;
3390 $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3391
3392 $sessions[] = [
3393 'session_id' => $session_id,
3394 'display_name' => $display_name,
3395 'display_sub' => $display_sub,
3396 'initials' => $initials,
3397 'preview' => $preview,
3398 'message_count' => (int) $message_stats->count,
3399 'time_display' => $time_display,
3400 'timestamp' => $message_stats->latest,
3401 'rating_value' => $rating_value,
3402 'rating_feedback' => $rating_feedback,
3403 ];
3404 }
3405
3406 wp_send_json([
3407 'success' => true,
3408 'sessions' => $sessions,
3409 'page' => $page,
3410 'total_pages' => $total_pages,
3411 'total_sessions' => $total_sessions,
3412 'showing_start' => $offset + 1,
3413 'showing_end' => min($offset + $per_page, $total_sessions)
3414 ]);
3415 wp_die();
3416 }
3417
3418 /**
3419 * Fetch single conversation details for split-panel view
3420 */
3421 public function mxchat_fetch_conversation() {
3422 global $wpdb;
3423 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3424 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3425
3426 if (!current_user_can('manage_options')) {
3427 wp_send_json_error(['message' => 'Insufficient permissions']);
3428 wp_die();
3429 }
3430
3431 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3432 if (empty($session_id)) {
3433 wp_send_json_error(['message' => 'No session ID provided']);
3434 wp_die();
3435 }
3436
3437 // Check for optional columns/tables
3438 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3439 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3440
3441 // Get session metadata
3442 $session_data = $originating_columns_exist
3443 ? $wpdb->get_row($wpdb->prepare(
3444 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3445 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3446 $session_id
3447 ))
3448 : $wpdb->get_row($wpdb->prepare(
3449 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3450 WHERE session_id = %s LIMIT 1",
3451 $session_id
3452 ));
3453
3454 if (!$session_data) {
3455 wp_send_json_error(['message' => 'Session not found']);
3456 wp_die();
3457 }
3458
3459 // Get clicked URLs
3460 $clicked_urls = [];
3461 if ($url_table_exists) {
3462 $url_clicks = $wpdb->get_results($wpdb->prepare(
3463 "SELECT DISTINCT clicked_url FROM {$url_clicks_table}
3464 WHERE session_id = %s ORDER BY click_timestamp ASC",
3465 $session_id
3466 ));
3467 foreach ($url_clicks as $click) {
3468 $clicked_urls[] = $click->clicked_url;
3469 }
3470 }
3471
3472 // Get all messages
3473 $messages = $wpdb->get_results($wpdb->prepare(
3474 "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC",
3475 $session_id
3476 ));
3477
3478 // Build user info
3479 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3480 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3481 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3482
3483 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3484 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3485
3486 // Get initials
3487 $initials = strtoupper(substr($display_name, 0, 2));
3488 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3489 $parts = explode(' ', $display_name);
3490 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3491 }
3492
3493 // Page info
3494 $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : '';
3495 $page_title = '';
3496 if ($page_url) {
3497 $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH);
3498 }
3499
3500 // Format messages for output
3501 $formatted_messages = [];
3502 foreach ($messages as $msg) {
3503 $is_user = ($msg->role === 'user');
3504 // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'.
3505 // Kept OUT of $is_bot so the RAG Sources link below stays bot-only.
3506 $is_agent = ($msg->role === 'agent');
3507 $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant');
3508
3509 $content = wp_kses(
3510 stripslashes($msg->message),
3511 [
3512 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3513 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3514 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []],
3515 'div' => ['class' => [], 'id' => [], 'data-nonce' => []],
3516 'img' => ['src' => [], 'alt' => [], 'class' => []],
3517 'h3' => ['class' => []],
3518 'h4' => ['class' => []],
3519 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []],
3520 'select' => ['class' => [], 'data-attribute' => []],
3521 'option' => ['value' => []],
3522 'span' => ['class' => []],
3523 'del' => [], 'ins' => [],
3524 ]
3525 );
3526 $formatted_content = $this->format_transcript_message($content);
3527
3528 $has_rag = $is_bot && !empty($msg->rag_context);
3529
3530 $formatted_messages[] = [
3531 'id' => $msg->id,
3532 'role' => $msg->role,
3533 'is_user' => $is_user,
3534 'is_agent' => $is_agent,
3535 'is_bot' => $is_bot,
3536 'content' => $formatted_content,
3537 'timestamp' => wp_date('g:i A', strtotime($msg->timestamp . ' UTC')),
3538 'full_timestamp' => wp_date('F j, Y g:i A', strtotime($msg->timestamp . ' UTC')),
3539 'has_rag' => $has_rag
3540 ];
3541 }
3542
3543 // First message timestamp for "started" display
3544 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
3545
3546 // Pull rating_feedback for this session (if any) so the details drawer can show it.
3547 $rating_feedback = '';
3548 $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
3549 if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
3550 $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
3551 "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
3552 $session_id
3553 ));
3554 }
3555
3556 wp_send_json([
3557 'success' => true,
3558 'session_id' => $session_id,
3559 'user' => [
3560 'name' => $display_name,
3561 'sub' => $display_sub,
3562 'initials' => $initials,
3563 'email' => $user_email,
3564 'identifier' => $user_identifier
3565 ],
3566 'page' => [
3567 'url' => $page_url,
3568 'title' => $page_title
3569 ],
3570 'clicked_urls' => $clicked_urls,
3571 'messages' => $formatted_messages,
3572 'message_count' => count($messages),
3573 'started' => $started,
3574 'rating_feedback' => $rating_feedback
3575 ]);
3576 wp_die();
3577 }
3578
3579
3580 /**
3581 * ALTERNATIVE: Simpler helper method using string replacement
3582 */
3583 private function highlight_clicked_links($message_content, $clicked_urls) {
3584 if (empty($clicked_urls)) {
3585 return wp_kses(
3586 $message_content,
3587 [
3588 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3589 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3590 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3591 ]
3592 );
3593 }
3594
3595 // First apply standard sanitization
3596 $message_content = wp_kses(
3597 $message_content,
3598 [
3599 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3600 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3601 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3602 ]
3603 );
3604
3605 // Process each clicked URL
3606 foreach ($clicked_urls as $clicked_url) {
3607 // Try multiple patterns to catch different link formats
3608 $patterns = [
3609 // Standard link format
3610 '/<a([^>]*href=["\']' . preg_quote($clicked_url, '/') . '["\'][^>]*)>/i',
3611 // Link with trailing slash
3612 '/<a([^>]*href=["\']' . preg_quote(rtrim($clicked_url, '/'), '/') . '\/?["\'][^>]*)>/i',
3613 // Encoded entities version
3614 '/<a([^>]*href=["\']' . preg_quote(htmlentities($clicked_url), '/') . '["\'][^>]*)>/i',
3615 ];
3616
3617 foreach ($patterns as $pattern) {
3618 if (preg_match($pattern, $message_content)) {
3619 $message_content = preg_replace_callback(
3620 $pattern,
3621 function($matches) {
3622 $full_match = $matches[0];
3623 $attributes = $matches[1];
3624
3625 // Check if it already has the class
3626 if (strpos($full_match, 'mxchat-clicked-link') !== false) {
3627 return $full_match;
3628 }
3629
3630 // Check if class attribute exists
3631 if (preg_match('/class=["\']([^"\']*)["\']/', $attributes, $class_matches)) {
3632 // Add to existing class
3633 $new_attributes = preg_replace(
3634 '/class=["\']([^"\']*)["\']/',
3635 'class="$1 mxchat-clicked-link"',
3636 $attributes
3637 );
3638 } else {
3639 // Add new class attribute
3640 $new_attributes = $attributes . ' class="mxchat-clicked-link"';
3641 }
3642
3643 // Add title if not present
3644 if (strpos($new_attributes, 'title=') === false) {
3645 $new_attributes .= ' title="User clicked this link"';
3646 }
3647
3648 return '<a' . $new_attributes . '>';
3649 },
3650 $message_content
3651 );
3652
3653 // If we found and replaced, break out of the patterns loop
3654 break;
3655 }
3656 }
3657 }
3658
3659 return $message_content;
3660 }
3661
3662 public function mxchat_create_prompts_page() {
3663 //error_log('=== DEBUG: mxchat_create_prompts_page started ===');
3664
3665 global $wpdb;
3666 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3667
3668 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
3669 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
3670
3671 // Display success message if all prompts were deleted
3672 if (isset($_GET['all_deleted']) && $_GET['all_deleted'] === 'true') {
3673 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__('All knowledge has been deleted successfully.', 'mxchat') . '</p><button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button></div>';
3674 }
3675
3676 // Set up pagination, search query, and content type filter
3677 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '';
3678 $search_query = (!empty($nonce) && wp_verify_nonce($nonce, 'mxchat_prompts_search_nonce') && isset($_GET['search'])) ? sanitize_text_field($_GET['search']) : '';
3679 $content_type_filter = isset($_GET['content_type']) ? sanitize_key($_GET['content_type']) : ''; // ADDED 2.5.6
3680 $current_page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
3681 $per_page = 25;
3682
3683 //error_log('DEBUG: Search query: ' . $search_query);
3684 //error_log('DEBUG: Content type filter: ' . $content_type_filter);
3685 //error_log('DEBUG: Current page: ' . $current_page);
3686 //error_log('DEBUG: Per page: ' . $per_page);
3687
3688 // ================================
3689 // MULTI-BOT CONFIGURATION
3690 // ================================
3691
3692 // Check for multi-bot and set up bot selection
3693 if (class_exists('MxChat_Multi_Bot_Manager')) {
3694 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
3695 $available_bots = $multi_bot_manager->get_available_bots();
3696
3697 // Get saved bot selection (user-specific first, then site-wide default)
3698 $user_id = get_current_user_id();
3699 $saved_bot_id = get_user_meta($user_id, 'mxchat_selected_knowledge_bot', true);
3700 if (empty($saved_bot_id)) {
3701 $saved_bot_id = get_option('mxchat_current_knowledge_bot', 'default');
3702 }
3703
3704 // Allow URL override but default to saved selection
3705 $current_bot_id = isset($_GET['bot_id']) ? sanitize_key($_GET['bot_id']) : $saved_bot_id;
3706 $multibot_active = true;
3707 } else {
3708 $current_bot_id = 'default';
3709 $multibot_active = false;
3710 }
3711
3712 // ================================
3713 // DATA SOURCE CONFIGURATION
3714 // ================================
3715
3716 // Get bot-specific Pinecone settings
3717 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($current_bot_id);
3718 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3719 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3720
3721 // Get OpenAI Vector Store settings
3722 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
3723 $use_vectorstore = ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1';
3724
3725 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3726 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Has API Key: ' . (!empty($pinecone_api_key) ? 'YES' : 'NO'));
3727 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Host: ' . ($pinecone_options['mxchat_pinecone_host'] ?? 'NOT SET'));
3728 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Namespace: ' . ($pinecone_options['mxchat_pinecone_namespace'] ?? 'NOT SET'));
3729
3730 if ($use_pinecone && !empty($pinecone_api_key)) {
3731 //error_log('DEBUG: Using PINECONE data source with bot-specific config');
3732 // PINECONE DATA SOURCE
3733 $data_source = 'pinecone';
3734
3735 // IMPORTANT: Pass the bot-specific $pinecone_options, not default options!
3736 // UPDATED 2.5.6: Added content_type_filter parameter
3737 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $current_page, $per_page, $current_bot_id, $content_type_filter);
3738
3739 // TEMPORARY DEBUG - Add this right after the fetch call
3740 //error_log('=== DEBUG: Bot switching issue ===');
3741 //error_log('Current bot ID: ' . $current_bot_id);
3742 //error_log('Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3743 //error_log('Records returned: ' . count($records['data'] ?? []));
3744 //error_log('Total records: ' . ($records['total'] ?? 0));
3745
3746 // Check first few records to see their bot_id
3747 if (!empty($records['data'])) {
3748 foreach (array_slice($records['data'], 0, 3) as $i => $record) {
3749 $record_bot_id = $record->bot_id ?? 'NOT_SET';
3750 //error_log('Record ' . ($i+1) . ' bot_id: ' . $record_bot_id . ', content preview: ' . substr($record->article_content ?? '', 0, 30) . '...');
3751 }
3752 }
3753 //error_log('=== END DEBUG ===');
3754
3755 $total_records = $records['total'] ?? 0;
3756 $prompts = $records['data'] ?? array();
3757 $total_in_database = $records['total_in_database'] ?? 0;
3758 $showing_recent_only = $records['showing_recent_only'] ?? false;
3759
3760 $total_pages = ceil($total_records / $per_page);
3761
3762 } else {
3763 //error_log('DEBUG: Using WORDPRESS DB data source');
3764 // WORDPRESS DB DATA SOURCE (your existing logic)
3765 $data_source = 'wordpress';
3766
3767 // Initialize these variables for WordPress DB
3768 $total_in_database = 0;
3769 $showing_recent_only = false;
3770
3771 $offset = ($current_page - 1) * $per_page;
3772
3773 // UPDATED 2.5.6: Build WHERE clause for search and content type filtering
3774 $where_clauses = array();
3775 $where_values = array();
3776
3777 if ($search_query) {
3778 $where_clauses[] = "article_content LIKE %s";
3779 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3780 }
3781
3782 if ($content_type_filter) {
3783 $where_clauses[] = "content_type = %s";
3784 $where_values[] = $content_type_filter;
3785 }
3786
3787 $sql_where = "";
3788 if (!empty($where_clauses)) {
3789 $sql_where = "WHERE " . implode(" AND ", $where_clauses);
3790 }
3791
3792 // UPDATED 2.6.3: Count unique entries (by source_url) instead of individual rows
3793 // This ensures pagination shows X entries per page, not X chunks
3794 // Entries with empty source_url are counted individually
3795 if (!empty($where_values)) {
3796 // Count unique source_urls + count of rows with empty source_url
3797 $count_query = $wpdb->prepare(
3798 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$sql_where} AND source_url != '') +
3799 (SELECT COUNT(*) FROM {$table_name} {$sql_where} AND (source_url = '' OR source_url IS NULL))",
3800 array_merge($where_values, $where_values)
3801 );
3802 } else {
3803 $count_query = "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '') +
3804 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL)";
3805 }
3806 $total_records = $wpdb->get_var($count_query);
3807 $total_pages = ceil($total_records / $per_page);
3808
3809 // UPDATED 2.6.3: Get unique source_urls for pagination, then fetch all their rows
3810 // Step 1: Get the source_urls for this page (distinct URLs ordered by latest timestamp)
3811 if (!empty($where_values)) {
3812 $urls_query = $wpdb->prepare(
3813 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name} {$sql_where}
3814 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3815 array_merge($where_values, array($per_page, $offset))
3816 );
3817 } else {
3818 $urls_query = $wpdb->prepare(
3819 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3820 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3821 $per_page, $offset
3822 );
3823 }
3824 $page_urls = $wpdb->get_results($urls_query);
3825
3826 // Step 2: Fetch all rows for these source_urls
3827 $prompts = array();
3828 if (!empty($page_urls)) {
3829 // Build URL order map to preserve newest-first ordering from step 1
3830 $url_order_map = array();
3831 $url_list = array();
3832 $has_empty_url = false;
3833 $order_index = 0;
3834 foreach ($page_urls as $url_row) {
3835 if (empty($url_row->source_url)) {
3836 $has_empty_url = true;
3837 $url_order_map['__empty__'] = $order_index++;
3838 } else {
3839 $url_list[] = $url_row->source_url;
3840 $url_order_map[$url_row->source_url] = $order_index++;
3841 }
3842 }
3843
3844 // Build query to fetch all rows for these URLs
3845 $url_conditions = array();
3846 $url_values = array();
3847
3848 if (!empty($url_list)) {
3849 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3850 $url_conditions[] = "source_url IN ($placeholders)";
3851 $url_values = array_merge($url_values, $url_list);
3852 }
3853
3854 if ($has_empty_url) {
3855 $url_conditions[] = "(source_url = '' OR source_url IS NULL)";
3856 }
3857
3858 if (!empty($url_conditions)) {
3859 $url_where = "WHERE (" . implode(" OR ", $url_conditions) . ")";
3860
3861 // Add original filters back
3862 if (!empty($where_clauses)) {
3863 $url_where .= " AND " . implode(" AND ", $where_clauses);
3864 $url_values = array_merge($url_values, $where_values);
3865 }
3866
3867 if (!empty($url_values)) {
3868 $prompts_query = $wpdb->prepare(
3869 "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC",
3870 $url_values
3871 );
3872 } else {
3873 $prompts_query = "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC";
3874 }
3875
3876 $prompts = $wpdb->get_results($prompts_query);
3877
3878 // Sort prompts by the original URL order (newest first), then by timestamp within each URL group
3879 usort($prompts, function($a, $b) use ($url_order_map) {
3880 $url_a = empty($a->source_url) ? '__empty__' : $a->source_url;
3881 $url_b = empty($b->source_url) ? '__empty__' : $b->source_url;
3882 $order_a = $url_order_map[$url_a] ?? PHP_INT_MAX;
3883 $order_b = $url_order_map[$url_b] ?? PHP_INT_MAX;
3884
3885 // First sort by URL order (newest URLs first)
3886 if ($order_a !== $order_b) {
3887 return $order_a - $order_b;
3888 }
3889
3890 // Within same URL, sort by timestamp DESC (newest chunks first)
3891 return strtotime($b->timestamp) - strtotime($a->timestamp);
3892 });
3893 }
3894 }
3895 }
3896
3897 // ================================
3898 // PAGINATION GENERATION
3899 // ================================
3900
3901 // Generate pagination links
3902 if ($total_pages > 1) {
3903 // Build a clean base URL with only necessary parameters
3904 $pagination_args = array('page' => 'mxchat-prompts');
3905
3906 // Preserve bot_id parameter if multi-bot is active
3907 if ($multibot_active && !empty($current_bot_id) && $current_bot_id !== 'default') {
3908 $pagination_args['bot_id'] = $current_bot_id;
3909 }
3910
3911 // Preserve search query and nonce if present
3912 if ($search_query) {
3913 $pagination_args['search'] = $search_query;
3914 if (!empty($nonce)) {
3915 $pagination_args['_wpnonce'] = $nonce;
3916 }
3917 }
3918
3919 // Preserve content type filter if present
3920 if ($content_type_filter) {
3921 $pagination_args['content_type'] = $content_type_filter;
3922 }
3923
3924 // Build clean base URL
3925 $base_url = add_query_arg($pagination_args, admin_url('admin.php'));
3926
3927 $page_links = paginate_links(array(
3928 'base' => $base_url . '%_%',
3929 'format' => '&paged=%#%',
3930 'prev_text' => __('&laquo; Previous', 'mxchat'),
3931 'next_text' => __('Next &raquo;', 'mxchat'),
3932 'total' => $total_pages,
3933 'current' => $current_page,
3934 'type' => 'plain',
3935 ));
3936 } else {
3937 $page_links = '';
3938 }
3939
3940 // ================================
3941 // PROCESSING STATUS RETRIEVAL
3942 // ================================
3943
3944 // Retrieve processing statuses using queue-based method
3945 $processing_statuses = $knowledge_manager->mxchat_get_processing_statuses();
3946 $pdf_status = $processing_statuses['pdf_status'];
3947 $sitemap_status = $processing_statuses['sitemap_status'];
3948 $is_processing = $processing_statuses['is_processing'];
3949
3950 //error_log('=== DEBUG: mxchat_create_prompts_page data preparation completed ===');
3951
3952 // ================================
3953 // RENDER PAGE WITH NEW SIDEBAR LAYOUT
3954 // ================================
3955
3956 // Include the new knowledge page template
3957 require_once plugin_dir_path(__FILE__) . 'admin-knowledge-page.php';
3958
3959 // Package all page data for the render function
3960 $page_data = array(
3961 'prompts' => $prompts,
3962 'total_records' => $total_records,
3963 'total_pages' => $total_pages,
3964 'current_page' => $current_page,
3965 'per_page' => $per_page,
3966 'page_links' => $page_links,
3967 'search_query' => $search_query,
3968 'content_type_filter' => $content_type_filter,
3969 'data_source' => $data_source,
3970 'use_pinecone' => $use_pinecone,
3971 'use_vectorstore' => $use_vectorstore,
3972 'multibot_active' => $multibot_active,
3973 'current_bot_id' => $current_bot_id,
3974 'pdf_status' => $pdf_status,
3975 'sitemap_status' => $sitemap_status,
3976 'is_processing' => $is_processing,
3977 'total_in_database' => $total_in_database ?? 0,
3978 'showing_recent_only' => $showing_recent_only ?? false,
3979 );
3980
3981 // Render the new sidebar-based page
3982 mxchat_render_knowledge_page($this, $knowledge_manager, $page_data);
3983 }
3984
3985 /**
3986 * Get bot-specific Pinecone configuration
3987 * Used in the knowledge retrieval functions
3988 */
3989 private function get_bot_pinecone_config($bot_id = 'default') {
3990 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
3991
3992 // If default bot or multi-bot add-on not active, use default Pinecone config
3993 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
3994 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
3995 $addon_options = get_option('mxchat_pinecone_addon_options', array());
3996 $config = array(
3997 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
3998 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
3999 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4000 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4001 );
4002 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4003 return $config;
4004 }
4005
4006 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4007
4008 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4009 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4010
4011 if (!empty($bot_pinecone_config)) {
4012 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4013 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4014 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4015 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4016 } else {
4017 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4018 }
4019
4020 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4021 }
4022
4023 public function mxchat_delete_chat_history() {
4024 if (!current_user_can('manage_options')) {
4025 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4026 wp_die();
4027 }
4028 check_ajax_referer('mxchat_delete_chat_history', 'security');
4029
4030 if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
4031 echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
4032 wp_die();
4033 }
4034
4035 global $wpdb;
4036 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4037 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
4038 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
4039
4040 // When true, any lead attached to these sessions is fully wiped (all their sessions,
4041 // across the whole table). Default false: the chat rows go away but the lead is
4042 // preserved as a separate "chat deleted" lead in the Leads tab.
4043 $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
4044
4045 $deleted_count = 0;
4046 $preserved_as_deleted_leads = 0;
4047 $emails_to_fully_wipe = [];
4048
4049 foreach ((array) $_POST['delete_session_ids'] as $session_id) {
4050 $session_id_sanitized = MxChat_Utils::sanitize_session_id($session_id);
4051 if ($session_id_sanitized === '') {
4052 continue;
4053 }
4054
4055 // Capture the lead info attached to this session *before* we delete the rows.
4056 $lead_row = $wpdb->get_row($wpdb->prepare(
4057 "SELECT user_email, user_name, MAX(timestamp) AS last_ts
4058 FROM {$table_name}
4059 WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
4060 GROUP BY user_email, user_name
4061 ORDER BY last_ts DESC LIMIT 1",
4062 $session_id_sanitized
4063 ));
4064
4065 wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
4066 $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
4067
4068 if ($has_translations) {
4069 $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
4070 }
4071
4072 delete_option('mxchat_history_' . $session_id_sanitized);
4073 delete_option('mxchat_agent_name_' . $session_id_sanitized);
4074
4075 if ($lead_row && !empty($lead_row->user_email)) {
4076 if ($also_delete_lead) {
4077 // Full-wipe requested — queue the email so that all their sessions and
4078 // related options get swept below. Also clear this session's pre-chat
4079 // capture options (they're no longer meaningful).
4080 $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
4081 delete_option('mxchat_email_' . $session_id_sanitized);
4082 delete_option('mxchat_name_' . $session_id_sanitized);
4083 } else {
4084 // Preserve the lead in a "Chat deleted" state via distinct option keys so
4085 // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
4086 update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
4087 if (!empty($lead_row->user_name)) {
4088 update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
4089 }
4090 if (!empty($lead_row->last_ts)) {
4091 update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
4092 }
4093 // Clean up pre-chat capture options for this session — chat_deleted supersedes.
4094 delete_option('mxchat_email_' . $session_id_sanitized);
4095 delete_option('mxchat_name_' . $session_id_sanitized);
4096 $preserved_as_deleted_leads++;
4097 }
4098 } else {
4099 // No lead attached — nothing to preserve. Clean up any orphan options anyway.
4100 delete_option('mxchat_email_' . $session_id_sanitized);
4101 delete_option('mxchat_name_' . $session_id_sanitized);
4102 }
4103
4104 $deleted_count++;
4105 }
4106
4107 // Opt-in full-lead wipe: sweep every remaining row + every option key (including
4108 // chat_deleted preservation) for each affected email. Reuses the same internal
4109 // helper as the Leads-tab Delete button for consistency.
4110 if (!empty($emails_to_fully_wipe)) {
4111 self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
4112 }
4113
4114 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
4115
4116 echo wp_json_encode([
4117 'success' => sprintf(
4118 esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
4119 $deleted_count
4120 ),
4121 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
4122 'leads_fully_wiped' => count($emails_to_fully_wipe),
4123 ]);
4124 wp_die();
4125 }
4126
4127 /**
4128 * Format transcript message content with markdown processing
4129 * Converts markdown links, bold, italic, code blocks, and plain URLs to HTML
4130 */
4131 private function format_transcript_message($text) {
4132 if (empty($text)) {
4133 return '';
4134 }
4135
4136 // Normalize line endings and clean up excessive whitespace
4137 $text = str_replace("\r\n", "\n", $text);
4138 $text = str_replace("\r", "\n", $text);
4139
4140 // Clean up existing <br> tags that may have been saved (legacy data)
4141 // Convert <br>, <br/>, <br /> back to newlines for consistent processing
4142 $text = preg_replace('/<br\s*\/?>\s*/i', "\n", $text);
4143
4144 // Collapse 3+ consecutive newlines to just 2 (paragraph break)
4145 $text = preg_replace('/\n{3,}/', "\n\n", $text);
4146
4147 // Process markdown headers (# Header)
4148 $text = preg_replace_callback('/^(#{1,6})\s+(.+)$/m', function($matches) {
4149 $level = strlen($matches[1]);
4150 $content = esc_html(trim($matches[2]));
4151 return "<h{$level}>{$content}</h{$level}>";
4152 }, $text);
4153
4154 // Process code blocks with triple backticks
4155 $text = preg_replace_callback('/```(\w+)?\n?([\s\S]*?)```/', function($matches) {
4156 $language = !empty($matches[1]) ? ' class="language-' . esc_attr($matches[1]) . '"' : '';
4157 $code = esc_html($matches[2]);
4158 return "<pre><code{$language}>{$code}</code></pre>";
4159 }, $text);
4160
4161 // Process inline code with single backticks
4162 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
4163
4164 // Process bold text **text** or __text__
4165 $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
4166 $text = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $text);
4167
4168 // Process italic text *text* or _text_ (but not if part of URL)
4169 $text = preg_replace('/(?<![*_\w])\*([^*]+)\*(?![*\w])/', '<em>$1</em>', $text);
4170 $text = preg_replace('/(?<![*_\w])_([^_]+)_(?![*\w])/', '<em>$1</em>', $text);
4171
4172 // Process markdown links [text](url)
4173 $text = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function($matches) {
4174 $link_text = esc_html($matches[1]);
4175 $url = esc_url($matches[2]);
4176 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$link_text}</a>";
4177 }, $text);
4178
4179 // Process citation-style brackets [URL]
4180 $text = preg_replace_callback('/\[(https?:\/\/[^\]]+)\]/', function($matches) {
4181 $url = esc_url($matches[1]);
4182 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$url}</a>";
4183 }, $text);
4184
4185 // Process standalone URLs (not already in links or img src)
4186 $text = preg_replace_callback(
4187 '/(?<!href="|src="|">)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/',
4188 function($matches) {
4189 $url = esc_url($matches[1]);
4190 // Truncate display URL if too long
4191 $display = strlen($matches[1]) > 50 ? substr($matches[1], 0, 47) . '...' : $matches[1];
4192 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$display}</a>";
4193 },
4194 $text
4195 );
4196
4197 // Process mailto links
4198 $text = preg_replace_callback('/\[([^\]]+)\]\((mailto:[^)]+)\)/', function($matches) {
4199 $link_text = esc_html($matches[1]);
4200 $mailto = esc_url($matches[2]);
4201 return "<a href=\"{$mailto}\">{$link_text}</a>";
4202 }, $text);
4203
4204 // Convert paragraphs: split by double newlines, wrap in <p> tags
4205 // This creates proper paragraph structure instead of excessive <br> tags
4206 $paragraphs = preg_split('/\n\n+/', $text);
4207
4208 // Filter out empty paragraphs but preserve content like "0"
4209 $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), function($p) {
4210 return $p !== '';
4211 }));
4212
4213 if (empty($paragraphs)) {
4214 // No content after filtering
4215 return '';
4216 } elseif (count($paragraphs) > 1) {
4217 // Multiple paragraphs - wrap each in <p> tags, convert single newlines to <br>
4218 $formatted_paragraphs = array_map(function($p) {
4219 return nl2br($p);
4220 }, $paragraphs);
4221 $text = '<p>' . implode('</p><p>', $formatted_paragraphs) . '</p>';
4222 } else {
4223 // Single paragraph - just convert newlines to <br>
4224 $text = nl2br($paragraphs[0]);
4225 }
4226
4227 return $text;
4228 }
4229
4230 /**
4231 * AJAX handler to fetch RAG context for a specific message
4232 * Used by the transcript viewer to show retrieved documents
4233 */
4234 public function mxchat_get_rag_context() {
4235 // Check permissions
4236 if (!current_user_can('manage_options')) {
4237 wp_send_json_error(['message' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4238 wp_die();
4239 }
4240
4241 // Validate message ID
4242 if (!isset($_POST['message_id']) || empty($_POST['message_id'])) {
4243 wp_send_json_error(['message' => esc_html__('Message ID is required.', 'mxchat')]);
4244 wp_die();
4245 }
4246
4247 $message_id = absint($_POST['message_id']);
4248
4249 global $wpdb;
4250 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4251
4252 // Fetch the RAG context for this message
4253 $result = $wpdb->get_row($wpdb->prepare(
4254 "SELECT rag_context FROM {$table_name} WHERE id = %d",
4255 $message_id
4256 ));
4257
4258 if (!$result || empty($result->rag_context)) {
4259 wp_send_json_error(['message' => esc_html__('No RAG context found for this message.', 'mxchat')]);
4260 wp_die();
4261 }
4262
4263 // Decode the JSON data
4264 $rag_context = json_decode($result->rag_context, true);
4265
4266 if (json_last_error() !== JSON_ERROR_NONE) {
4267 wp_send_json_error(['message' => esc_html__('Invalid RAG context data.', 'mxchat')]);
4268 wp_die();
4269 }
4270
4271 wp_send_json_success($rag_context);
4272 wp_die();
4273 }
4274
4275 public function display_admin_notices() {
4276 // Check if we're on a MXChat admin page
4277 $screen = get_current_screen();
4278 if (!$screen || strpos($screen->base, 'mxchat') === false) {
4279 return;
4280 }
4281
4282 $dismiss_button = '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button>';
4283
4284 // Check for error notices
4285 $error_notice = get_transient('mxchat_admin_notice_error');
4286 if ($error_notice) {
4287 echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post($error_notice) . '</p>' . $dismiss_button . '</div>';
4288 delete_transient('mxchat_admin_notice_error');
4289 }
4290
4291 // Check for success notices
4292 $success_notice = get_transient('mxchat_admin_notice_success');
4293 if ($success_notice) {
4294 echo '<div class="notice notice-success is-dismissible"><p>' . wp_kses_post($success_notice) . '</p>' . $dismiss_button . '</div>';
4295 delete_transient('mxchat_admin_notice_success');
4296 }
4297
4298 // Check for info notices
4299 $info_notice = get_transient('mxchat_admin_notice_info');
4300 if ($info_notice) {
4301 echo '<div class="notice notice-info is-dismissible"><p>' . wp_kses_post($info_notice) . '</p>' . $dismiss_button . '</div>';
4302 delete_transient('mxchat_admin_notice_info');
4303 }
4304 }
4305
4306
4307 public function mxchat_create_activation_page() {
4308 // Include the new Pro & Extensions page template
4309 require_once plugin_dir_path(__FILE__) . 'admin-pro-page.php';
4310
4311 // Get addons configuration from the MxChat_Addons class
4312 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
4313 $addons_instance = new MxChat_Addons();
4314 $addons_config = $addons_instance->get_addons_config();
4315
4316 // Render the consolidated Pro & Extensions page
4317 mxchat_render_pro_page($this, $addons_config);
4318 }
4319
4320 /**
4321 * Check if current activation is linked to a domain
4322 * This checks YOUR website's database, not the user's local database
4323 */
4324 public function is_current_activation_linked($domain) {
4325 $license_key = get_option('mxchat_activation_key');
4326 $email = get_option('mxchat_pro_email');
4327
4328 if (empty($license_key) || empty($email)) {
4329 return false;
4330 }
4331
4332 // Check with YOUR website's API
4333 $response = wp_remote_post('https://mxchat.ai/mxchat-api/check-domain', array(
4334 'body' => array(
4335 'license_key' => $license_key,
4336 'email' => $email,
4337 'domain' => $domain
4338 ),
4339 'timeout' => 10,
4340 'sslverify' => false
4341 ));
4342
4343 if (is_wp_error($response)) {
4344 return false;
4345 }
4346
4347 $body = json_decode(wp_remote_retrieve_body($response), true);
4348 return isset($body['success']) && $body['success'] && isset($body['data']['linked']) && $body['data']['linked'];
4349 }
4350
4351
4352 public function mxchat_actions_page_html() {
4353 if (!current_user_can('manage_options')) {
4354 return;
4355 }
4356
4357 global $wpdb;
4358 $table_name = $wpdb->prefix . 'mxchat_intents';
4359
4360 // Get stats for dashboard
4361 $total_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
4362 $enabled_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE enabled = 1");
4363 $disabled_actions = $total_actions - $enabled_actions;
4364
4365 // Get unique action types count
4366 $action_types_count = $wpdb->get_var("SELECT COUNT(DISTINCT callback_function) FROM $table_name");
4367
4368 // Get action type distribution
4369 $type_distribution_raw = $wpdb->get_results("SELECT callback_function, COUNT(*) as count FROM $table_name GROUP BY callback_function ORDER BY count DESC LIMIT 10");
4370 $available_callbacks = $this->mxchat_get_available_callbacks();
4371 $callback_groups = $this->mxchat_get_available_callbacks(true, true);
4372
4373 $action_type_distribution = array();
4374 foreach ($type_distribution_raw as $row) {
4375 $label = isset($available_callbacks[$row->callback_function]['label'])
4376 ? $available_callbacks[$row->callback_function]['label']
4377 : $row->callback_function;
4378 $action_type_distribution[$label] = $row->count;
4379 }
4380
4381 // Native function-calling (AI Tools) data — plan-mxchat-20260617-a41dee.
4382 // The AI Tools checklist reads from MxChat_Tool_Registry, the SAME single
4383 // source the chat-time function-calling loop reads, so the two never drift.
4384 if (!class_exists('MxChat_Tool_Registry')) {
4385 require_once plugin_dir_path(__FILE__) . 'class-mxchat-tool-registry.php';
4386 }
4387 if (!class_exists('MxChat_Model_Catalog')) {
4388 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
4389 }
4390 $fc_options = get_option('mxchat_options', array());
4391 $fc_current_model = isset($fc_options['model']) ? $fc_options['model'] : 'gpt-5.6-sol';
4392 $fc_model_capable = class_exists('MxChat_Model_Catalog')
4393 ? MxChat_Model_Catalog::supports_tools($fc_current_model) : true;
4394 $active_tab = (isset($_GET['tab']) && $_GET['tab'] === 'ai-tools') ? 'ai-tools' : 'dashboard';
4395
4396 // Enrich each AI Tool with the dashicon its callback already uses on the
4397 // Trigger Phrases "Choose what it does" grid, so the AI Tools cards/modal
4398 // share the same iconography (plan 8bbf98 part 4). View-layer only — the
4399 // registry's model-facing data is untouched. Default admin-generic.
4400 $fc_tools = MxChat_Tool_Registry::available_tools();
4401 foreach ($fc_tools as &$fc_tool_ref) {
4402 $fc_cb_ref = $fc_tool_ref['callback'];
4403 $fc_tool_ref['icon'] = isset($available_callbacks[$fc_cb_ref]['icon'])
4404 ? $available_callbacks[$fc_cb_ref]['icon']
4405 : 'admin-generic';
4406 }
4407 unset($fc_tool_ref);
4408
4409 // Count of ACTIVE tools — drives the AI Tools sidebar nav badge, mirroring
4410 // $total_actions for Trigger Phrases. A tool in the list = active (plan
4411 // d450a7), so the badge shows the same number the list pane shows (plan 5f7409).
4412 $total_tools = count(array_filter($fc_tools, function ($t) {
4413 return !empty($t['enabled']);
4414 }));
4415
4416 // Brave-key dependency check (plan 183856): Web Search + Image Search run on
4417 // the Brave Search API. If either is enabled as a tool but no brave_api_key is
4418 // configured, surface a graceful "key not set" notice so the admin isn't met
4419 // with a silently no-firing tool.
4420 $fc_brave_missing = false;
4421 $brave_key = isset($fc_options['brave_api_key']) ? trim($fc_options['brave_api_key']) : '';
4422 if ($brave_key === '') {
4423 foreach ($fc_tools as $fc_t) {
4424 if (!empty($fc_t['enabled']) && isset($fc_t['requires_key']) && $fc_t['requires_key'] === 'brave_api_key') {
4425 $fc_brave_missing = true;
4426 break;
4427 }
4428 }
4429 }
4430
4431 // Prepare page data
4432 $page_data = array(
4433 'total_actions' => $total_actions,
4434 'enabled_actions' => $enabled_actions,
4435 'disabled_actions' => $disabled_actions,
4436 'action_types_count' => $action_types_count,
4437 'action_type_distribution' => $action_type_distribution,
4438 'available_callbacks' => $available_callbacks,
4439 'callback_groups' => $callback_groups,
4440 // AI Tools section
4441 'active_tab' => $active_tab,
4442 'fc_enabled' => MxChat_Tool_Registry::is_enabled(),
4443 'total_tools' => $total_tools,
4444 'fc_tools' => $fc_tools,
4445 'fc_current_model' => $fc_current_model,
4446 'fc_model_capable' => $fc_model_capable,
4447 'fc_brave_missing' => $fc_brave_missing,
4448 'fc_saved' => isset($_GET['mxchat_fc_saved']),
4449 );
4450
4451 // Include and render the new template
4452 require_once plugin_dir_path(__FILE__) . 'admin-actions-page.php';
4453 mxchat_render_actions_page($this, $page_data);
4454 }
4455
4456 /**
4457 * LEGACY HTML - Preserved below for reference, to be removed in future update
4458 */
4459 function mxchat_actions_page_legacy_html() {
4460 // This function is deprecated and no longer used
4461 // The new template is in includes/admin-actions-page.php
4462 ?>
4463 <div class="wrap mxchat-wrapper">
4464 <!-- Hero Section -->
4465 <div class="mxchat-hero">
4466 <h1 class="mxchat-main-title">
4467 <span class="mxchat-gradient-text">Actions</span> Manager
4468 </h1>
4469 <p class="mxchat-hero-subtitle">
4470 <?php esc_html_e('Create and manage custom actions to enhance your chatbot\'s capabilities.', 'mxchat'); ?>
4471 </p>
4472 </div>
4473
4474 <!-- Actions Header with Search and Filter -->
4475 <div class="mxchat-actions-header">
4476 <div class="mxchat-actions-filters">
4477 <form method="get" class="mxchat-search-form">
4478 <input type="hidden" name="page" value="mxchat-actions">
4479 <div class="mxchat-search-group">
4480 <span class="dashicons dashicons-search"></span>
4481 <input type="text" name="s" class="mxchat-search-input"
4482 placeholder="<?php esc_attr_e('Search Actions', 'mxchat'); ?>"
4483 value="<?php echo esc_attr($search_term); ?>">
4484 </div>
4485 <select name="callback_filter" class="mxchat-action-filter">
4486 <option value=""><?php esc_html_e('All Action Types', 'mxchat'); ?></option>
4487 <?php foreach ($available_callbacks as $function => $callback_data) :
4488 $label = $callback_data['label']; ?>
4489 <option value="<?php echo esc_attr($function); ?>"
4490 <?php selected($callback_filter, $function); ?>>
4491 <?php echo esc_html($label); ?>
4492 </option>
4493 <?php endforeach; ?>
4494 </select>
4495 <button type="submit" class="mxchat-button-secondary">
4496 <?php esc_html_e('Filter', 'mxchat'); ?>
4497 </button>
4498 </form>
4499 </div>
4500 <div class="mxchat-actions-controls">
4501 <button type="button" id="mxchat-add-action-btn" class="mxchat-button-primary">
4502 <span class="dashicons dashicons-plus-alt"></span>
4503 <?php esc_html_e('Add New Action', 'mxchat'); ?>
4504 </button>
4505 </div>
4506 </div>
4507
4508 <!-- Actions Grid Layout - All actions in a single grid -->
4509 <div class="mxchat-actions-grid">
4510 <div class="mxchat-cards-container">
4511 <?php if (!empty($actions)) : ?>
4512 <?php foreach ($actions as $action) :
4513 $callback_function = $action->callback_function;
4514 $callback_label = isset($available_callbacks[$callback_function]['label'])
4515 ? $available_callbacks[$callback_function]['label']
4516 : $callback_function;
4517 $threshold_value = isset($action->similarity_threshold)
4518 ? round($action->similarity_threshold * 100)
4519 : 85;
4520
4521 // Check if this is a form action
4522 $is_form_action = strpos($action->intent_label, 'Form ') === 0;
4523
4524 // Get action status (enabled/disabled) - default to true if column doesn't exist
4525 $is_enabled = isset($action->enabled) ? (bool)$action->enabled : true;
4526
4527 // Get enabled bots for display
4528 $enabled_bots = [];
4529 if (isset($action->enabled_bots) && !empty($action->enabled_bots)) {
4530 $enabled_bots = json_decode($action->enabled_bots, true);
4531 if (!is_array($enabled_bots)) {
4532 $enabled_bots = ['default'];
4533 }
4534 } else {
4535 $enabled_bots = ['default']; // Backward compatibility
4536 }
4537 ?>
4538 <div class="mxchat-action-card <?php echo $is_form_action ? 'mxchat-form-action' : ''; ?>">
4539 <div class="mxchat-card-header">
4540 <div class="mxchat-card-title"><?php echo esc_html($action->intent_label); ?></div>
4541 <div class="mxchat-card-toggle">
4542 <label class="mxchat-switch">
4543 <input type="checkbox" class="mxchat-action-toggle"
4544 data-action-id="<?php echo esc_attr($action->id); ?>"
4545 <?php checked($is_enabled); ?>>
4546 <span class="mxchat-slider round"></span>
4547 </label>
4548 </div>
4549 </div>
4550
4551 <div class="mxchat-card-body">
4552 <div class="mxchat-card-description">
4553 <strong><?php esc_html_e('Type:', 'mxchat'); ?></strong>
4554 <?php echo esc_html($callback_label); ?>
4555 </div>
4556
4557 <div class="mxchat-card-phrases">
4558 <strong><?php esc_html_e('Trigger phrases:', 'mxchat'); ?></strong>
4559 <div class="mxchat-phrases-preview">
4560 <?php
4561 // Check if the helper function exists, otherwise use a simple substring
4562 if (method_exists($this, 'get_trimmed_phrases')) {
4563 echo esc_html($this->get_trimmed_phrases($action->phrases));
4564 } else {
4565 echo esc_html(strlen($action->phrases) > 100 ?
4566 substr($action->phrases, 0, 97) . '...' :
4567 $action->phrases);
4568 }
4569 ?>
4570 </div>
4571 </div>
4572
4573 <div class="mxchat-threshold-control">
4574 <div class="mxchat-threshold-label">
4575 <?php esc_html_e('Similarity Threshold:', 'mxchat'); ?>
4576 <span class="mxchat-threshold-value"><?php echo esc_html($threshold_value); ?>%</span>
4577 </div>
4578 </div>
4579
4580 <!-- Bot Availability Display (Read-only) -->
4581 <div class="mxchat-card-bots">
4582 <strong><?php esc_html_e('Assigned bots:', 'mxchat'); ?></strong>
4583 <div class="mxchat-bot-badges">
4584 <?php
4585 foreach ($enabled_bots as $bot_id) {
4586 if ($bot_id === 'default') {
4587 echo '<span class="mxchat-bot-badge">' . esc_html__('Default Bot', 'mxchat') . '</span>';
4588 } else {
4589 // Try to get bot name from multi-bot manager
4590 if (class_exists('MxChat_Multi_Bot_Core_Manager')) {
4591 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4592 $available_bots = $multi_bot_manager->get_available_bots();
4593 $bot_name = isset($available_bots[$bot_id]) ? $available_bots[$bot_id] : $bot_id;
4594 echo '<span class="mxchat-bot-badge">' . esc_html($bot_name) . '</span>';
4595 } else {
4596 echo '<span class="mxchat-bot-badge">' . esc_html($bot_id) . '</span>';
4597 }
4598 }
4599 }
4600 ?>
4601 </div>
4602 </div>
4603 </div>
4604
4605 <div class="mxchat-card-footer">
4606 <?php
4607 // Check if it's a form action
4608 $is_form_action = preg_match('/Form (\d+)/', $action->intent_label, $form_matches);
4609
4610 // Check if it's a recommendation flow action
4611 $is_flow_action = preg_match('/Recommendation Flow (\d+)/', $action->intent_label, $flow_matches);
4612
4613 if ($is_form_action) {
4614 $form_id = isset($form_matches[1]) ? $form_matches[1] : '';
4615 ?>
4616 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-forms&action=edit&form_id=' . $form_id)); ?>"
4617 class="mxchat-button-primary">
4618 <span class="dashicons dashicons-feedback"></span>
4619 <?php esc_html_e('Edit Form', 'mxchat'); ?>
4620 </a>
4621 <?php } elseif ($is_flow_action) {
4622 ?>
4623 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-smart-recommender')); ?>"
4624 class="mxchat-button-primary">
4625 <span class="dashicons dashicons-list-view"></span>
4626 <?php esc_html_e('Manage Flows', 'mxchat'); ?>
4627 </a>
4628 <?php } else { ?>
4629 <button type="button"
4630 class="mxchat-button-secondary mxchat-edit-button"
4631 data-action-id="<?php echo esc_attr($action->id); ?>"
4632 data-phrases="<?php echo esc_attr($action->phrases); ?>"
4633 data-label="<?php echo esc_attr($action->intent_label); ?>"
4634 data-threshold="<?php echo esc_attr(round($action->similarity_threshold * 100)); ?>"
4635 data-callback-function="<?php echo esc_attr($action->callback_function); ?>"
4636 data-enabled-bots="<?php echo esc_attr(json_encode($enabled_bots)); ?>">
4637 <span class="dashicons dashicons-edit"></span>
4638 <?php esc_html_e('Edit', 'mxchat'); ?>
4639 </button>
4640 <form method="post"
4641 action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
4642 class="mxchat-delete-form"
4643 onsubmit="return confirm('<?php esc_attr_e('Are you sure you want to delete this action?', 'mxchat'); ?>');">
4644 <?php wp_nonce_field('mxchat_delete_intent_nonce'); ?>
4645 <input type="hidden" name="action" value="mxchat_delete_intent">
4646 <input type="hidden" name="intent_id" value="<?php echo esc_attr($action->id); ?>">
4647 <button type="submit" class="mxchat-button-text mxchat-delete-button">
4648 <span class="dashicons dashicons-trash"></span>
4649 <?php esc_html_e('Delete', 'mxchat'); ?>
4650 </button>
4651 </form>
4652 <?php } ?>
4653 </div>
4654 </div>
4655 <?php endforeach; ?>
4656 <?php else : ?>
4657 <!-- If no actions found -->
4658 <div class="mxchat-no-actions">
4659 <div class="mxchat-empty-state">
4660 <span class="dashicons dashicons-format-chat"></span>
4661 <h2><?php esc_html_e('No actions found', 'mxchat'); ?></h2>
4662 <p><?php esc_html_e('Get started by creating your first action to enhance your chatbot.', 'mxchat'); ?></p>
4663 <button type="button" id="mxchat-create-first-action" class="mxchat-button-primary">
4664 <?php esc_html_e('Create Your First Action', 'mxchat'); ?>
4665 </button>
4666 </div>
4667 </div>
4668 <?php endif; ?>
4669 </div>
4670 </div>
4671
4672 <?php if ($total_pages > 1) : ?>
4673 <div class="mxchat-pagination">
4674 <?php
4675 echo paginate_links(array(
4676 'base' => add_query_arg('paged', '%#%'),
4677 'format' => '',
4678 'prev_text' => __('&laquo; Previous', 'mxchat'),
4679 'next_text' => __('Next &raquo;', 'mxchat'),
4680 'total' => $total_pages,
4681 'current' => $page
4682 ));
4683 ?>
4684 </div>
4685 <?php endif; ?>
4686
4687 <!-- Add/Edit Action Modal with Step-Based Approach -->
4688 <!-- Complete Modal HTML with Defined Groups Variable -->
4689 <div id="mxchat-action-modal" class="mxchat-modal" style="display: none;">
4690 <div class="mxchat-modal-content">
4691 <span class="mxchat-modal-close">&times;</span>
4692
4693 <form id="mxchat-action-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
4694 <!-- Dynamic nonce field -->
4695 <div id="action-nonce-container">
4696 <?php wp_nonce_field('mxchat_add_intent_nonce', 'add_intent_nonce'); ?>
4697 </div>
4698 <input type="hidden" name="action" id="form_action_type" value="mxchat_add_intent">
4699 <input type="hidden" name="intent_id" id="edit_action_id" value="">
4700 <input type="hidden" name="callback_function" id="callback_function" value="">
4701
4702 <!-- Step 1: Action Type Selection -->
4703 <div id="mxchat-action-step-1" class="mxchat-action-step active">
4704 <div class="mxchat-step-indicator">
4705 <div class="mxchat-step-number">1</div>
4706 <div class="mxchat-step-title"><?php esc_html_e('Select Action Type', 'mxchat'); ?></div>
4707 </div>
4708
4709 <div id="mxchat-action-type-selector" class="mxchat-action-type-selector">
4710 <div class="mxchat-action-type-search">
4711 <span class="dashicons dashicons-search"></span>
4712 <input type="text" id="action-type-search" placeholder="<?php esc_attr_e('Search action types...', 'mxchat'); ?>" class="mxchat-action-type-search-input">
4713 </div>
4714
4715 <?php
4716 // Get the callbacks - IMPORTANT: Define the $groups variable here
4717 $groups = $this->mxchat_get_available_callbacks(true, true);
4718 ?>
4719
4720 <div class="mxchat-action-type-categories">
4721 <button type="button" class="mxchat-category-button active" data-category="all"><?php esc_html_e('All', 'mxchat'); ?></button>
4722 <?php
4723 // Get unique categories from the defined groups
4724 foreach ($groups as $group_label => $group_callbacks) :
4725 $category_slug = sanitize_title($group_label);
4726 ?>
4727 <button type="button" class="mxchat-category-button" data-category="<?php echo esc_attr($category_slug); ?>"><?php echo esc_html($group_label); ?></button>
4728 <?php endforeach; ?>
4729 </div>
4730
4731 <div class="mxchat-action-types-grid">
4732 <?php
4733 // Generate action cards from available callbacks
4734 foreach ($groups as $group_label => $group_callbacks) :
4735 $category_slug = sanitize_title($group_label);
4736
4737 foreach ($group_callbacks as $function => $data) :
4738 $label = $data['label'];
4739 $icon = isset($data['icon']) ? $data['icon'] : 'admin-generic';
4740 $description = isset($data['description']) ? $data['description'] : '';
4741 $is_addon = isset($data['addon']) && $data['addon'] !== false;
4742 $addon_name = isset($data['addon_name']) ? $data['addon_name'] : '';
4743 $is_installed = isset($data['installed']) ? $data['installed'] : true;
4744 $is_promo = !empty($data['addon_promo']) && !$is_installed;
4745
4746 // Determine card status and styling
4747 $card_class = 'mxchat-action-type-card';
4748 $icon_class = 'mxchat-action-type-icon';
4749 $status_badge = '';
4750
4751 if ($is_promo) {
4752 // Promotional card — not selectable, just informational
4753 $card_class .= ' not-installed mxchat-promo-card';
4754 $status_badge = '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
4755 } elseif ($is_addon && !$is_installed) {
4756 // Add-on not installed
4757 $card_class .= ' not-installed';
4758 $status_badge .= '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
4759 }
4760
4761 // Default description if none provided
4762 if (empty($description)) {
4763 $description = sprintf(
4764 esc_html__('Use the %s action in your chatbot', 'mxchat'),
4765 $label
4766 );
4767 }
4768 ?>
4769 <div class="<?php echo esc_attr($card_class); ?>"
4770 data-category="<?php echo esc_attr($category_slug); ?>"
4771 <?php if (!$is_promo) : ?>
4772 data-value="<?php echo esc_attr($function); ?>"
4773 data-label="<?php echo esc_attr($label); ?>"
4774 <?php endif; ?>
4775 data-pro="false"
4776 data-addon="<?php echo esc_attr($is_addon ? $data['addon'] : ''); ?>"
4777 data-installed="<?php echo $is_installed ? 'true' : 'false'; ?>"
4778 <?php if ($is_promo) : ?>data-promo="true"<?php endif; ?>>
4779 <div class="<?php echo esc_attr($icon_class); ?>">
4780 <span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span>
4781 </div>
4782 <div class="mxchat-action-type-info">
4783 <h4><?php echo esc_html($label); ?></h4>
4784 <p><?php echo esc_html($description); ?></p>
4785 <?php if (!empty($status_badge)) : ?>
4786 <?php echo $status_badge; ?>
4787 <?php endif; ?>
4788
4789 <?php if ($is_promo || ($is_addon && !$is_installed)) : ?>
4790 <div class="mxchat-addon-info">
4791 <?php echo esc_html(sprintf(
4792 __('Requires %s', 'mxchat'),
4793 $addon_name
4794 )); ?>
4795 — <a href="https://mxchat.ai/" target="_blank"><?php esc_html_e('Get Add-on', 'mxchat'); ?></a>
4796 </div>
4797 <?php endif; ?>
4798 </div>
4799 </div>
4800 <?php endforeach;
4801 endforeach; ?>
4802 </div>
4803 </div>
4804
4805 <div class="mxchat-modal-actions">
4806 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
4807 <?php esc_html_e('Cancel', 'mxchat'); ?>
4808 </button>
4809 </div>
4810 </div>
4811
4812 <!-- Step 2: Action Configuration -->
4813 <div id="mxchat-action-step-2" class="mxchat-action-step">
4814 <div class="mxchat-step-indicator">
4815 <div class="mxchat-step-number">2</div>
4816 <div class="mxchat-step-title"><?php esc_html_e('Configure Action', 'mxchat'); ?></div>
4817 </div>
4818
4819 <div class="mxchat-selected-action">
4820 <button type="button" class="mxchat-back-button" id="mxchat-back-to-step-1">
4821 <span class="dashicons dashicons-arrow-left-alt"></span>
4822 <?php esc_html_e('Back to Action Types', 'mxchat'); ?>
4823 </button>
4824 <div class="mxchat-selected-action-info">
4825 <div id="selected-action-icon" class="mxchat-action-type-icon">
4826 <span class="dashicons dashicons-admin-generic"></span>
4827 </div>
4828 <div class="mxchat-selected-action-details">
4829 <h3 id="selected-action-title"><?php esc_html_e('Selected Action', 'mxchat'); ?></h3>
4830 <p id="selected-action-description"><?php esc_html_e('Configure this action for your chatbot', 'mxchat'); ?></p>
4831 </div>
4832 </div>
4833 </div>
4834
4835 <div class="mxchat-form-group">
4836 <label for="intent_label">
4837 <?php esc_html_e('Action Label (For your reference only)', 'mxchat'); ?>
4838 </label>
4839 <input name="intent_label" type="text" id="intent_label" required
4840 class="mxchat-intent-input"
4841 placeholder="<?php esc_attr_e('Example: Newsletter Signup', 'mxchat'); ?>">
4842 </div>
4843
4844 <div class="mxchat-form-group">
4845 <label for="phrases">
4846 <?php esc_html_e('Trigger Phrases (comma-separated)', 'mxchat'); ?>
4847 </label>
4848 <textarea name="phrases" id="action_phrases" rows="5" required
4849 class="mxchat-intent-textarea"
4850 placeholder="<?php esc_attr_e('Example: sign me up, subscribe me, I want to join, add me to the newsletter', 'mxchat'); ?>"></textarea>
4851 </div>
4852
4853 <div class="mxchat-form-group">
4854 <label for="similarity_threshold">
4855 <?php esc_html_e('Similarity Threshold', 'mxchat'); ?>
4856 <span class="mxchat-threshold-value-display">85%</span>
4857 </label>
4858 <div class="mxchat-slider-group modal-slider">
4859 <input type="range"
4860 name="similarity_threshold"
4861 id="similarity_threshold"
4862 min="10"
4863 max="95"
4864 value="85"
4865 class="mxchat-intent-slider"
4866 oninput="document.querySelector('.mxchat-threshold-value-display').textContent = this.value + '%'">
4867 </div>
4868 <div class="mxchat-threshold-hint">
4869 <?php esc_html_e('Lower values (10-30) make the action trigger more easily. Higher values (70-95) require more exact matches.', 'mxchat'); ?>
4870 </div>
4871 </div>
4872
4873 <!-- Bot Selection Section -->
4874 <div class="mxchat-form-group">
4875 <label for="enabled_bots">
4876 <?php esc_html_e('Which bots should we enable this action for?', 'mxchat'); ?>
4877 </label>
4878 <div class="mxchat-bot-selector">
4879 <div class="mxchat-bot-option">
4880 <label class="mxchat-checkbox-label">
4881 <input type="checkbox"
4882 name="enabled_bots[]"
4883 value="default"
4884 id="bot_default"
4885 checked="checked">
4886 <span class="mxchat-checkmark"></span>
4887 <?php esc_html_e('Default Bot', 'mxchat'); ?>
4888 </label>
4889 </div>
4890
4891 <?php if (class_exists('MxChat_Multi_Bot_Core_Manager')) :
4892 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4893 $available_bots = $multi_bot_manager->get_available_bots();
4894
4895 foreach ($available_bots as $bot_id => $bot_name) :
4896 if ($bot_id === 'default') continue; // Skip default, already shown above
4897 ?>
4898 <div class="mxchat-bot-option">
4899 <label class="mxchat-checkbox-label">
4900 <input type="checkbox"
4901 name="enabled_bots[]"
4902 value="<?php echo esc_attr($bot_id); ?>"
4903 id="bot_<?php echo esc_attr($bot_id); ?>">
4904 <span class="mxchat-checkmark"></span>
4905 <?php echo esc_html($bot_name); ?>
4906 </label>
4907 </div>
4908 <?php
4909 endforeach;
4910 endif; ?>
4911 </div>
4912 </div>
4913
4914 <div class="mxchat-modal-actions">
4915 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
4916 <?php esc_html_e('Cancel', 'mxchat'); ?>
4917 </button>
4918 <button type="submit" class="mxchat-button-primary" id="mxchat-save-action-btn">
4919 <?php esc_html_e('Save Action', 'mxchat'); ?>
4920 </button>
4921 </div>
4922 </div>
4923 </form>
4924 </div>
4925 </div>
4926
4927 <div id="mxchat-action-loading" class="mxchat-action-loading" style="display: none;">
4928 <div class="mxchat-action-loading-spinner"></div>
4929 <div class="mxchat-action-loading-text">
4930 <?php esc_html_e('Saving action, please wait...', 'mxchat'); ?>
4931 </div>
4932 </div>
4933 </div><!-- .mxchat-wrapper -->
4934 <?php
4935 }
4936 private function get_trimmed_phrases($phrases, $max_length = 100) {
4937 if (strlen($phrases) <= $max_length) {
4938 return $phrases;
4939 }
4940
4941 $trimmed = substr($phrases, 0, $max_length);
4942 $last_comma = strrpos($trimmed, ',');
4943
4944 if ($last_comma !== false) {
4945 $trimmed = substr($trimmed, 0, $last_comma);
4946 }
4947
4948 return $trimmed . '...';
4949 }
4950 public function mxchat_add_enabled_column_to_intents() {
4951 global $wpdb;
4952 $table_name = $wpdb->prefix . 'mxchat_intents';
4953
4954 // Check if the column already exists
4955 $columns = $wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'enabled'");
4956
4957 if (empty($columns)) {
4958 // Add the column with default value of 1 (enabled)
4959 $wpdb->query("ALTER TABLE $table_name ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1");
4960 }
4961 }
4962
4963 public function mxchat_handle_delete_intent() {
4964 if ( ! current_user_can( 'manage_options' ) ) {
4965 wp_die( esc_html__('Unauthorized user', 'mxchat') );
4966 }
4967
4968 check_admin_referer('mxchat_delete_intent_nonce');
4969
4970 if (isset($_POST['intent_id'])) {
4971 global $wpdb;
4972 $table_name = $wpdb->prefix . 'mxchat_intents';
4973 $intent_id = intval($_POST['intent_id']);
4974
4975 $wpdb->delete($table_name, ['id' => $intent_id], ['%d']);
4976 }
4977
4978 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
4979 exit;
4980 }
4981 public function mxchat_handle_edit_intent() {
4982 // Security checks (nonce and permissions)
4983 if (!current_user_can('manage_options')) {
4984 wp_die(esc_html__('Unauthorized user', 'mxchat'));
4985 }
4986 check_admin_referer('mxchat_edit_intent');
4987
4988 // Get POST data
4989 $intent_id = isset($_POST['intent_id']) ? absint($_POST['intent_id']) : 0;
4990 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
4991 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
4992 $threshold_percentage = isset($_POST['similarity_threshold']) ? intval($_POST['similarity_threshold']) : 85;
4993 $similarity_threshold = min(95, max(10, $threshold_percentage)) / 100; // Convert to 0.10–0.95
4994
4995 // Handle enabled_bots
4996 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
4997 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
4998
4999 // Ensure default is always included for backward compatibility
5000 if (!in_array('default', $enabled_bots)) {
5001 $enabled_bots[] = 'default';
5002 }
5003
5004 $enabled_bots_json = json_encode($enabled_bots);
5005
5006 // Validate inputs
5007 if (!$intent_id || empty($intent_label) || empty($phrases_input)) {
5008 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5009 return;
5010 }
5011
5012 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5013 if (empty($phrases_array)) {
5014 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5015 return;
5016 }
5017
5018 // Generate embeddings with improved error handling
5019 $vectors = [];
5020 $failed_phrases = [];
5021
5022 foreach ($phrases_array as $phrase) {
5023 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5024 if (is_array($embedding_vector)) {
5025 $vectors[] = $embedding_vector;
5026 } else {
5027 $failed_phrases[] = $phrase;
5028 }
5029 }
5030
5031 if (!empty($failed_phrases)) {
5032 $this->handle_embedding_error(
5033 sprintf(
5034 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5035 implode(', ', $failed_phrases)
5036 )
5037 );
5038 return;
5039 }
5040
5041 if (empty($vectors)) {
5042 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5043 return;
5044 }
5045
5046 $combined_vector = $this->mxchat_average_vectors($vectors);
5047 $serialized_vector = maybe_serialize($combined_vector);
5048
5049 // Update the database
5050 global $wpdb;
5051 $table_name = $wpdb->prefix . 'mxchat_intents';
5052
5053 $result = $wpdb->update(
5054 $table_name,
5055 array(
5056 'intent_label' => $intent_label,
5057 'phrases' => implode(', ', $phrases_array),
5058 'embedding_vector' => $serialized_vector,
5059 'similarity_threshold' => $similarity_threshold,
5060 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
5061 ),
5062 array('id' => $intent_id),
5063 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
5064 array('%d') // Where format: integer
5065 );
5066
5067 if (false === $result) {
5068 $this->handle_embedding_error(__('Failed to update action in database.', 'mxchat'));
5069 return;
5070 }
5071
5072 // Set success message and redirect
5073 set_transient('mxchat_admin_notice_success', __('Intent updated successfully!', 'mxchat'), 60);
5074
5075 $redirect_url = add_query_arg(
5076 array(
5077 'page' => 'mxchat-actions'
5078 ),
5079 admin_url('admin.php')
5080 );
5081 wp_safe_redirect($redirect_url);
5082 exit;
5083 }
5084 public function mxchat_handle_add_intent() {
5085 if (!current_user_can('manage_options')) {
5086 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5087 }
5088 check_admin_referer('mxchat_add_intent_nonce');
5089 global $wpdb;
5090 $table_name = $wpdb->prefix . 'mxchat_intents';
5091
5092 // Sanitize and get form data
5093 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5094 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5095 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5096
5097 // Get similarity threshold from form (convert percentage to decimal)
5098 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5099
5100 // Handle enabled_bots
5101 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5102 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5103
5104 // Ensure default is always included for backward compatibility with existing actions
5105 if (!in_array('default', $enabled_bots)) {
5106 $enabled_bots[] = 'default';
5107 }
5108
5109 $enabled_bots_json = json_encode($enabled_bots);
5110
5111 // Validate required fields
5112 if (empty($intent_label) || empty($callback_function) || empty($phrases_input)) {
5113 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5114 return;
5115 }
5116
5117 // Validate callback function
5118 $available_callbacks = $this->mxchat_get_available_callbacks();
5119 if (!array_key_exists($callback_function, $available_callbacks)) {
5120 $this->handle_embedding_error(__('Invalid callback function selected.', 'mxchat'));
5121 return;
5122 }
5123
5124 // Check if this is an add-on promotional placeholder (not a real action)
5125 if (!empty($available_callbacks[$callback_function]['addon_promo'])) {
5126 $addon_name = isset($available_callbacks[$callback_function]['addon_name']) ? $available_callbacks[$callback_function]['addon_name'] : __('an add-on', 'mxchat');
5127 $this->handle_embedding_error(sprintf(
5128 __('This action requires the %s to be installed and activated.', 'mxchat'),
5129 $addon_name
5130 ));
5131 return;
5132 }
5133
5134 // Process phrases
5135 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5136 if (empty($phrases_array)) {
5137 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5138 return;
5139 }
5140
5141 // Generate embeddings with improved error handling
5142 $vectors = [];
5143 $failed_phrases = [];
5144 foreach ($phrases_array as $phrase) {
5145 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5146 if (is_array($embedding_vector)) {
5147 $vectors[] = $embedding_vector;
5148 } else {
5149 $failed_phrases[] = $phrase;
5150 }
5151 }
5152
5153 // Check for embedding failures
5154 if (!empty($failed_phrases)) {
5155 $this->handle_embedding_error(
5156 sprintf(
5157 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5158 implode(', ', $failed_phrases)
5159 )
5160 );
5161 return;
5162 }
5163
5164 if (empty($vectors)) {
5165 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5166 return;
5167 }
5168
5169 // Create combined vector and insert into database
5170 $combined_vector = $this->mxchat_average_vectors($vectors);
5171 $serialized_vector = maybe_serialize($combined_vector);
5172
5173 $result = $wpdb->insert($table_name, [
5174 'intent_label' => $intent_label,
5175 'phrases' => implode(', ', $phrases_array),
5176 'embedding_vector' => $serialized_vector,
5177 'callback_function' => $callback_function,
5178 'similarity_threshold' => $similarity_threshold,
5179 'enabled_bots' => $enabled_bots_json, // NEW field
5180 ]);
5181
5182 if ($result === false) {
5183 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
5184 return;
5185 }
5186
5187 // Set success message and redirect
5188 set_transient('mxchat_admin_notice_success', __('New intent added successfully!', 'mxchat'), 60);
5189 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5190 exit;
5191 }
5192
5193
5194
5195
5196 private function handle_embedding_error($message, $redirect = true) {
5197 // Store the error message in the existing transient
5198 set_transient('mxchat_admin_notice_error', $message, 60);
5199
5200 if ($redirect) {
5201 // Redirect back to the actions page
5202 $redirect_url = add_query_arg(
5203 array(
5204 'page' => 'mxchat-actions'
5205 ),
5206 admin_url('admin.php')
5207 );
5208 wp_safe_redirect($redirect_url);
5209 exit;
5210 }
5211 }
5212
5213 /**
5214 * AJAX handler to fetch actions list for the new split-panel UI
5215 */
5216 public function mxchat_fetch_actions_list() {
5217 check_ajax_referer('mxchat_actions_nonce', 'security');
5218
5219 if (!current_user_can('manage_options')) {
5220 wp_send_json_error(__('Unauthorized', 'mxchat'));
5221 }
5222
5223 global $wpdb;
5224 $table_name = $wpdb->prefix . 'mxchat_intents';
5225
5226 $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
5227 $per_page = isset($_POST['per_page']) ? min(100, max(1, intval($_POST['per_page']))) : 50;
5228 $offset = ($page - 1) * $per_page;
5229 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
5230 $callback_filter = isset($_POST['callback_filter']) ? sanitize_text_field($_POST['callback_filter']) : '';
5231 $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
5232
5233 // Build WHERE clause
5234 $where = '1=1';
5235 $params = array();
5236
5237 if ($search) {
5238 $search_like = '%' . $wpdb->esc_like($search) . '%';
5239 $where .= ' AND (intent_label LIKE %s OR phrases LIKE %s)';
5240 $params[] = $search_like;
5241 $params[] = $search_like;
5242 }
5243
5244 if ($callback_filter) {
5245 $where .= ' AND callback_function = %s';
5246 $params[] = $callback_filter;
5247 }
5248
5249 // Get total count
5250 $count_query = "SELECT COUNT(*) FROM $table_name WHERE $where";
5251 if (!empty($params)) {
5252 $count_query = $wpdb->prepare($count_query, $params);
5253 }
5254 $total_actions = $wpdb->get_var($count_query);
5255
5256 // Get actions
5257 $query = "SELECT * FROM $table_name WHERE $where ORDER BY id $sort_order LIMIT %d OFFSET %d";
5258 $all_params = array_merge($params, array($per_page, $offset));
5259 $actions = $wpdb->get_results($wpdb->prepare($query, $all_params));
5260
5261 // Get available callbacks for labels/icons
5262 $available_callbacks = $this->mxchat_get_available_callbacks();
5263
5264 // Prefetch individual phrase counts for all fetched actions
5265 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5266 $phrase_counts = array();
5267 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5268 $action_ids = wp_list_pluck($actions, 'id');
5269 if (!empty($action_ids)) {
5270 $id_placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5271 $count_results = $wpdb->get_results($wpdb->prepare(
5272 "SELECT intent_id, COUNT(*) as cnt FROM $phrases_table WHERE intent_id IN ($id_placeholders) GROUP BY intent_id",
5273 $action_ids
5274 ));
5275 foreach ($count_results as $row) {
5276 $phrase_counts[$row->intent_id] = intval($row->cnt);
5277 }
5278 }
5279 }
5280
5281 // Format actions for response
5282 $formatted_actions = array();
5283 foreach ($actions as $action) {
5284 $callback_data = isset($available_callbacks[$action->callback_function])
5285 ? $available_callbacks[$action->callback_function]
5286 : array('label' => $action->callback_function, 'icon' => 'admin-generic');
5287
5288 $enabled_bots = json_decode($action->enabled_bots, true);
5289 if (!is_array($enabled_bots)) {
5290 $enabled_bots = array('default');
5291 }
5292
5293 $formatted_actions[] = array(
5294 'id' => intval($action->id),
5295 'label' => $action->intent_label,
5296 'phrases' => $action->phrases,
5297 'callback_function' => $action->callback_function,
5298 'callback_label' => $callback_data['label'],
5299 'icon' => isset($callback_data['icon']) ? $callback_data['icon'] : 'admin-generic',
5300 'threshold' => round($action->similarity_threshold * 100),
5301 'enabled' => (bool) $action->enabled,
5302 'enabled_bots' => $enabled_bots,
5303 'has_legacy_vector' => !empty($action->embedding_vector),
5304 'individual_phrase_count' => isset($phrase_counts[$action->id]) ? $phrase_counts[$action->id] : 0,
5305 );
5306 }
5307
5308 $total_pages = ceil($total_actions / $per_page);
5309 $showing_start = $total_actions > 0 ? $offset + 1 : 0;
5310 $showing_end = min($offset + $per_page, $total_actions);
5311
5312 wp_send_json_success(array(
5313 'actions' => $formatted_actions,
5314 'page' => $page,
5315 'per_page' => $per_page,
5316 'total_actions' => intval($total_actions),
5317 'total_pages' => $total_pages,
5318 'showing_start' => $showing_start,
5319 'showing_end' => $showing_end,
5320 ));
5321 }
5322
5323 /**
5324 * AJAX handler to toggle action enabled status
5325 */
5326 public function mxchat_toggle_action_status() {
5327 check_ajax_referer('mxchat_actions_nonce', 'security');
5328
5329 if (!current_user_can('manage_options')) {
5330 wp_send_json_error(__('Unauthorized', 'mxchat'));
5331 }
5332
5333 $action_id = isset($_POST['action_id']) ? intval($_POST['action_id']) : 0;
5334 $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
5335
5336 if (!$action_id) {
5337 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5338 }
5339
5340 global $wpdb;
5341 $table_name = $wpdb->prefix . 'mxchat_intents';
5342
5343 $result = $wpdb->update(
5344 $table_name,
5345 array('enabled' => $enabled ? 1 : 0),
5346 array('id' => $action_id),
5347 array('%d'),
5348 array('%d')
5349 );
5350
5351 if ($result === false) {
5352 wp_send_json_error(__('Failed to update action status', 'mxchat'));
5353 }
5354
5355 wp_send_json_success(array('enabled' => (bool) $enabled));
5356 }
5357
5358 /**
5359 * AJAX handler to bulk delete actions
5360 */
5361 public function mxchat_bulk_delete_actions() {
5362 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5363
5364 if (!current_user_can('manage_options')) {
5365 wp_send_json_error(__('Unauthorized', 'mxchat'));
5366 }
5367
5368 $action_ids = isset($_POST['action_ids']) ? array_map('intval', (array) $_POST['action_ids']) : array();
5369
5370 if (empty($action_ids)) {
5371 wp_send_json_error(__('No actions selected', 'mxchat'));
5372 }
5373
5374 global $wpdb;
5375 $table_name = $wpdb->prefix . 'mxchat_intents';
5376
5377 $placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5378 $query = $wpdb->prepare("DELETE FROM $table_name WHERE id IN ($placeholders)", $action_ids);
5379 $result = $wpdb->query($query);
5380
5381 if ($result === false) {
5382 wp_send_json_error(__('Failed to delete actions', 'mxchat'));
5383 }
5384
5385 // Also delete individual phrases for these intents
5386 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5387 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5388 $wpdb->query($wpdb->prepare("DELETE FROM $phrases_table WHERE intent_id IN ($placeholders)", $action_ids));
5389 }
5390
5391 wp_send_json_success(array('deleted' => $result));
5392 }
5393
5394 /**
5395 * AJAX handler to add a new intent/action
5396 */
5397 public function mxchat_add_intent_ajax() {
5398 check_ajax_referer('mxchat_add_intent_nonce', 'security');
5399
5400 if (!current_user_can('manage_options')) {
5401 wp_send_json_error(__('Unauthorized', 'mxchat'));
5402 }
5403
5404 global $wpdb;
5405 $table_name = $wpdb->prefix . 'mxchat_intents';
5406
5407 // Sanitize input
5408 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5409 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5410 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5411 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5412 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5413
5414 // Validate
5415 // Check if using new individual phrases mode or legacy mode
5416 $individual_phrases = isset($_POST['individual_phrases']) ? array_filter(array_map('sanitize_text_field', (array) $_POST['individual_phrases'])) : array();
5417 $use_individual = !empty($individual_phrases);
5418
5419 // Validate required fields (phrases not required when using individual mode)
5420 if (empty($intent_label) || empty($callback_function)) {
5421 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5422 }
5423 if (!$use_individual && empty($phrases_input)) {
5424 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5425 }
5426
5427 // Ensure default bot is included
5428 if (!in_array('default', $enabled_bots)) {
5429 $enabled_bots[] = 'default';
5430 }
5431 $enabled_bots_json = json_encode($enabled_bots);
5432
5433 if ($use_individual) {
5434 // New mode: individual phrases each get their own vector
5435 // Insert the intent row with empty legacy fields
5436 $result = $wpdb->insert(
5437 $table_name,
5438 array(
5439 'intent_label' => $intent_label,
5440 'phrases' => '',
5441 'embedding_vector' => '',
5442 'similarity_threshold' => $similarity_threshold,
5443 'callback_function' => $callback_function,
5444 'enabled' => 1,
5445 'enabled_bots' => $enabled_bots_json,
5446 )
5447 );
5448
5449 if ($result === false) {
5450 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5451 }
5452
5453 $intent_id = $wpdb->insert_id;
5454
5455 // Insert each phrase individually with its own embedding
5456 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5457 $failed_phrases = array();
5458 foreach ($individual_phrases as $phrase) {
5459 $phrase = trim($phrase);
5460 if (empty($phrase)) continue;
5461
5462 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5463 if (is_wp_error($embedding_vector)) {
5464 $failed_phrases[] = $phrase;
5465 continue;
5466 }
5467
5468 $wpdb->insert(
5469 $phrases_table,
5470 array(
5471 'intent_id' => $intent_id,
5472 'phrase' => $phrase,
5473 'embedding_vector' => maybe_serialize($embedding_vector),
5474 )
5475 );
5476 }
5477
5478 $response = array('id' => $intent_id);
5479 if (!empty($failed_phrases)) {
5480 $response['failed_phrases'] = $failed_phrases;
5481 }
5482 wp_send_json_success($response);
5483
5484 } else {
5485 // Legacy mode: combine all phrases into one embedding (backwards compatible)
5486 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5487 if (empty($phrases_array)) {
5488 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5489 }
5490
5491 // Generate embedding (combine phrases into single string for embedding)
5492 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5493 if (is_wp_error($embedding_vector)) {
5494 // Fallback: store without embedding
5495 $serialized_vector = null;
5496 } else {
5497 $serialized_vector = maybe_serialize($embedding_vector);
5498 }
5499
5500 // Insert
5501 $result = $wpdb->insert(
5502 $table_name,
5503 array(
5504 'intent_label' => $intent_label,
5505 'phrases' => implode(', ', $phrases_array),
5506 'embedding_vector' => $serialized_vector,
5507 'similarity_threshold' => $similarity_threshold,
5508 'callback_function' => $callback_function,
5509 'enabled' => 1,
5510 'enabled_bots' => $enabled_bots_json,
5511 )
5512 );
5513
5514 if ($result === false) {
5515 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5516 }
5517
5518 wp_send_json_success(array('id' => $wpdb->insert_id));
5519 }
5520 }
5521
5522 /**
5523 * AJAX handler to edit an existing intent/action
5524 */
5525 public function mxchat_edit_intent_ajax() {
5526 check_ajax_referer('mxchat_edit_intent', 'security');
5527
5528 if (!current_user_can('manage_options')) {
5529 wp_send_json_error(__('Unauthorized', 'mxchat'));
5530 }
5531
5532 global $wpdb;
5533 $table_name = $wpdb->prefix . 'mxchat_intents';
5534
5535 // Sanitize input
5536 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5537 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5538 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5539 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5540 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5541 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5542
5543 // Validate
5544 if (!$intent_id || empty($intent_label)) {
5545 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5546 }
5547
5548 // Check if phrases are managed individually (empty phrases_input means individual mode)
5549 $uses_individual_phrases = empty($phrases_input);
5550
5551 if ($uses_individual_phrases) {
5552 // Individual phrase mode: only update non-phrase fields, skip embedding regeneration
5553 $update_data = array(
5554 'intent_label' => $intent_label,
5555 'similarity_threshold' => $similarity_threshold,
5556 'callback_function' => $callback_function,
5557 'enabled_bots' => json_encode($enabled_bots),
5558 );
5559 } else {
5560 // Legacy mode: process phrases and regenerate embedding (backwards compatible for add-ons)
5561 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5562 if (empty($phrases_array)) {
5563 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5564 }
5565
5566 // Generate new embedding (combine phrases into single string for embedding)
5567 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5568 if (is_wp_error($embedding_vector)) {
5569 // Keep existing embedding
5570 $update_data = array(
5571 'intent_label' => $intent_label,
5572 'phrases' => implode(', ', $phrases_array),
5573 'similarity_threshold' => $similarity_threshold,
5574 'callback_function' => $callback_function,
5575 'enabled_bots' => json_encode($enabled_bots),
5576 );
5577 } else {
5578 $serialized_vector = maybe_serialize($embedding_vector);
5579 $update_data = array(
5580 'intent_label' => $intent_label,
5581 'phrases' => implode(', ', $phrases_array),
5582 'embedding_vector' => $serialized_vector,
5583 'similarity_threshold' => $similarity_threshold,
5584 'callback_function' => $callback_function,
5585 'enabled_bots' => json_encode($enabled_bots),
5586 );
5587 }
5588 }
5589
5590 // Ensure default bot is included
5591 if (!in_array('default', $enabled_bots)) {
5592 $enabled_bots[] = 'default';
5593 }
5594 $update_data['enabled_bots'] = json_encode($enabled_bots);
5595
5596 $result = $wpdb->update(
5597 $table_name,
5598 $update_data,
5599 array('id' => $intent_id),
5600 null,
5601 array('%d')
5602 );
5603
5604 if ($result === false) {
5605 wp_send_json_error(__('Failed to update action.', 'mxchat'));
5606 }
5607
5608 wp_send_json_success(array('updated' => true));
5609 }
5610
5611 /**
5612 * AJAX handler to delete a single intent/action
5613 */
5614 public function mxchat_delete_intent_ajax() {
5615 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5616
5617 if (!current_user_can('manage_options')) {
5618 wp_send_json_error(__('Unauthorized', 'mxchat'));
5619 }
5620
5621 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5622
5623 if (!$intent_id) {
5624 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5625 }
5626
5627 global $wpdb;
5628 $table_name = $wpdb->prefix . 'mxchat_intents';
5629
5630 $result = $wpdb->delete($table_name, array('id' => $intent_id), array('%d'));
5631
5632 if ($result === false) {
5633 wp_send_json_error(__('Failed to delete action', 'mxchat'));
5634 }
5635
5636 // Also delete individual phrases for this intent
5637 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5638 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5639 $wpdb->delete($phrases_table, array('intent_id' => $intent_id), array('%d'));
5640 }
5641
5642 wp_send_json_success(array('deleted' => true));
5643 }
5644
5645 /**
5646 * AJAX handler to add a single phrase with its own embedding to an intent
5647 */
5648 public function mxchat_add_phrase_ajax() {
5649 check_ajax_referer('mxchat_add_phrase_nonce', 'security');
5650
5651 if (!current_user_can('manage_options')) {
5652 wp_send_json_error(__('Unauthorized', 'mxchat'));
5653 }
5654
5655 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5656 $phrase = isset($_POST['phrase']) ? sanitize_text_field($_POST['phrase']) : '';
5657
5658 if (!$intent_id || empty($phrase)) {
5659 wp_send_json_error(__('Please provide an intent ID and phrase.', 'mxchat'));
5660 }
5661
5662 // Verify the intent exists
5663 global $wpdb;
5664 $intents_table = $wpdb->prefix . 'mxchat_intents';
5665 $intent = $wpdb->get_row($wpdb->prepare("SELECT id FROM $intents_table WHERE id = %d", $intent_id));
5666 if (!$intent) {
5667 wp_send_json_error(__('Action not found.', 'mxchat'));
5668 }
5669
5670 // Generate embedding for this single phrase
5671 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5672 if (is_wp_error($embedding_vector)) {
5673 wp_send_json_error(__('Failed to generate embedding: ', 'mxchat') . $embedding_vector->get_error_message());
5674 }
5675
5676 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5677 $result = $wpdb->insert(
5678 $phrases_table,
5679 array(
5680 'intent_id' => $intent_id,
5681 'phrase' => $phrase,
5682 'embedding_vector' => maybe_serialize($embedding_vector),
5683 )
5684 );
5685
5686 if ($result === false) {
5687 wp_send_json_error(__('Failed to add phrase.', 'mxchat'));
5688 }
5689
5690 wp_send_json_success(array('id' => $wpdb->insert_id, 'phrase' => $phrase));
5691 }
5692
5693 /**
5694 * AJAX handler to delete a single phrase from wp_mxchat_intent_phrases
5695 */
5696 public function mxchat_delete_phrase_ajax() {
5697 check_ajax_referer('mxchat_delete_phrase_nonce', 'security');
5698
5699 if (!current_user_can('manage_options')) {
5700 wp_send_json_error(__('Unauthorized', 'mxchat'));
5701 }
5702
5703 $phrase_id = isset($_POST['phrase_id']) ? intval($_POST['phrase_id']) : 0;
5704 if (!$phrase_id) {
5705 wp_send_json_error(__('Invalid phrase ID.', 'mxchat'));
5706 }
5707
5708 global $wpdb;
5709 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5710 $result = $wpdb->delete($phrases_table, array('id' => $phrase_id), array('%d'));
5711
5712 if ($result === false) {
5713 wp_send_json_error(__('Failed to delete phrase.', 'mxchat'));
5714 }
5715
5716 wp_send_json_success(array('deleted' => true));
5717 }
5718
5719 /**
5720 * AJAX handler to fetch individual phrases for an intent
5721 */
5722 public function mxchat_get_phrases_ajax() {
5723 check_ajax_referer('mxchat_get_phrases_nonce', 'security');
5724
5725 if (!current_user_can('manage_options')) {
5726 wp_send_json_error(__('Unauthorized', 'mxchat'));
5727 }
5728
5729 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5730 if (!$intent_id) {
5731 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
5732 }
5733
5734 global $wpdb;
5735 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5736
5737 $phrases = array();
5738 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5739 $phrases = $wpdb->get_results($wpdb->prepare(
5740 "SELECT id, phrase, created_at FROM $phrases_table WHERE intent_id = %d ORDER BY created_at ASC",
5741 $intent_id
5742 ));
5743 }
5744
5745 wp_send_json_success(array('phrases' => $phrases));
5746 }
5747
5748 /**
5749 * AJAX handler to clear legacy phrases and embedding from the main intents table
5750 */
5751 public function mxchat_delete_legacy_phrases_ajax() {
5752 check_ajax_referer('mxchat_delete_legacy_nonce', 'security');
5753
5754 if (!current_user_can('manage_options')) {
5755 wp_send_json_error(__('Unauthorized', 'mxchat'));
5756 }
5757
5758 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5759 if (!$intent_id) {
5760 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
5761 }
5762
5763 global $wpdb;
5764 $table_name = $wpdb->prefix . 'mxchat_intents';
5765
5766 $result = $wpdb->update(
5767 $table_name,
5768 array('phrases' => '', 'embedding_vector' => ''),
5769 array('id' => $intent_id),
5770 array('%s', '%s'),
5771 array('%d')
5772 );
5773
5774 if ($result === false) {
5775 wp_send_json_error(__('Failed to clear legacy phrases.', 'mxchat'));
5776 }
5777
5778 wp_send_json_success(array('cleared' => true));
5779 }
5780
5781 /**
5782 * Enhanced get_available_callbacks function with form action exclusion
5783 *
5784 * @param bool $grouped Whether to return callbacks grouped by category
5785 * @param bool $include_all Whether to include all potential actions (even if add-on not installed)
5786 * @return array Callbacks data with icons, descriptions and availability status
5787 */
5788 private function mxchat_get_available_callbacks($grouped = false, $include_all = true) {
5789 // Load WordPress plugin functions if needed
5790 if (!function_exists('get_plugins')) {
5791 require_once ABSPATH . 'wp-admin/includes/plugin.php';
5792 }
5793
5794 // Get active plugins
5795 $active_plugins = get_option('active_plugins', array());
5796
5797 // Functions to exclude from the action selector only if Pro is activated
5798 // If user doesn't have Pro, show these so they can see what they're missing
5799 $excluded_when_pro_active_functions = array(
5800 'mxchat_handle_form_collection' // Forms add-on action
5801 );
5802
5803 // Always excluded functions (regardless of Pro status)
5804 $always_excluded_functions = array();
5805
5806 // Combine exclusion lists based on Pro activation status
5807 $excluded_functions = $always_excluded_functions;
5808 if ($this->is_activated) {
5809 // Only exclude add-on managed functions if Pro is active
5810 $excluded_functions = array_merge($excluded_functions, $excluded_when_pro_active_functions);
5811 }
5812
5813 // Define add-on plugin files and their corresponding action functions
5814 $addon_plugins = array(
5815 'mxchat-woo/mxchat-woo.php' => array(
5816 'functions' => array(
5817 'mxchat_handle_product_recommendations',
5818 'mxchat_handle_order_history',
5819 'mxchat_show_product_card',
5820 'mxchat_add_to_cart',
5821 'mxchat_checkout_redirect',
5822 'mxchat_handle_featured_products'
5823 ),
5824 'name' => __('WooCommerce Add-on', 'mxchat'),
5825 'pro_required' => true
5826 ),
5827 'mxchat-perplexity/mxchat-perplexity.php' => array(
5828 'functions' => array('mxchat_perplexity_research'),
5829 'name' => __('Perplexity Add-on', 'mxchat'),
5830 'pro_required' => true
5831 ),
5832 'mxchat-forms/mxchat-forms.php' => array(
5833 'functions' => array('mxchat_handle_form_collection'),
5834 'name' => __('Forms Add-on', 'mxchat'),
5835 'pro_required' => true
5836 ),
5837 // Add other add-ons and their functions here
5838 );
5839
5840 // Get the functions that are provided by active add-ons
5841 $addon_provided_functions = array();
5842 $addon_function_mapping = array(); // Maps functions to their add-on info
5843
5844 // Check which add-ons are active
5845 foreach ($addon_plugins as $plugin_file => $addon_info) {
5846 $is_active = in_array($plugin_file, $active_plugins);
5847
5848 // For each function in this addon
5849 foreach ($addon_info['functions'] as $function) {
5850 // Consider a function installed only if:
5851 // 1. The add-on is active AND
5852 // 2. Either it doesn't require Pro OR Pro is activated
5853 $is_installed = $is_active && (!$addon_info['pro_required'] || $this->is_activated);
5854
5855 // If the add-on is installed, mark this function as provided by an add-on
5856 if ($is_installed) {
5857 $addon_provided_functions[] = $function;
5858 }
5859
5860 // Store addon info for this function regardless of installation status
5861 $addon_function_mapping[$function] = array(
5862 'addon' => basename(dirname($plugin_file)),
5863 'addon_name' => $addon_info['name'],
5864 'pro_required' => $addon_info['pro_required'],
5865 'is_active' => $is_active,
5866 'is_installed' => $is_installed
5867 );
5868 }
5869 }
5870
5871 // Core callbacks - always available in the base plugin
5872 $core_callbacks = array(
5873 'mxchat_handle_email_capture' => array(
5874 'label' => __('Loops Email Capture', 'mxchat'),
5875 'pro_only' => false,
5876 'group' => __('Customer Engagement', 'mxchat'),
5877 'icon' => 'email-alt',
5878 'description' => __('Collect visitor emails for your mailing list in Loops', 'mxchat'),
5879 'addon' => false, // Not from an add-on
5880 'installed' => true // Always installed with base plugin
5881 ),
5882 'mxchat_handle_search_request' => array(
5883 'label' => __('Brave Web Search', 'mxchat'),
5884 'pro_only' => false,
5885 'group' => __('Search Features', 'mxchat'),
5886 'icon' => 'search',
5887 'description' => __('Let users search the web directly from the chat (requires a Brave Search API key)', 'mxchat'),
5888 'addon' => false,
5889 'installed' => true
5890 ),
5891 'mxchat_handle_image_search_request' => array(
5892 'label' => __('Brave Image Search', 'mxchat'),
5893 'pro_only' => false,
5894 'group' => __('Search Features', 'mxchat'),
5895 'icon' => 'format-image',
5896 'description' => __('Search and display images in the chat conversation (requires a Brave Search API key)', 'mxchat'),
5897 'addon' => false,
5898 'installed' => true
5899 ),
5900 // Pro core features - check is_activated property
5901 'mxchat_generate_image' => array(
5902 'label' => __('Generate Image (OpenAI)', 'mxchat'),
5903 'pro_only' => false,
5904 'group' => __('Other Features', 'mxchat'),
5905 'icon' => 'art',
5906 'description' => __('Create images with GPT Image from OpenAI (requires OpenAI API key)', 'mxchat'),
5907 'addon' => false,
5908 'installed' => true
5909 ),
5910 'mxchat_generate_gemini_image' => array(
5911 'label' => __('Generate Image (Gemini)', 'mxchat'),
5912 'pro_only' => false,
5913 'group' => __('Other Features', 'mxchat'),
5914 'icon' => 'art',
5915 'description' => __('Create images with Imagen from Google (requires Gemini API key)', 'mxchat'),
5916 'addon' => false,
5917 'installed' => true
5918 ),
5919 'mxchat_handle_pdf_discussion' => array(
5920 'label' => __('Chat with PDF', 'mxchat'),
5921 'pro_only' => false,
5922 'group' => __('Other Features', 'mxchat'),
5923 'icon' => 'media-document',
5924 'description' => __('Answer questions about uploaded PDF documents', 'mxchat'),
5925 'addon' => false,
5926 'installed' => true
5927 ),
5928 'mxchat_live_agent_handover' => array(
5929 'label' => __('Slack Live Agent', 'mxchat'),
5930 'pro_only' => false,
5931 'group' => __('Customer Engagement', 'mxchat'),
5932 'icon' => 'admin-users',
5933 'description' => __('Transfer conversation to a human support agent on Slack', 'mxchat'),
5934 'addon' => false,
5935 'installed' => true
5936 ),
5937 'mxchat_telegram_live_agent_handover' => array(
5938 'label' => __('Telegram Live Agent', 'mxchat'),
5939 'pro_only' => false,
5940 'group' => __('Customer Engagement', 'mxchat'),
5941 'icon' => 'format-chat',
5942 'description' => __('Transfer conversation to a human support agent on Telegram', 'mxchat'),
5943 'addon' => false,
5944 'installed' => true
5945 ),
5946 'mxchat_handle_switch_to_chatbot_intent' => array(
5947 'label' => __('Back to Chatbot', 'mxchat'),
5948 'pro_only' => false,
5949 'group' => __('Customer Engagement', 'mxchat'),
5950 'icon' => 'backup',
5951 'description' => __('Return from live agent mode to AI chatbot', 'mxchat'),
5952 'addon' => false,
5953 'installed' => true
5954 ),
5955 );
5956
5957 // Add-on callbacks with placeholders - only include if the add-on is NOT active
5958 // These are promotional/informational only — not selectable as real actions
5959 $addon_callbacks = array(
5960 // WooCommerce Add-on
5961 'mxchat_handle_product_recommendations' => array(
5962 'label' => __('Product Recommendations', 'mxchat'),
5963 'pro_only' => false,
5964 'addon_promo' => true,
5965 'group' => __('WooCommerce Features', 'mxchat'),
5966 'icon' => 'cart',
5967 'description' => __('Suggest products based on customer preferences', 'mxchat'),
5968 ),
5969 'mxchat_handle_order_history' => array(
5970 'label' => __('Order History', 'mxchat'),
5971 'pro_only' => false,
5972 'addon_promo' => true,
5973 'group' => __('WooCommerce Features', 'mxchat'),
5974 'icon' => 'clipboard',
5975 'description' => __('Allow customers to check their order status', 'mxchat'),
5976 ),
5977 'mxchat_show_product_card' => array(
5978 'label' => __('Show Product Card', 'mxchat'),
5979 'pro_only' => false,
5980 'addon_promo' => true,
5981 'group' => __('WooCommerce Features', 'mxchat'),
5982 'icon' => 'products',
5983 'description' => __('Display product information in the chat', 'mxchat'),
5984 ),
5985 'mxchat_add_to_cart' => array(
5986 'label' => __('Add to Cart', 'mxchat'),
5987 'pro_only' => false,
5988 'addon_promo' => true,
5989 'group' => __('WooCommerce Features', 'mxchat'),
5990 'icon' => 'plus-alt',
5991 'description' => __('Add products to cart directly from chat', 'mxchat'),
5992 ),
5993 'mxchat_checkout_redirect' => array(
5994 'label' => __('Proceed to Checkout', 'mxchat'),
5995 'pro_only' => false,
5996 'addon_promo' => true,
5997 'group' => __('WooCommerce Features', 'mxchat'),
5998 'icon' => 'arrow-right-alt',
5999 'description' => __('Redirect customer to checkout page', 'mxchat'),
6000 ),
6001 'mxchat_handle_featured_products' => array(
6002 'label' => __('Featured Products Showcase', 'mxchat'),
6003 'pro_only' => false,
6004 'addon_promo' => true,
6005 'group' => __('WooCommerce Features', 'mxchat'),
6006 'icon' => 'star-filled',
6007 'description' => __('Display a curated selection of products with an AI-generated message', 'mxchat'),
6008 ),
6009
6010 // Perplexity Add-on
6011 'mxchat_perplexity_research' => array(
6012 'label' => __('Perplexity Research', 'mxchat'),
6013 'pro_only' => false,
6014 'addon_promo' => true,
6015 'group' => __('Search Features', 'mxchat'),
6016 'icon' => 'book-alt',
6017 'description' => __('Allows the chatbot to search the web for accurate, up-to-date answers', 'mxchat'),
6018 ),
6019
6020 // Forms Add-on
6021 'mxchat_handle_form_collection' => array(
6022 'label' => __('Form Collection', 'mxchat'),
6023 'pro_only' => false,
6024 'addon_promo' => true,
6025 'group' => __('Form Features', 'mxchat'),
6026 'icon' => 'feedback',
6027 'description' => __('Collect user information through custom forms in chat', 'mxchat'),
6028 ),
6029 );
6030
6031 // Enhance add-on callbacks with installation status and addon info
6032 foreach ($addon_callbacks as $function => $data) {
6033 if (isset($addon_function_mapping[$function])) {
6034 $addon_info = $addon_function_mapping[$function];
6035
6036 $addon_callbacks[$function]['addon'] = $addon_info['addon'];
6037 $addon_callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6038 $addon_callbacks[$function]['installed'] = $addon_info['is_installed'];
6039
6040 // Set pro_only based on add-on configuration
6041 $addon_callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6042 } else {
6043 $addon_callbacks[$function]['addon'] = 'unknown';
6044 $addon_callbacks[$function]['addon_name'] = __('Unknown Add-on', 'mxchat');
6045 $addon_callbacks[$function]['installed'] = false;
6046 }
6047 }
6048
6049 // Initialize callbacks with core features
6050 $callbacks = $core_callbacks;
6051
6052 // Get callbacks from active add-ons
6053 $active_addon_callbacks = apply_filters('mxchat_available_callbacks', array());
6054
6055 // Add placeholder callbacks only for add-ons that aren't active
6056 if ($include_all) {
6057 foreach ($addon_callbacks as $function => $data) {
6058 // Skip placeholders for functions provided by active add-ons
6059 if (in_array($function, $addon_provided_functions)) {
6060 continue;
6061 }
6062
6063 // Skip excluded functions
6064 if (in_array($function, $excluded_functions)) {
6065 continue;
6066 }
6067
6068 // Add the placeholder
6069 $callbacks[$function] = $data;
6070 }
6071 }
6072
6073 // Add callbacks from active add-ons (will override placeholders)
6074 foreach ($active_addon_callbacks as $function => $data) {
6075 // Skip excluded functions
6076 if (in_array($function, $excluded_functions)) {
6077 continue;
6078 }
6079
6080 // Always include callbacks from add-ons
6081 $callbacks[$function] = $data;
6082
6083 // Ensure they have the proper add-on info
6084 if (isset($addon_function_mapping[$function])) {
6085 $addon_info = $addon_function_mapping[$function];
6086 $callbacks[$function]['addon'] = $addon_info['addon'];
6087 $callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6088 $callbacks[$function]['installed'] = $addon_info['is_installed'];
6089 $callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6090 }
6091 }
6092
6093 // Just before returning callbacks, sort them to prioritize free features
6094 if (!$grouped) {
6095 // Create temporary arrays for sorting
6096 $free_callbacks = array();
6097 $pro_callbacks = array();
6098
6099 // Split callbacks into free and pro
6100 foreach ($callbacks as $key => $data) {
6101 if (isset($data['pro_only']) && $data['pro_only']) {
6102 $pro_callbacks[$key] = $data;
6103 } else {
6104 $free_callbacks[$key] = $data;
6105 }
6106 }
6107
6108 // Merge with free callbacks first
6109 $callbacks = array_merge($free_callbacks, $pro_callbacks);
6110 }
6111
6112 // Return grouped structure if requested
6113 if ($grouped) {
6114 $grouped_callbacks = array();
6115 foreach ($callbacks as $key => $data) {
6116 $group_label = isset($data['group']) ? $data['group'] : __('Other Features', 'mxchat');
6117
6118 // Ensure we carry forward all the new fields in grouped mode
6119 $callback_data = array(
6120 'label' => $data['label'],
6121 'pro_only' => isset($data['pro_only']) ? $data['pro_only'] : false,
6122 'icon' => isset($data['icon']) ? $data['icon'] : 'admin-generic',
6123 'description' => isset($data['description']) ? $data['description'] : __('Custom action for your chatbot', 'mxchat'),
6124 'addon' => isset($data['addon']) ? $data['addon'] : false,
6125 'addon_name' => isset($data['addon_name']) ? $data['addon_name'] : '',
6126 'installed' => isset($data['installed']) ? $data['installed'] : true
6127 );
6128
6129 $grouped_callbacks[$group_label][$key] = $callback_data;
6130 }
6131
6132 // Sort within each group to prioritize free features
6133 foreach ($grouped_callbacks as $group => $items) {
6134 $free_items = array();
6135 $pro_items = array();
6136
6137 foreach ($items as $key => $data) {
6138 if (isset($data['pro_only']) && $data['pro_only']) {
6139 $pro_items[$key] = $data;
6140 } else {
6141 $free_items[$key] = $data;
6142 }
6143 }
6144
6145 $grouped_callbacks[$group] = array_merge($free_items, $pro_items);
6146 }
6147
6148 return $grouped_callbacks;
6149 }
6150
6151 return $callbacks;
6152 }
6153
6154 public function mxchat_page_init() {
6155 register_setting(
6156 'mxchat_option_group',
6157 'mxchat_options',
6158 array($this, 'mxchat_sanitize')
6159 );
6160
6161 register_setting(
6162 'mxchat_option_group',
6163 'mxchat_similarity_threshold',
6164 array(
6165 'type' => 'number',
6166 'sanitize_callback' => function($value) {
6167 $value = absint($value);
6168 return min(max($value, 20), 95);
6169 },
6170 'default' => 80,
6171 )
6172 );
6173
6174 // Chatbot Settings Section
6175 add_settings_section(
6176 'mxchat_chatbot_section',
6177 esc_html__('Chatbot Settings', 'mxchat'),
6178 null,
6179 'mxchat-chatbot'
6180 );
6181
6182 // API Keys Settings Section
6183 add_settings_section(
6184 'mxchat_api_keys_section',
6185 esc_html__('API Keys', 'mxchat'),
6186 array($this, 'mxchat_api_keys_section_callback'),
6187 'mxchat-api-keys'
6188 );
6189
6190 // OpenAI API Key
6191 add_settings_field(
6192 'api_key',
6193 esc_html__('OpenAI API Key', 'mxchat'),
6194 array($this, 'api_key_callback'),
6195 'mxchat-api-keys',
6196 'mxchat_api_keys_section'
6197 );
6198
6199 // X.AI API Key
6200 add_settings_field(
6201 'xai_api_key',
6202 esc_html__('X.AI API Key', 'mxchat'),
6203 array($this, 'xai_api_key_callback'),
6204 'mxchat-api-keys',
6205 'mxchat_api_keys_section'
6206 );
6207
6208 // Claude API Key
6209 add_settings_field(
6210 'claude_api_key',
6211 esc_html__('Claude API Key', 'mxchat'),
6212 array($this, 'claude_api_key_callback'),
6213 'mxchat-api-keys',
6214 'mxchat_api_keys_section'
6215 );
6216
6217 // DeepSeek API Key
6218 add_settings_field(
6219 'deepseek_api_key',
6220 esc_html__('DeepSeek API Key', 'mxchat'),
6221 array($this, 'deepseek_api_key_callback'),
6222 'mxchat-api-keys',
6223 'mxchat_api_keys_section'
6224 );
6225
6226 // Google Gemini API Key
6227 add_settings_field(
6228 'gemini_api_key',
6229 esc_html__('Google Gemini API Key', 'mxchat'),
6230 array($this, 'gemini_api_key_callback'),
6231 'mxchat-api-keys',
6232 'mxchat_api_keys_section'
6233 );
6234
6235 // Voyage AI API Key
6236 add_settings_field(
6237 'voyage_api_key',
6238 esc_html__('Voyage AI API Key', 'mxchat'),
6239 array($this, 'voyage_api_key_callback'),
6240 'mxchat-api-keys',
6241 'mxchat_api_keys_section'
6242 );
6243
6244 // OpenRouter API Key
6245 add_settings_field(
6246 'openrouter_api_key',
6247 esc_html__('OpenRouter API Key', 'mxchat'),
6248 array($this, 'openrouter_api_key_callback'),
6249 'mxchat-api-keys',
6250 'mxchat_api_keys_section'
6251 );
6252
6253 // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6254 add_settings_field(
6255 'custom_provider',
6256 esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6257 array($this, 'custom_provider_callback'),
6258 'mxchat-api-keys',
6259 'mxchat_api_keys_section'
6260 );
6261
6262 // Loops API Key
6263 add_settings_field(
6264 'loops_api_key',
6265 esc_html__('Loops API Key', 'mxchat'),
6266 array($this, 'mxchat_loops_api_key_callback'),
6267 'mxchat-api-keys',
6268 'mxchat_api_keys_section'
6269 );
6270
6271 // Brave Search API Key
6272 add_settings_field(
6273 'brave_api_key',
6274 __('Brave API Key', 'mxchat'),
6275 array($this, 'mxchat_brave_api_key_callback'),
6276 'mxchat-api-keys',
6277 'mxchat_api_keys_section'
6278 );
6279
6280 // Similarity Threshold Slider
6281 add_settings_field(
6282 'similarity_threshold', // Field ID
6283 esc_html__('Similarity Threshold', 'mxchat'), // Field title
6284 array($this, 'mxchat_similarity_threshold_callback'), // Callback function
6285 'mxchat-chatbot', // Page
6286 'mxchat_chatbot_section' // Section
6287 );
6288
6289 // RAG Sources Limit Slider
6290 add_settings_field(
6291 'rag_sources_limit', // Field ID
6292 esc_html__('RAG Sources Limit', 'mxchat'), // Field title
6293 array($this, 'mxchat_rag_sources_limit_callback'), // Callback function
6294 'mxchat-chatbot', // Page
6295 'mxchat_chatbot_section' // Section
6296 );
6297
6298 // RAG Chunks Limit Slider
6299 add_settings_field(
6300 'rag_chunks_limit', // Field ID
6301 esc_html__('RAG Chunks Limit', 'mxchat'), // Field title
6302 array($this, 'mxchat_rag_chunks_limit_callback'), // Callback function
6303 'mxchat-chatbot', // Page
6304 'mxchat_chatbot_section' // Section
6305 );
6306
6307 add_settings_field(
6308 'append_to_body',
6309 esc_html__('Auto-Display Chatbot', 'mxchat'),
6310 array($this, 'mxchat_append_to_body_callback'),
6311 'mxchat-chatbot',
6312 'mxchat_chatbot_section'
6313 );
6314
6315 add_settings_field(
6316 'contextual_awareness_toggle',
6317 esc_html__('Contextual Awareness', 'mxchat'),
6318 array($this, 'mxchat_contextual_awareness_callback'),
6319 'mxchat-chatbot',
6320 'mxchat_chatbot_section'
6321 );
6322
6323 add_settings_field(
6324 'citation_links_toggle',
6325 esc_html__('Citation Links', 'mxchat'),
6326 array($this, 'mxchat_citation_links_toggle_callback'),
6327 'mxchat-chatbot',
6328 'mxchat_chatbot_section'
6329 );
6330
6331 // Satisfaction rating toggle (plan-a5b006).
6332 add_settings_field(
6333 'satisfaction_rating_enabled',
6334 esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6335 array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6336 'mxchat-chatbot',
6337 'mxchat_chatbot_section'
6338 );
6339
6340 // Satisfaction rating customization (plan-141a12, plan-29caac):
6341 // the 5 customization fields are now inline-rendered inside
6342 // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6343
6344 add_settings_field(
6345 'enable_streaming_toggle',
6346 esc_html__('Enable Streaming', 'mxchat'),
6347 array($this, 'enable_streaming_toggle_callback'),
6348 'mxchat-chatbot',
6349 'mxchat_chatbot_section', // Same section as your working toggle
6350 array(
6351 'class' => 'mxchat-setting-row streaming-setting',
6352 'style' => 'display: none;' // Hidden by default, shown when OpenAI/Claude selected
6353 )
6354 );
6355
6356
6357 add_settings_field(
6358 'model',
6359 esc_html__('Chat Model', 'mxchat'),
6360 array($this, 'mxchat_model_callback'),
6361 'mxchat-chatbot',
6362 'mxchat_chatbot_section'
6363 );
6364
6365 add_settings_field(
6366 'embedding_model',
6367 esc_html__('Embedding Model', 'mxchat'),
6368 array($this, 'embedding_model_callback'),
6369 'mxchat-chatbot',
6370 'mxchat_chatbot_section'
6371 );
6372
6373 add_settings_field(
6374 'system_prompt_instructions',
6375 esc_html__('AI Instructions (Behavior)', 'mxchat'),
6376 array($this, 'system_prompt_instructions_callback'),
6377 'mxchat-chatbot',
6378 'mxchat_chatbot_section'
6379 );
6380
6381
6382 add_settings_field(
6383 'top_bar_title',
6384 esc_html__('Top Bar Title', 'mxchat'),
6385 array($this, 'mxchat_top_bar_title_callback'),
6386 'mxchat-chatbot',
6387 'mxchat_chatbot_section'
6388 );
6389
6390 add_settings_field(
6391 'ai_agent_text',
6392 esc_html__('AI Agent Text', 'mxchat'),
6393 array($this, 'mxchat_ai_agent_text_callback'),
6394 'mxchat-chatbot',
6395 'mxchat_chatbot_section'
6396 );
6397
6398 add_settings_field(
6399 'enable_email_block',
6400 esc_html__('Require Email To Chat', 'mxchat'),
6401 array($this, 'enable_email_block_callback'),
6402 'mxchat-chatbot',
6403 'mxchat_chatbot_section'
6404 );
6405
6406 add_settings_field(
6407 'email_blocker_header_content',
6408 esc_html__('Require Email Chat Content', 'mxchat'),
6409 array($this, 'email_blocker_header_content_callback'),
6410 'mxchat-chatbot',
6411 'mxchat_chatbot_section'
6412 );
6413
6414 add_settings_field(
6415 'email_blocker_button_text',
6416 esc_html__('Require Email Chat Button Text', 'mxchat'),
6417 [$this, 'email_blocker_button_text_callback'],
6418 'mxchat-chatbot',
6419 'mxchat_chatbot_section'
6420 );
6421
6422 add_settings_field(
6423 'enable_name_field',
6424 esc_html__('Require Name Field', 'mxchat'),
6425 array($this, 'enable_name_field_callback'),
6426 'mxchat-chatbot',
6427 'mxchat_chatbot_section'
6428 );
6429
6430 add_settings_field(
6431 'name_field_placeholder',
6432 esc_html__('Name Field Placeholder', 'mxchat'),
6433 array($this, 'name_field_placeholder_callback'),
6434 'mxchat-chatbot',
6435 'mxchat_chatbot_section'
6436 );
6437
6438 add_settings_field(
6439 'intro_message',
6440 esc_html__('Introductory Message', 'mxchat'),
6441 array($this, 'mxchat_intro_message_callback'),
6442 'mxchat-chatbot',
6443 'mxchat_chatbot_section'
6444 );
6445
6446 add_settings_field(
6447 'input_copy',
6448 esc_html__('Input Copy', 'mxchat'),
6449 array($this, 'mxchat_input_copy_callback'),
6450 'mxchat-chatbot',
6451 'mxchat_chatbot_section'
6452 );
6453
6454 add_settings_field(
6455 'pre_chat_message',
6456 esc_html__('Chat Teaser Pop-up', 'mxchat'),
6457 array($this, 'mxchat_pre_chat_message_callback'),
6458 'mxchat-chatbot',
6459 'mxchat_chatbot_section'
6460 );
6461
6462 add_settings_field(
6463 'privacy_toggle',
6464 esc_html__('Toggle Privacy Notice', 'mxchat'),
6465 array($this, 'mxchat_privacy_toggle_callback'),
6466 'mxchat-chatbot',
6467 'mxchat_chatbot_section'
6468 );
6469
6470 add_settings_field(
6471 'complianz_toggle',
6472 esc_html__('Enable Complianz', 'mxchat'),
6473 array($this, 'mxchat_complianz_toggle_callback'),
6474 'mxchat-chatbot',
6475 'mxchat_chatbot_section'
6476 );
6477
6478 add_settings_field(
6479 'link_target_toggle',
6480 esc_html__('Open Links in a New Tab', 'mxchat'),
6481 array($this, 'mxchat_link_target_toggle_callback'),
6482 'mxchat-chatbot',
6483 'mxchat_chatbot_section'
6484 );
6485
6486 add_settings_field(
6487 'chat_persistence_toggle',
6488 esc_html__('Enable Chat Persistence', 'mxchat'),
6489 array($this, 'mxchat_chat_persistence_toggle_callback'),
6490 'mxchat-chatbot',
6491 'mxchat_chatbot_section'
6492 );
6493
6494 add_settings_field(
6495 'print_button_enabled',
6496 esc_html__('Show Download Transcript Button', 'mxchat'),
6497 array($this, 'mxchat_print_button_toggle_callback'),
6498 'mxchat-chatbot',
6499 'mxchat_chatbot_section'
6500 );
6501
6502 add_settings_field(
6503 'reset_chat_enabled',
6504 esc_html__('Show Start-New-Chat Button', 'mxchat'),
6505 array($this, 'mxchat_reset_chat_toggle_callback'),
6506 'mxchat-chatbot',
6507 'mxchat_chatbot_section'
6508 );
6509
6510 add_settings_field(
6511 'reset_chat_label',
6512 esc_html__('Start-New-Chat Button Label', 'mxchat'),
6513 array($this, 'mxchat_reset_chat_label_callback'),
6514 'mxchat-chatbot',
6515 'mxchat_chatbot_section'
6516 );
6517
6518 add_settings_field(
6519 'popular_question_1',
6520 esc_html__('Quick Question 1', 'mxchat'),
6521 array($this, 'mxchat_popular_question_1_callback'),
6522 'mxchat-chatbot',
6523 'mxchat_chatbot_section'
6524 );
6525
6526 add_settings_field(
6527 'popular_question_2',
6528 esc_html__('Quick Question 2', 'mxchat'),
6529 array($this, 'mxchat_popular_question_2_callback'),
6530 'mxchat-chatbot',
6531 'mxchat_chatbot_section'
6532 );
6533
6534 add_settings_field(
6535 'popular_question_3',
6536 esc_html__('Quick Question 3', 'mxchat'),
6537 array($this, 'mxchat_popular_question_3_callback'),
6538 'mxchat-chatbot',
6539 'mxchat_chatbot_section'
6540 );
6541
6542 add_settings_field(
6543 'additional_popular_questions',
6544 esc_html__('Additional Quick Questions', 'mxchat'),
6545 array($this, 'mxchat_additional_popular_questions_callback'),
6546 'mxchat-chatbot',
6547 'mxchat_chatbot_section'
6548 );
6549
6550
6551 add_settings_field(
6552 'rate_limits',
6553 __('Rate Limits Settings', 'mxchat'),
6554 array($this, 'mxchat_rate_limits_callback'),
6555 'mxchat-chatbot',
6556 'mxchat_chatbot_section'
6557 );
6558
6559 // Loops Settings Section
6560 add_settings_section(
6561 'mxchat_loops_section',
6562 esc_html__('Loops Settings', 'mxchat'),
6563 null,
6564 'mxchat-embed'
6565 );
6566
6567 // Loops Settings Fields (API Key moved to API Keys tab)
6568 add_settings_field(
6569 'loops_mailing_list',
6570 esc_html__('Loops Mailing List', 'mxchat'),
6571 array($this, 'mxchat_loops_mailing_list_callback'),
6572 'mxchat-embed',
6573 'mxchat_loops_section'
6574 );
6575
6576 add_settings_field(
6577 'triggered_phrase_response',
6578 esc_html__('Triggered Phrase Response', 'mxchat'),
6579 array($this, 'mxchat_triggered_phrase_response_callback'),
6580 'mxchat-embed',
6581 'mxchat_loops_section'
6582 );
6583
6584 add_settings_field(
6585 'email_capture_response',
6586 esc_html__('Email Capture Response', 'mxchat'),
6587 array($this, 'mxchat_email_capture_response_callback'),
6588 'mxchat-embed',
6589 'mxchat_loops_section'
6590 );
6591
6592 // Brave Search Settings Fields
6593 add_settings_section(
6594 'mxchat_brave_section',
6595 __('Brave Search Settings', 'mxchat'),
6596 array($this, 'mxchat_brave_section_callback'),
6597 'mxchat-embed'
6598 );
6599
6600 // Brave API Key moved to API Keys tab
6601 add_settings_field(
6602 'brave_image_count',
6603 __('Number of Images to Return', 'mxchat'),
6604 array($this, 'mxchat_brave_image_count_callback'),
6605 'mxchat-embed',
6606 'mxchat_brave_section'
6607 );
6608
6609 add_settings_field(
6610 'brave_safe_search',
6611 __('Safe Search', 'mxchat'),
6612 array($this, 'mxchat_brave_safe_search_callback'),
6613 'mxchat-embed',
6614 'mxchat_brave_section'
6615 );
6616
6617 add_settings_field(
6618 'brave_news_count',
6619 __('Number of News Articles', 'mxchat'),
6620 array($this, 'mxchat_brave_news_count_callback'),
6621 'mxchat-embed',
6622 'mxchat_brave_section'
6623 );
6624
6625 add_settings_field(
6626 'brave_country',
6627 __('Country', 'mxchat'),
6628 array($this, 'mxchat_brave_country_callback'),
6629 'mxchat-embed',
6630 'mxchat_brave_section'
6631 );
6632
6633 add_settings_field(
6634 'brave_language',
6635 __('Language', 'mxchat'),
6636 array($this, 'mxchat_brave_language_callback'),
6637 'mxchat-embed',
6638 'mxchat_brave_section'
6639 );
6640
6641 // Chat with PDF Intent Settings Fields
6642 add_settings_section(
6643 'mxchat_pdf_intent_section',
6644 __('Toolbar Settings & Intents', 'mxchat'),
6645 array($this, 'mxchat_pdf_intent_section_callback'),
6646 'mxchat-embed'
6647 );
6648
6649 add_settings_field(
6650 'chat_toolbar_toggle',
6651 __('Show Chat Toolbar', 'mxchat'),
6652 array($this, 'mxchat_chat_toolbar_toggle_callback'),
6653 'mxchat-embed',
6654 'mxchat_pdf_intent_section'
6655 );
6656
6657 // PDF Upload Button Toggle
6658 add_settings_field(
6659 'show_pdf_upload_button',
6660 __('Show PDF Upload Button', 'mxchat'),
6661 array($this, 'mxchat_show_pdf_upload_button_callback'),
6662 'mxchat-embed',
6663 'mxchat_pdf_intent_section'
6664 );
6665
6666 // Word Upload Button Toggle
6667 add_settings_field(
6668 'show_word_upload_button',
6669 __('Show Word Upload Button', 'mxchat'),
6670 array($this, 'mxchat_show_word_upload_button_callback'),
6671 'mxchat-embed',
6672 'mxchat_pdf_intent_section'
6673 );
6674
6675 add_settings_field(
6676 'pdf_intent_trigger_text',
6677 __('Intent Trigger Text', 'mxchat'),
6678 array($this, 'mxchat_pdf_intent_trigger_text_callback'),
6679 'mxchat-embed',
6680 'mxchat_pdf_intent_section'
6681 );
6682
6683 add_settings_field(
6684 'pdf_intent_success_text',
6685 __('Success Text', 'mxchat'),
6686 array($this, 'mxchat_pdf_intent_success_text_callback'),
6687 'mxchat-embed',
6688 'mxchat_pdf_intent_section'
6689 );
6690
6691 add_settings_field(
6692 'pdf_intent_error_text',
6693 __('Error Text', 'mxchat'),
6694 array($this, 'mxchat_pdf_intent_error_text_callback'),
6695 'mxchat-embed',
6696 'mxchat_pdf_intent_section'
6697 );
6698
6699 // Add PDF Maximum Pages Field
6700 add_settings_field(
6701 'pdf_max_pages',
6702 __('Maximum Document Pages', 'mxchat'),
6703 array($this, 'mxchat_pdf_max_pages_callback'),
6704 'mxchat-embed',
6705 'mxchat_pdf_intent_section'
6706 );
6707
6708 // Live Agent Settings Fields
6709 add_settings_section(
6710 'mxchat_live_agent_section',
6711 __('Live Agent Settings', 'mxchat'),
6712 array($this, 'mxchat_live_agent_section_callback'),
6713 'mxchat-embed'
6714 );
6715
6716 // Live Agent Status Fields (add at top of live agent settings)
6717 add_settings_field(
6718 'live_agent_status',
6719 __('Live Agent Status', 'mxchat'),
6720 array($this, 'mxchat_live_agent_status_callback'),
6721 'mxchat-embed',
6722 'mxchat_live_agent_section'
6723 );
6724
6725 // Slack availability schedule (plans 8ccaa2 + 99d7a4). Each handoff channel
6726 // owns an independent schedule rendered under its own Integrations tab; this
6727 // one governs Slack only. Same callback as Telegram's, parameterized.
6728 add_settings_field(
6729 'live_agent_schedule_slack',
6730 __('Availability Schedule', 'mxchat'),
6731 array($this, 'mxchat_live_agent_schedule_callback'),
6732 'mxchat-embed',
6733 'mxchat_live_agent_section',
6734 array('channel' => 'slack')
6735 );
6736
6737 add_settings_field(
6738 'live_agent_notification_message',
6739 __('Notification Message', 'mxchat'),
6740 array($this, 'mxchat_live_agent_notification_message_callback'),
6741 'mxchat-embed',
6742 'mxchat_live_agent_section'
6743 );
6744
6745 add_settings_field(
6746 'live_agent_away_message',
6747 __('Away Message', 'mxchat'),
6748 array($this, 'mxchat_live_agent_away_message_callback'),
6749 'mxchat-embed',
6750 'mxchat_live_agent_section'
6751 );
6752
6753 add_settings_field(
6754 'live_agent_user_ids',
6755 __('Slack Agent User IDs', 'mxchat'),
6756 array($this, 'mxchat_live_agent_user_ids_callback'),
6757 'mxchat-embed',
6758 'mxchat_live_agent_section'
6759 );
6760
6761 // Shared handoff channel (plan 9f7756): route every handoff into one
6762 // pre-existing channel as threads instead of creating chat-* channels.
6763 add_settings_field(
6764 'live_agent_shared_channel',
6765 __('Shared Handoff Channel', 'mxchat'),
6766 array($this, 'mxchat_live_agent_shared_channel_callback'),
6767 'mxchat-embed',
6768 'mxchat_live_agent_section'
6769 );
6770
6771 // Auto-archive per-conversation chat- channels on !endchat (plan 7458a7).
6772 // Default OFF; never touches the shared handoff channel.
6773 add_settings_field(
6774 'live_agent_archive_on_end_toggle',
6775 __('Archive Channel When Chat Ends', 'mxchat'),
6776 array($this, 'mxchat_live_agent_archive_on_end_callback'),
6777 'mxchat-embed',
6778 'mxchat_live_agent_section'
6779 );
6780
6781 add_settings_field(
6782 'live_agent_webhook_url',
6783 __('Slack Webhook URL', 'mxchat'),
6784 array($this, 'mxchat_live_agent_webhook_url_callback'),
6785 'mxchat-embed',
6786 'mxchat_live_agent_section'
6787 );
6788
6789 add_settings_field(
6790 'live_agent_secret_key',
6791 __('Slack Secret Key', 'mxchat'),
6792 array($this, 'mxchat_live_agent_secret_key_callback'),
6793 'mxchat-embed',
6794 'mxchat_live_agent_section'
6795 );
6796
6797 // Live Agent Integration Fields
6798 add_settings_field(
6799 'live_agent_bot_token',
6800 __('Slack Bot OAuth Token', 'mxchat'),
6801 array($this, 'mxchat_live_agent_bot_token_callback'),
6802 'mxchat-embed',
6803 'mxchat_live_agent_section'
6804 );
6805
6806 // Telegram Integration Section
6807 add_settings_section(
6808 'mxchat_telegram_section',
6809 __('Telegram Settings', 'mxchat'),
6810 array($this, 'mxchat_telegram_section_callback'),
6811 'mxchat-embed'
6812 );
6813
6814 add_settings_field(
6815 'telegram_status',
6816 __('Live Agent Status', 'mxchat'),
6817 array($this, 'mxchat_telegram_status_callback'),
6818 'mxchat-embed',
6819 'mxchat_telegram_section'
6820 );
6821
6822 // Telegram availability schedule (plan 99d7a4) — independent of Slack's,
6823 // rendered right under the Telegram status toggle it extends.
6824 add_settings_field(
6825 'live_agent_schedule_telegram',
6826 __('Availability Schedule', 'mxchat'),
6827 array($this, 'mxchat_live_agent_schedule_callback'),
6828 'mxchat-embed',
6829 'mxchat_telegram_section',
6830 array('channel' => 'telegram')
6831 );
6832
6833 add_settings_field(
6834 'telegram_notification_message',
6835 __('Notification Message', 'mxchat'),
6836 array($this, 'mxchat_telegram_notification_message_callback'),
6837 'mxchat-embed',
6838 'mxchat_telegram_section'
6839 );
6840
6841 add_settings_field(
6842 'telegram_away_message',
6843 __('Away Message', 'mxchat'),
6844 array($this, 'mxchat_telegram_away_message_callback'),
6845 'mxchat-embed',
6846 'mxchat_telegram_section'
6847 );
6848
6849 add_settings_field(
6850 'telegram_bot_token',
6851 __('Telegram Bot Token', 'mxchat'),
6852 array($this, 'mxchat_telegram_bot_token_callback'),
6853 'mxchat-embed',
6854 'mxchat_telegram_section'
6855 );
6856
6857 add_settings_field(
6858 'telegram_group_id',
6859 __('Telegram Group ID', 'mxchat'),
6860 array($this, 'mxchat_telegram_group_id_callback'),
6861 'mxchat-embed',
6862 'mxchat_telegram_section'
6863 );
6864
6865 add_settings_field(
6866 'telegram_webhook_secret',
6867 __('Webhook Secret Token', 'mxchat'),
6868 array($this, 'mxchat_telegram_webhook_secret_callback'),
6869 'mxchat-embed',
6870 'mxchat_telegram_section'
6871 );
6872
6873 // General Settings Section
6874 add_settings_section(
6875 'mxchat_general_section',
6876 esc_html__('YouTube Tutorials', 'mxchat'),
6877 null,
6878 'mxchat-general'
6879 );
6880 }
6881
6882 public function mxchat_prompts_page_init() {
6883 register_setting(
6884 'mxchat_prompts_options',
6885 'mxchat_prompts_options',
6886 array(
6887 'type' => 'array',
6888 'description' => __('MXChat Knowledge Base Settings', 'mxchat'),
6889 'default' => array(
6890 'mxchat_auto_sync_posts' => 0,
6891 'mxchat_auto_sync_pages' => 0,
6892 'mxchat_use_pinecone' => 0,
6893 'mxchat_pinecone_api_key' => '',
6894 'mxchat_pinecone_environment' => '',
6895 'mxchat_pinecone_index' => '',
6896 'mxchat_pinecone_host' => '',
6897 ),
6898 'sanitize_callback' => array($this, 'sanitize_prompts_options'),
6899 )
6900 );
6901
6902 add_action('admin_notices', array($this, 'sync_settings_notice'));
6903 }
6904
6905 public function mxchat_transcripts_page_init() {
6906 register_setting(
6907 'mxchat_transcripts_options',
6908 'mxchat_transcripts_options',
6909 array(
6910 'type' => 'array',
6911 'description' => __('MXChat Transcripts Notification Settings', 'mxchat'),
6912 'default' => array(
6913 'mxchat_enable_notifications' => 0,
6914 'mxchat_notification_email' => get_option('admin_email'),
6915 'mxchat_auto_delete_transcripts' => 'never',
6916 ),
6917 'sanitize_callback' => array($this, 'sanitize_transcripts_options'),
6918 )
6919 );
6920
6921 add_settings_section(
6922 'mxchat_transcripts_notification_section',
6923 esc_html__('Chat Notification Settings', 'mxchat'),
6924 array($this, 'mxchat_transcripts_notification_section_callback'),
6925 'mxchat-transcripts'
6926 );
6927
6928 add_settings_field(
6929 'mxchat_enable_notifications',
6930 esc_html__('Enable Chat Notifications', 'mxchat'),
6931 array($this, 'mxchat_enable_notifications_callback'),
6932 'mxchat-transcripts',
6933 'mxchat_transcripts_notification_section'
6934 );
6935
6936 add_settings_field(
6937 'mxchat_notification_email',
6938 esc_html__('Notification Email Address', 'mxchat'),
6939 array($this, 'mxchat_notification_email_callback'),
6940 'mxchat-transcripts',
6941 'mxchat_transcripts_notification_section'
6942 );
6943
6944 add_settings_field(
6945 'mxchat_auto_delete_transcripts',
6946 esc_html__('Auto-Delete Old Transcripts', 'mxchat'),
6947 array($this, 'mxchat_auto_delete_transcripts_callback'),
6948 'mxchat-transcripts',
6949 'mxchat_transcripts_notification_section'
6950 );
6951
6952 add_settings_field(
6953 'mxchat_retention_days',
6954 esc_html__('Custom Retention (Days)', 'mxchat'),
6955 array($this, 'mxchat_retention_days_callback'),
6956 'mxchat-transcripts',
6957 'mxchat_transcripts_notification_section'
6958 );
6959
6960 add_settings_field(
6961 'mxchat_auto_email_transcript',
6962 esc_html__('Auto-Email Full Transcript', 'mxchat'),
6963 array($this, 'mxchat_auto_email_transcript_callback'),
6964 'mxchat-transcripts',
6965 'mxchat_transcripts_notification_section'
6966 );
6967 }
6968
6969
6970 /**
6971 * Sanitize all prompts options
6972 *
6973 * @param array $input The unsanitized options array
6974 * @return array The sanitized options array
6975 */
6976 public function sanitize_prompts_options($input) {
6977 // Log the incoming input.
6978 //error_log('Sanitizing inputs: ' . print_r($input, true));
6979
6980 $sanitized = array();
6981
6982 // Boolean options
6983 $sanitized['mxchat_auto_sync_posts'] = isset($input['mxchat_auto_sync_posts']) ? 1 : 0;
6984 $sanitized['mxchat_auto_sync_pages'] = isset($input['mxchat_auto_sync_pages']) ? 1 : 0;
6985 $sanitized['mxchat_use_pinecone'] = !empty($input['mxchat_use_pinecone']) ? 1 : 0;
6986
6987 // API Key: if less than 32 characters, flag as invalid.
6988 $api_key = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
6989 if (!empty($api_key) && strlen($api_key) < 32) {
6990 add_settings_error(
6991 'mxchat_prompts_options',
6992 'invalid_api_key',
6993 __('The Pinecone API key appears to be invalid. Please check your API key.', 'mxchat')
6994 );
6995 $existing_options = get_option('mxchat_prompts_options', array());
6996 $sanitized['mxchat_pinecone_api_key'] = $existing_options['mxchat_pinecone_api_key'] ?? '';
6997 } else {
6998 $sanitized['mxchat_pinecone_api_key'] = $api_key;
6999 }
7000
7001 // Environment and Index Name
7002 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
7003 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
7004
7005 // Host: Remove protocol and validate format.
7006 $host = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
7007 $host = preg_replace('#^https?://#', '', $host);
7008 //error_log('Host after removing protocol: ' . $host);
7009 if (!empty($host)) {
7010 if (!preg_match('/^[\w-]+\.svc\.[\w-]+\.pinecone\.io$/', $host)) {
7011 add_settings_error(
7012 'mxchat_prompts_options',
7013 'invalid_host',
7014 __('The Pinecone host appears to be invalid. It should look like "mxchat-vectors-zrmsquq.svc.aped-4627-b74a.pinecone.io"', 'mxchat')
7015 );
7016 $existing_options = get_option('mxchat_prompts_options', array());
7017 $sanitized['mxchat_pinecone_host'] = $existing_options['mxchat_pinecone_host'] ?? '';
7018 } else {
7019 $sanitized['mxchat_pinecone_host'] = $host;
7020 }
7021 } else {
7022 $sanitized['mxchat_pinecone_host'] = '';
7023 }
7024
7025 //error_log('Final sanitized array: ' . print_r($sanitized, true));
7026
7027 return $sanitized;
7028 }
7029
7030 public function sync_settings_notice() {
7031 // Only show notice on our plugin page
7032 if (!isset($_GET['page']) || $_GET['page'] !== 'mxchat-prompts') {
7033 return;
7034 }
7035
7036 // Check if settings were updated
7037 if (isset($_GET['settings-updated'])) {
7038
7039 ?>
7040 <div class="notice notice-success is-dismissible">
7041 <p><?php esc_html_e('Sync settings updated successfully.', 'mxchat'); ?></p>
7042 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php esc_html_e('Dismiss this notice.', 'mxchat'); ?></span></button>
7043 </div>
7044 <?php
7045
7046 }
7047 }
7048 // Add this sanitization function to your class
7049 public function sanitize_sync_setting($input) {
7050 return (bool)$input ? __('1', 'mxchat') : __('', 'mxchat');
7051 }
7052
7053 public function mxchat_rate_limits_callback() {
7054 $all_options = get_option('mxchat_options', []);
7055
7056 // Define available rate limits
7057 $rate_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
7058
7059 // Define available timeframes
7060 $timeframes = array(
7061 'hourly' => __('Per Hour', 'mxchat'),
7062 'daily' => __('Per Day', 'mxchat'),
7063 'weekly' => __('Per Week', 'mxchat'),
7064 'monthly' => __('Per Month', 'mxchat')
7065 );
7066
7067 // Get all roles plus a "logged_out" pseudo-role
7068 $roles = wp_roles()->get_names();
7069 $roles['logged_out'] = __('Logged Out Users', 'mxchat');
7070
7071 // Start the wrapper
7072 echo '<div class="pro-feature-wrapper active">';
7073 echo '<div class="mxchat-rate-limits-container">';
7074
7075 echo '<p class="description" style="margin-bottom: 20px;">' .
7076 esc_html__('Set message limits for each user role and customize the experience when users reach those limits. You can use {limit}, {timeframe}, {count}, and {remaining} as placeholders.', 'mxchat') .
7077 '</p>';
7078
7079 // Add markdown link documentation
7080 echo '<div class="notice notice-info inline" style="margin-bottom: 20px; padding: 10px;">';
7081 echo '<p><strong>' . esc_html__('Markdown Links Supported:', 'mxchat') . '</strong></p>';
7082 echo '<p>' . esc_html__('You can include clickable links in your custom messages using markdown syntax:', 'mxchat') . '</p>';
7083 echo '<ul style="margin-left: 20px;">';
7084 echo '<li><code>[Link text](https://example.com)</code> - Creates a clickable link</li>';
7085 echo '<li><code>[Visit our pricing](https://example.com/pricing)</code> - Link with custom text</li>';
7086 echo '<li><code>Plain URLs like https://example.com will also become clickable</code></li>';
7087 echo '</ul>';
7088 echo '</div>';
7089
7090 // Output the controls for each role
7091 foreach ($roles as $role_id => $role_name) {
7092 // Get saved options or defaults
7093 $default_limit = ($role_id === 'logged_out') ? '10' : '100';
7094 $default_timeframe = 'daily';
7095 $default_message = __('Rate limit exceeded. Please try again later.', 'mxchat');
7096
7097 $selected_limit = isset($all_options['rate_limits'][$role_id]['limit'])
7098 ? $all_options['rate_limits'][$role_id]['limit']
7099 : $default_limit;
7100
7101 $selected_timeframe = isset($all_options['rate_limits'][$role_id]['timeframe'])
7102 ? $all_options['rate_limits'][$role_id]['timeframe']
7103 : $default_timeframe;
7104
7105 $custom_message = isset($all_options['rate_limits'][$role_id]['message'])
7106 ? $all_options['rate_limits'][$role_id]['message']
7107 : $default_message;
7108
7109 // Output the row
7110 echo '<div class="mxchat-rate-limit-row mxchat-autosave-section">';
7111
7112 // Role label
7113 echo '<div class="mxchat-rate-limit-role">' . esc_html($role_name) . '</div>';
7114
7115 // Controls section
7116 echo '<div class="mxchat-rate-limit-controls-wrapper">';
7117
7118 // Rate limit and timeframe controls
7119 echo '<div class="mxchat-rate-limit-controls">';
7120
7121 // Limit dropdown
7122 echo '<div>';
7123 echo '<label for="rate_limits_' . esc_attr($role_id) . '_limit">' . esc_html__('Limit:', 'mxchat') . '</label>';
7124 echo '<select
7125 id="rate_limits_' . esc_attr($role_id) . '_limit"
7126 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][limit]"
7127 class="mxchat-autosave-field">';
7128 foreach ($rate_limits as $limit) {
7129 echo '<option value="' . esc_attr($limit) . '" ' . selected($selected_limit, $limit, false) . '>' . esc_html($limit) . '</option>';
7130 }
7131 echo '</select>';
7132 echo '</div>';
7133
7134 // Timeframe dropdown
7135 echo '<div>';
7136 echo '<label for="rate_limits_' . esc_attr($role_id) . '_timeframe">' . esc_html__('Timeframe:', 'mxchat') . '</label>';
7137 echo '<select
7138 id="rate_limits_' . esc_attr($role_id) . '_timeframe"
7139 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][timeframe]"
7140 class="mxchat-autosave-field">';
7141 foreach ($timeframes as $value => $label) {
7142 echo '<option value="' . esc_attr($value) . '" ' . selected($selected_timeframe, $value, false) . '>' . esc_html($label) . '</option>';
7143 }
7144 echo '</select>';
7145 echo '</div>';
7146
7147 echo '</div>'; // End controls
7148
7149 // Custom message textarea
7150 echo '<div class="mxchat-rate-limit-message">';
7151 echo '<label for="rate_limits_' . esc_attr($role_id) . '_message">' . esc_html__('Custom Message:', 'mxchat') . '</label>';
7152 echo '<textarea
7153 id="rate_limits_' . esc_attr($role_id) . '_message"
7154 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][message]"
7155 class="mxchat-autosave-field"
7156 placeholder="' . esc_attr__('Enter custom message when rate limit is exceeded', 'mxchat') . '">' .
7157 esc_textarea($custom_message) .
7158 '</textarea>';
7159 echo '<p class="description">' .
7160 esc_html__('Example: Rate limit reached! [Visit our pricing page](https://example.com/pricing) to upgrade.', 'mxchat') .
7161 '</p>';
7162 echo '</div>'; // End message
7163
7164 echo '</div>'; // End controls wrapper
7165
7166 echo '</div>'; // End row
7167 }
7168
7169 echo '</div>'; // End container
7170
7171 echo '</div>'; // End pro-feature-wrapper
7172 }
7173
7174 private function mxchat_add_option_field($id, $title, $callback = '') {
7175 add_settings_field(
7176 $id,
7177 __($title, 'mxchat'),
7178 $callback ? array($this, $callback) : array($this, $id . '_callback'),
7179 'mxchat-max',
7180 'mxchat_setting_section_id',
7181 $id === 'model' ? ['label_for' => 'model'] : []
7182 );
7183 }
7184
7185 // API Keys Section Callback
7186 public function mxchat_api_keys_section_callback() {
7187 echo '<p>' . esc_html__('Manage all your API keys in one place. Add the API keys for the services you want to use with your chatbot.', 'mxchat') . '</p>';
7188 }
7189
7190 // OpenAI API Key
7191 public function api_key_callback() {
7192 $apiKey = isset($this->options['api_key']) ? esc_attr($this->options['api_key']) : '';
7193 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7194
7195 echo '<div class="api-key-wrapper">';
7196 echo '<input type="text" id="api_key" name="api_key" value="' . $apiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7197 echo '<button type="button" id="toggleApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7198 echo '<p class="description">' . esc_html__('Required for OpenAI GPT models and OpenAI embeddings. Get your API key from OpenAI Platform.', 'mxchat') . '</p>';
7199 echo $this->mxchat_provider_key_test_button('openai', 'api_key');
7200 echo '</div>';
7201 }
7202
7203 // X.AI API Key
7204 public function xai_api_key_callback() {
7205 $xaiApiKey = isset($this->options['xai_api_key']) ? esc_attr($this->options['xai_api_key']) : '';
7206 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7207
7208 echo '<div class="api-key-wrapper">';
7209 echo '<input type="text" id="xai_api_key" name="xai_api_key" value="' . $xaiApiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7210 echo '<button type="button" id="toggleXaiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7211 echo '<p class="description">' . esc_html__('Required for X.AI Grok models. Get your API key from X.AI Console.', 'mxchat') . '</p>';
7212 echo $this->mxchat_provider_key_test_button('xai', 'xai_api_key');
7213 echo '</div>';
7214 }
7215 // Claude API Key
7216 public function claude_api_key_callback() {
7217 $claudeApiKey = isset($this->options['claude_api_key']) ? esc_attr($this->options['claude_api_key']) : '';
7218 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7219
7220 echo '<div class="api-key-wrapper">';
7221 echo '<input type="text" id="claude_api_key" name="claude_api_key" value="' . $claudeApiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7222 echo '<button type="button" id="toggleClaudeApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7223 echo '<p class="description">' . esc_html__('Required for Anthropic Claude models. Get your API key from Anthropic Console.', 'mxchat') . '</p>';
7224 echo $this->mxchat_provider_key_test_button('claude', 'claude_api_key');
7225 echo '</div>';
7226 }
7227
7228 // DeepSeek API Key
7229 public function deepseek_api_key_callback() {
7230 $apiKey = isset($this->options['deepseek_api_key']) ? esc_attr($this->options['deepseek_api_key']) : '';
7231 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7232
7233 echo '<div class="api-key-wrapper">';
7234 echo '<input type="text" id="deepseek_api_key" name="deepseek_api_key" value="' . $apiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7235 echo '<button type="button" id="toggleDeepSeekApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7236 echo '<p class="description">' . esc_html__('Required for DeepSeek models. Get your API key from DeepSeek Platform.', 'mxchat') . '</p>';
7237 echo $this->mxchat_provider_key_test_button('deepseek', 'deepseek_api_key');
7238 echo '</div>';
7239 }
7240
7241 // Gemini API Key
7242 public function gemini_api_key_callback() {
7243 $geminiApiKey = isset($this->options['gemini_api_key']) ? esc_attr($this->options['gemini_api_key']) : '';
7244 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7245
7246 echo '<div class="api-key-wrapper">';
7247 echo '<input type="text" id="gemini_api_key" name="gemini_api_key" value="' . $geminiApiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7248 echo '<button type="button" id="toggleGeminiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7249 echo '<p class="description">' . esc_html__('Required for Google Gemini models and embeddings. Get your API key from Google AI Studio.', 'mxchat') . '</p>';
7250 echo $this->mxchat_provider_key_test_button('gemini', 'gemini_api_key');
7251 echo '</div>';
7252 }
7253
7254
7255 // OpenRouter API Key
7256 public function openrouter_api_key_callback() {
7257 $openrouterApiKey = isset($this->options['openrouter_api_key']) ? esc_attr($this->options['openrouter_api_key']) : '';
7258 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7259
7260 echo '<div class="api-key-wrapper">';
7261 echo '<input type="text" id="openrouter_api_key" name="openrouter_api_key" value="' . $openrouterApiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7262 echo '<button type="button" id="toggleOpenRouterApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7263 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7264 echo $this->mxchat_provider_key_test_button('openrouter', 'openrouter_api_key');
7265 echo '</div>';
7266 }
7267
7268 /**
7269 * Renders a "Test key" button + result target next to a built-in provider key
7270 * field, and emits the shared delegated click handler ONCE (static guard). The
7271 * button carries data-target = the key field id so the owner can test the value
7272 * they just typed (test-before-save); the AJAX handler falls back to the saved
7273 * key when the field is empty. Styled with the WP `button` class to match the
7274 * adjacent Custom-provider "Test Connection" button on this same page.
7275 * plan-mxchat-20260623-c41f74.
7276 */
7277 public function mxchat_provider_key_test_button($provider, $target_id) {
7278 static $script_emitted = false;
7279 $nonce = wp_create_nonce('mxchat_test_provider_key');
7280 $html = '<div class="mxchat-key-test" style="margin-top:8px;">'
7281 . '<button type="button" class="button mxchat-test-provider-key" data-provider="' . esc_attr($provider) . '" data-target="' . esc_attr($target_id) . '" data-nonce="' . esc_attr($nonce) . '">' . esc_html__('Test key', 'mxchat') . '</button>'
7282 . '<span class="mxchat-test-provider-key-result" style="margin-left:10px;font-size:13px;vertical-align:middle;"></span>'
7283 . '</div>';
7284 if (!$script_emitted) {
7285 $script_emitted = true;
7286 $html .= $this->mxchat_provider_key_test_script();
7287 }
7288 return $html;
7289 }
7290
7291 /**
7292 * One-time delegated click handler shared by every .mxchat-test-provider-key
7293 * button. Posts the typed key value + provider to mxchat_test_provider_key and
7294 * renders the success/error message inline. Mirrors the Custom-provider test.
7295 */
7296 private function mxchat_provider_key_test_script() {
7297 $t_testing = esc_js(__('Testing...', 'mxchat'));
7298 $t_valid = esc_js(__('Key is valid.', 'mxchat'));
7299 $t_failed = esc_js(__('Failed', 'mxchat'));
7300 $t_req = esc_js(__('Request failed', 'mxchat'));
7301 return '<script>(function(){'
7302 . 'if (window.__mxchatProviderKeyTestWired) { return; }'
7303 . 'window.__mxchatProviderKeyTestWired = true;'
7304 . 'document.addEventListener("click", function(e){'
7305 . 'var btn = e.target && e.target.closest ? e.target.closest(".mxchat-test-provider-key") : null;'
7306 . 'if (!btn) { return; }'
7307 . 'e.preventDefault();'
7308 . 'var field = btn.getAttribute("data-target") ? document.getElementById(btn.getAttribute("data-target")) : null;'
7309 . 'var out = btn.parentNode ? btn.parentNode.querySelector(".mxchat-test-provider-key-result") : null;'
7310 . 'if (out) { out.textContent = "' . $t_testing . '"; out.style.color = "#646970"; }'
7311 . 'btn.disabled = true;'
7312 . 'var fd = new FormData();'
7313 . 'fd.append("action", "mxchat_test_provider_key");'
7314 . 'fd.append("_wpnonce", btn.getAttribute("data-nonce"));'
7315 . 'fd.append("provider", btn.getAttribute("data-provider") || "");'
7316 . 'fd.append("key", field ? field.value : "");'
7317 . 'fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })'
7318 . '.then(function(r){ return r.json(); })'
7319 . '.then(function(j){'
7320 . 'if (out) {'
7321 . 'if (j && j.success) { out.textContent = "✓ " + ((j.data && j.data.message) ? j.data.message : "' . $t_valid . '"); out.style.color = "#00a32a"; }'
7322 . 'else { out.textContent = "⚠ " + ((j && j.data && j.data.message) ? j.data.message : "' . $t_failed . '"); out.style.color = "#d63638"; }'
7323 . '}'
7324 . 'btn.disabled = false;'
7325 . '})'
7326 . '.catch(function(){ if (out) { out.textContent = "⚠ ' . $t_req . '"; out.style.color = "#d63638"; } btn.disabled = false; });'
7327 . '});'
7328 . '})();</script>';
7329 }
7330
7331 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7332 public function custom_provider_callback() {
7333 $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7334 $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7335 $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7336 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7337 $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7338 $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7339 $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7340 $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7341 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7342 $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7343
7344 echo '<style>
7345 .mxchat-cp { max-width: 680px; }
7346 .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7347 .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7348 .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7349 .mxchat-cp .mxchat-cp-row > input[type="text"],
7350 .mxchat-cp .mxchat-cp-row > input[type="password"],
7351 .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7352 .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7353 .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7354 .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7355 .mxchat-cp .mxchat-cp-azure { margin: 0 0 18px; padding: 12px 14px; background: #f6f7ff; border: 1px solid #dfe1f5; border-left: 3px solid #7873f5; border-radius: 6px; max-width: 480px; }
7356 .mxchat-cp .mxchat-cp-azure > .mxchat-cp-azure-title { display: block; font-weight: 600; color: #1d2327; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; margin: 0 0 8px; }
7357 .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7358 .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7359 </style>';
7360
7361 echo '<div class="api-key-wrapper mxchat-cp">';
7362 echo '<p class="mxchat-cp-intro">' . esc_html__('Point MxChat at any OpenAI-compatible /v1/chat/completions endpoint: Ollama, LM Studio, vLLM, llama.cpp, LocalAI, Azure OpenAI, etc. Then select "Custom (OpenAI-compatible)" in the model picker.', 'mxchat') . '</p>';
7363
7364 // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7365 // so admins can configure Azure without piecing it together from each field's hint.
7366 echo '<div class="mxchat-cp-azure">';
7367 echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7368 echo '<ol>';
7369 echo '<li>' . wp_kses(__('<strong>Base URL</strong> → <code>https://&lt;resource&gt;.openai.azure.com/openai/deployments/&lt;deployment&gt;</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7370 echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7371 echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7372 echo '<li>' . wp_kses(__('<strong>API Version</strong> → required for Azure, e.g. <code>2024-08-01-preview</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7373 echo '</ol>';
7374 echo '</div>';
7375
7376 echo '<div class="mxchat-cp-row">';
7377 echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7378 echo '<input type="text" id="custom_provider_base_url" name="custom_provider_base_url" value="' . $base_url . '" class="regular-text mxchat-autosave-field" placeholder="http://localhost:11434/v1" autocomplete="off" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7379 echo '<p class="description">' . esc_html__('Examples: Ollama http://localhost:11434/v1 · LM Studio http://localhost:1234/v1 · vLLM http://gpu:8000/v1 · Azure https://<resource>.openai.azure.com/openai/deployments/<deployment>', 'mxchat') . '</p>';
7380 echo '</div>';
7381
7382 echo '<div class="mxchat-cp-row">';
7383 echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7384 echo '<input type="password" id="custom_provider_api_key" name="custom_provider_api_key" value="' . $api_key . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7385 echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7386 echo '</div>';
7387
7388 echo '<div class="mxchat-cp-row">';
7389 echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
7390 echo '<input type="text" id="custom_provider_model" name="custom_provider_model" value="' . $model_name . '" class="regular-text mxchat-autosave-field" placeholder="llama3.2" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7391 echo '<p class="description">' . esc_html__('The model identifier the upstream server expects (e.g. llama3.2, mistral, gpt-oss). For Azure this is the deployment ID — leave empty if the Base URL already includes /deployments/<deployment>.', 'mxchat') . '</p>';
7392 echo '</div>';
7393
7394 echo '<div class="mxchat-cp-row">';
7395 echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
7396 echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7397 echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
7398 echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
7399 echo '</select>';
7400 echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
7401 echo '</div>';
7402
7403 echo '<div class="mxchat-cp-row">';
7404 echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
7405 echo '<input type="text" id="custom_provider_api_version" name="custom_provider_api_version" value="' . $api_version . '" class="regular-text mxchat-autosave-field" placeholder="2024-08-01-preview" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7406 echo '<p class="description">' . esc_html__('Appended as ?api-version=... on the request URL. Required for Azure OpenAI; leave empty for non-Azure providers.', 'mxchat') . '</p>';
7407 echo '</div>';
7408
7409 // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
7410 echo '<div class="mxchat-cp-row">';
7411 echo '<label style="font-weight:600; display:block; margin:0 0 6px; color:#1d2327; font-size:13px;">' . esc_html__('Extended routing (opt-in)', 'mxchat') . '</label>';
7412 echo '<label style="display:block; margin:0 0 6px; font-weight:400;"><input type="checkbox" id="custom_provider_for_embeddings" name="custom_provider_for_embeddings" value="on"' . checked($use_embed, true, false) . ' class="mxchat-autosave-field" data-nonce="' . $nonce . '" /> ' . esc_html__('Use custom provider for embeddings', 'mxchat') . '</label>';
7413 echo '<label style="display:block; margin:0 0 0; font-weight:400;"><input type="checkbox" id="custom_provider_for_images" name="custom_provider_for_images" value="on"' . checked($use_images, true, false) . ' class="mxchat-autosave-field" data-nonce="' . $nonce . '" /> ' . esc_html__('Use custom provider for image generation', 'mxchat') . '</label>';
7414 echo '<p class="description">' . esc_html__('When off, embeddings and image generation continue to use OpenAI (current behavior). Turn on only if your endpoint exposes OpenAI-compatible /embeddings or /images/generations routes (e.g. Ollama, vLLM, LocalAI).', 'mxchat') . '</p>';
7415 echo '</div>';
7416
7417 echo '<div class="mxchat-cp-row">';
7418 echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
7419 echo '<input type="text" id="custom_provider_embedding_model" name="custom_provider_embedding_model" value="' . $embed_model . '" class="regular-text mxchat-autosave-field" placeholder="nomic-embed-text" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7420 echo '<p class="description">' . esc_html__('Only used when "Use custom provider for embeddings" is on. The embedding model name is separate from the chat model name above (e.g. Ollama embedding models: nomic-embed-text, mxbai-embed-large). Leave blank to fall back to the chat model name.', 'mxchat') . '</p>';
7421 echo '</div>';
7422
7423 echo '<div class="mxchat-cp-test">';
7424 echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
7425 echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
7426 echo '</div>';
7427
7428 echo '<script>(function(){
7429 var btn = document.getElementById("mxchat-test-custom-provider");
7430 if (!btn || btn._wired) { return; } btn._wired = true;
7431 btn.addEventListener("click", function(){
7432 var out = document.getElementById("mxchat-test-custom-provider-result");
7433 out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
7434 out.style.color = "#646970";
7435 var fd = new FormData();
7436 fd.append("action", "mxchat_test_custom_provider");
7437 fd.append("_wpnonce", btn.getAttribute("data-nonce"));
7438 fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
7439 .then(function(r){ return r.json(); })
7440 .then(function(j){
7441 if (j && j.success) {
7442 out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
7443 out.style.color = "#00a32a";
7444 } else {
7445 out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
7446 out.style.color = "#d63638";
7447 }
7448 })
7449 .catch(function(){
7450 out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
7451 out.style.color = "#d63638";
7452 });
7453 });
7454 })();</script>';
7455
7456 echo '</div>';
7457 }
7458
7459 // Voyage API Key
7460 public function voyage_api_key_callback() {
7461 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
7462 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7463
7464 echo '<div class="api-key-wrapper">';
7465 echo '<input type="text" id="voyage_api_key" name="voyage_api_key" value="' . $apiKey . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7466 echo '<button type="button" id="toggleVoyageAPIKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7467 echo '<p class="description">' . esc_html__('Required for Voyage AI embedding models. Get your API key from Voyage AI.', 'mxchat') . '</p>';
7468 echo '</div>';
7469 }
7470
7471 public function mxchat_loops_api_key_callback() {
7472 $loops_api_key = isset($this->options['loops_api_key']) ? esc_attr($this->options['loops_api_key']) : '';
7473 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7474
7475 echo '<div class="api-key-wrapper">';
7476 echo sprintf(
7477 '<input type="text" id="loops_api_key" name="loops_api_key" value="%s" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="%s" />',
7478 $loops_api_key,
7479 $nonce
7480 );
7481 echo '<button type="button" id="toggleLoopsApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7482 echo '</div>';
7483 // Cross-reference back to where the list is chosen, so the two screens point
7484 // at each other (plan-mxchat-20260802-907a63).
7485 echo '<p class="description">' . wp_kses(
7486 sprintf(
7487 /* translators: %s: link to the Loops integration tab. */
7488 __('Required for Loops email integration. Get your API key from Loops.so, then choose your mailing list under %s.', 'mxchat'),
7489 '<a href="#integrations-loops" data-target="integrations-loops">' . esc_html__('Integrations, Loops', 'mxchat') . '</a>'
7490 ),
7491 self::mxchat_loops_pointer_allowed_html()
7492 ) . '</p>';
7493 }
7494 public function mxchat_loops_mailing_list_callback() {
7495 // Add error handling and type checking
7496 $loops_api_key = '';
7497 $selected_list = '';
7498 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7499
7500 // Safely get the API key
7501 if (isset($this->options['loops_api_key']) && is_string($this->options['loops_api_key'])) {
7502 $loops_api_key = $this->options['loops_api_key'];
7503 }
7504
7505 // Safely get the selected list
7506 if (isset($this->options['loops_mailing_list']) && is_string($this->options['loops_mailing_list'])) {
7507 $selected_list = $this->options['loops_mailing_list'];
7508 }
7509
7510 if (!empty($loops_api_key)) {
7511 $lists = $this->mxchat_fetch_loops_mailing_lists($loops_api_key);
7512 if (is_array($lists) && !empty($lists)) {
7513 echo '<div class="mxchat-field-wrapper">';
7514 echo '<select id="loops_mailing_list" name="loops_mailing_list" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7515
7516 // Add a default "Select a list" option
7517 echo '<option value="" ' . selected($selected_list, '', false) . '>' . esc_html__('Select a list', 'mxchat') . '</option>';
7518
7519 foreach ($lists as $list) {
7520 if (is_array($list) && isset($list['id']) && isset($list['name'])) {
7521 echo sprintf(
7522 '<option value="%s" %s>%s</option>',
7523 esc_attr($list['id']),
7524 selected($selected_list, $list['id'], false),
7525 esc_html($list['name'])
7526 );
7527 }
7528 }
7529 echo '</select>';
7530 echo '</div>';
7531 echo '<p class="description">' . esc_html__('Please select a mailing list to use with Loops.', 'mxchat') . '</p>';
7532 } else {
7533 echo '<p class="description">' . wp_kses(
7534 self::mxchat_loops_api_key_pointer(
7535 /* translators: %s: link to the API Keys tab. */
7536 __('No lists found. Please check your Loops API key under %s.', 'mxchat')
7537 ),
7538 self::mxchat_loops_pointer_allowed_html()
7539 ) . '</p>';
7540 }
7541 } else {
7542 echo '<p class="description">' . wp_kses(
7543 self::mxchat_loops_api_key_pointer(
7544 /* translators: %s: link to the API Keys tab. */
7545 __('Enter your Loops API key under %s to load mailing lists.', 'mxchat')
7546 ),
7547 self::mxchat_loops_pointer_allowed_html()
7548 ) . '</p>';
7549 }
7550 }
7551
7552 /**
7553 * Build the "API Keys" pointer used by the Loops mailing-list field.
7554 *
7555 * plan-mxchat-20260802-907a63. The Loops API key field was moved to the API
7556 * Keys tab, but this field's copy still read as though the input were beside
7557 * it, so users had nowhere to go. Both live on the SAME screen
7558 * (admin.php?page=mxchat-settings) — the key is under the API Keys tab, this
7559 * dropdown under Integrations > Loops — so the pointer is an in-page tab
7560 * switch, not a cross-page link.
7561 *
7562 * The anchor carries data-target="api-keys", which the shared admin shell
7563 * (js/admin-sidebar.js) already wires for EVERY [data-target] element inside
7564 * .mxch-admin-wrapper — so this actually switches tabs with no new JS. A plain
7565 * href="#api-keys" would NOT work: the shell has no hashchange handling, so the
7566 * link would look right and land the user on the wrong tab.
7567 *
7568 * @param string $template Translatable string containing one %s placeholder.
7569 * @return string
7570 */
7571 private static function mxchat_loops_api_key_pointer($template) {
7572 return sprintf(
7573 $template,
7574 '<a href="#api-keys" data-target="api-keys">' . esc_html__('API Keys', 'mxchat') . '</a>'
7575 );
7576 }
7577
7578 /**
7579 * Allowed HTML for the Loops pointer — anchor plus the shell's tab-switch hook.
7580 */
7581 private static function mxchat_loops_pointer_allowed_html() {
7582 return array(
7583 'a' => array(
7584 'href' => array(),
7585 'data-target' => array(),
7586 ),
7587 );
7588 }
7589 public function mxchat_triggered_phrase_response_callback() {
7590 $default_response = __('Would you like to join our mailing list? Please provide your email below.', 'mxchat');
7591 $triggered_response = isset($this->options['triggered_phrase_response'])
7592 ? $this->options['triggered_phrase_response']
7593 : $default_response;
7594 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7595
7596 echo '<div class="mxchat-field-wrapper">';
7597 echo sprintf(
7598 '<textarea id="triggered_phrase_response" name="triggered_phrase_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7599 $nonce,
7600 esc_textarea($triggered_response)
7601 );
7602 echo '</div>';
7603 echo '<p class="description">' . esc_html__('Enter the instruction for the AI when a trigger keyword is detected. The AI will use this as guidance to naturally ask for the user\'s email in a conversational way.', 'mxchat') . '</p>';
7604 }
7605
7606 public function mxchat_email_capture_response_callback() {
7607 $default_response = __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
7608 $email_capture_response = isset($this->options['email_capture_response'])
7609 ? $this->options['email_capture_response']
7610 : $default_response;
7611 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7612
7613 echo '<div class="mxchat-field-wrapper">';
7614 echo sprintf(
7615 '<textarea id="email_capture_response" name="email_capture_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7616 $nonce,
7617 esc_textarea($email_capture_response)
7618 );
7619 echo '</div>';
7620 echo '<p class="description">' . esc_html__('Enter the instruction for the AI when a user provides their email. The AI will use this as guidance to naturally confirm the email capture in a conversational way.', 'mxchat') . '</p>';
7621 }
7622 public function mxchat_pre_chat_message_callback() {
7623 // Load the entire 'mxchat_options' array
7624 $all_options = get_option('mxchat_options', []);
7625
7626 // Retrieve the saved message or use the default value
7627 $default_message = __('Hey there! Ask me anything!', 'mxchat');
7628 $pre_chat_message = isset($all_options['pre_chat_message']) ? $all_options['pre_chat_message'] : $default_message;
7629
7630 // Output the textarea
7631 printf(
7632 '<textarea id="pre_chat_message" name="pre_chat_message" rows="5" cols="50">%s</textarea>',
7633 esc_textarea($pre_chat_message)
7634 );
7635 }
7636
7637 // Callback for AI Instructions textarea
7638 public function system_prompt_instructions_callback() {
7639 // Retrieve the current value of the system prompt instructions
7640 $instructions = isset($this->options['system_prompt_instructions']) ? esc_textarea($this->options['system_prompt_instructions']) : '';
7641 // Render the textarea field
7642 printf(
7643 '<textarea id="system_prompt_instructions" name="system_prompt_instructions" rows="5" cols="50">%s</textarea>',
7644 $instructions
7645 );
7646 // Personalization hint
7647 echo '<p class="description" style="margin-top: 8px;">';
7648 echo esc_html__('Use {visitor_name} to personalize AI responses when lead capture is enabled.', 'mxchat') . '<br>';
7649 echo '<code style="font-size: 12px;">' . esc_html__('Example: The visitor\'s name is {visitor_name}. Address them by name.', 'mxchat') . '</code>';
7650 echo '</p>';
7651 // Sample instructions button
7652 echo '<div class="mxchat-instructions-container">';
7653 echo '<button type="button" class="mxchat-instructions-btn" id="mxchatViewSampleBtn">';
7654 echo '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
7655 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
7656 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
7657 echo '</svg>';
7658 echo esc_html__('View Sample Instructions', 'mxchat');
7659 echo '</button>';
7660 echo '</div>';
7661
7662 // Add modal to WordPress admin footer instead of inline
7663 add_action('admin_footer', array($this, 'render_sample_instructions_modal'));
7664 }
7665
7666 // New method to render modal in admin footer
7667 public function render_sample_instructions_modal() {
7668 static $modal_rendered = false;
7669 if ($modal_rendered) return; // Prevent duplicate modals
7670 $modal_rendered = true;
7671
7672 echo '<div class="mxchat-instructions-modal-overlay" id="mxchatSampleModal">';
7673 echo '<div class="mxchat-instructions-modal-content">';
7674 echo '<div class="mxchat-instructions-modal-header">';
7675 echo '<h3 class="mxchat-instructions-modal-title">';
7676 echo '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
7677 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
7678 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
7679 echo '</svg>';
7680 echo esc_html__('Sample AI Instructions', 'mxchat');
7681 echo '</h3>';
7682 echo '<button type="button" class="mxchat-instructions-modal-close" id="mxchatModalClose">';
7683 echo '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
7684 echo '<line x1="18" y1="6" x2="6" y2="18"/>';
7685 echo '<line x1="6" y1="6" x2="18" y2="18"/>';
7686 echo '</svg>';
7687 echo '</button>';
7688 echo '</div>';
7689 echo '<div class="mxchat-instructions-modal-body">';
7690 echo '<div class="mxchat-instructions-content">';
7691 echo esc_html('You are an AI Chatbot assistant for this website. Your main goal is to assist visitors with questions and provide helpful information. Here are your key guidelines:
7692
7693 # Response Style - CRITICALLY IMPORTANT
7694 - MAXIMUM LENGTH: 1-3 short sentences per response
7695 - Ultra-concise: Get straight to the answer with no filler
7696 - No introductions like "Sure!" or "I\'d be happy to help"
7697 - No phrases like "based on my knowledge" or "according to information"
7698 - No explanatory text before giving the answer
7699 - No summaries or repetition
7700 - Hyperlink all URLs
7701 - Respond in user\'s language
7702 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
7703
7704 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
7705 - ONLY answer using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
7706 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
7707 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
7708 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
7709 - Better to admit insufficient information than provide inaccurate answers');
7710 echo '</div>';
7711 echo '<button type="button" class="mxchat-instructions-copy-btn" id="mxchatCopyBtn">';
7712 echo '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
7713 echo '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>';
7714 echo '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
7715 echo '</svg>';
7716 echo esc_html__('Copy Instructions', 'mxchat');
7717 echo '</button>';
7718 echo '</div>';
7719 echo '<div class="mxchat-instructions-modal-footer">';
7720 echo '<button type="button" class="mxchat-instructions-btn-secondary" id="mxchatCloseBtn">' . esc_html__('Close', 'mxchat') . '</button>';
7721 echo '</div>';
7722 echo '</div>';
7723 echo '</div>';
7724 }
7725
7726
7727 public function mxchat_model_callback() {
7728 // Catalog refactor (plan-d14e89): single source of truth lives in
7729 // includes/class-mxchat-model-catalog.php. Dropdown groups are the
7730 // provider labels; each group maps model_id => "Label" strings.
7731 if (!class_exists('MxChat_Model_Catalog')) {
7732 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7733 }
7734 // Retrieve the currently selected model from saved options
7735 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.6-sol';
7736
7737 // Pass the saved model so a provider-retired id keeps rendering as the
7738 // current selection instead of a blank select (plan e46b8f).
7739 $models = MxChat_Model_Catalog::settings_dropdown_groups($selected_model);
7740
7741 // Begin the select dropdown
7742 echo '<select id="model" name="model">';
7743
7744 // Iterate over groups of models
7745 foreach ($models as $group_label => $group_models) {
7746 echo '<optgroup label="' . esc_attr($group_label) . '">';
7747
7748 foreach ($group_models as $model_value => $model_label) {
7749 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
7750 }
7751
7752 echo '</optgroup>';
7753 }
7754
7755 echo '</select>';
7756
7757 // Add a note for OpenRouter
7758 echo '<p class="description" id="openrouter-model-note" style="display:none; color: #d63638; font-weight: 500;">';
7759 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle;"></span> ';
7760 echo esc_html__('After entering your OpenRouter API key above, click the button below to load available models.', 'mxchat');
7761 echo '</p>';
7762
7763 // API Key Status Messages (hidden by default, shown by JS based on selected model)
7764 $has_openai_key = !empty($this->options['api_key']);
7765 $has_claude_key = !empty($this->options['claude_api_key']);
7766 $has_xai_key = !empty($this->options['xai_api_key']);
7767 $has_deepseek_key = !empty($this->options['deepseek_api_key']);
7768 $has_gemini_key = !empty($this->options['gemini_api_key']);
7769 $has_openrouter_key = !empty($this->options['openrouter_api_key']);
7770
7771 // OpenAI/GPT models
7772 echo '<p class="mxchat-api-status" data-provider="openai" style="display:none;">';
7773 if ($has_openai_key) {
7774 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
7775 } else {
7776 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7777 }
7778 echo '</p>';
7779
7780 // Claude models
7781 echo '<p class="mxchat-api-status" data-provider="claude" style="display:none;">';
7782 if ($has_claude_key) {
7783 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Anthropic (Claude) detected', 'mxchat') . '</span>';
7784 } else {
7785 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Anthropic (Claude) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7786 }
7787 echo '</p>';
7788
7789 // X.AI models
7790 echo '<p class="mxchat-api-status" data-provider="xai" style="display:none;">';
7791 if ($has_xai_key) {
7792 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for X.AI (Grok) detected', 'mxchat') . '</span>';
7793 } else {
7794 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for X.AI (Grok) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7795 }
7796 echo '</p>';
7797
7798 // DeepSeek models
7799 echo '<p class="mxchat-api-status" data-provider="deepseek" style="display:none;">';
7800 if ($has_deepseek_key) {
7801 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for DeepSeek detected', 'mxchat') . '</span>';
7802 } else {
7803 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for DeepSeek detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7804 }
7805 echo '</p>';
7806
7807 // Gemini models
7808 echo '<p class="mxchat-api-status" data-provider="gemini" style="display:none;">';
7809 if ($has_gemini_key) {
7810 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
7811 } else {
7812 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7813 }
7814 echo '</p>';
7815
7816 // OpenRouter models
7817 echo '<p class="mxchat-api-status" data-provider="openrouter" style="display:none;">';
7818 if ($has_openrouter_key) {
7819 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenRouter detected', 'mxchat') . '</span>';
7820 } else {
7821 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenRouter detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7822 }
7823 echo '</p>';
7824
7825 // ADD THESE HIDDEN FIELDS RIGHT HERE:
7826 $openrouter_model = isset($this->options['openrouter_selected_model']) ? esc_attr($this->options['openrouter_selected_model']) : '';
7827 $openrouter_model_name = isset($this->options['openrouter_selected_model_name']) ? esc_attr($this->options['openrouter_selected_model_name']) : '';
7828
7829 echo '<input type="hidden" id="openrouter_selected_model" name="openrouter_selected_model" value="' . $openrouter_model . '" />';
7830 echo '<input type="hidden" id="openrouter_selected_model_name" name="openrouter_selected_model_name" value="' . $openrouter_model_name . '" />';
7831 }
7832
7833 // Update your existing callback method
7834 public function enable_streaming_toggle_callback() {
7835 // Get value from options array, default to 'on'
7836 $enabled = isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on';
7837 $checked = ($enabled === 'on') ? 'checked' : '';
7838
7839 echo '<label class="toggle-switch">';
7840 echo sprintf(
7841 '<input type="checkbox" id="enable_streaming_toggle" name="enable_streaming_toggle" value="on" %s />',
7842 esc_attr($checked)
7843 );
7844 echo '<span class="slider"></span>';
7845 echo '</label>';
7846
7847 // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
7848 echo '<div class="mxch-streaming-test-row">';
7849 echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
7850 echo '<svg class="mxch-streaming-test-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
7851 echo esc_html__('Test Streaming Compatibility', 'mxchat');
7852 echo '</button>';
7853 echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
7854 echo '</div>';
7855 }
7856
7857 // Web Search toggle callback
7858 public function enable_web_search_toggle_callback() {
7859 // Get value from options array, default to 'off'
7860 $enabled = isset($this->options['enable_web_search']) ? $this->options['enable_web_search'] : 'off';
7861 $checked = ($enabled === 'on') ? 'checked' : '';
7862
7863 // Get current model to determine if we should show/enable the toggle
7864 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
7865
7866 // Models that DON'T support web search — OpenAI-docs-driven exception list.
7867 // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
7868 // future OpenAI model that lacks Responses-API web_search support is added here.
7869 $unsupported_models = array('gpt-4.1-nano');
7870
7871 // Web-search-capable chat-model allowlists, derived from the central model catalog
7872 // (class-mxchat-model-catalog.php). When a new OpenAI/Gemini chat model is added there,
7873 // the Web Search toggle picks it up automatically — no edit here.
7874 // OpenAI grounds via the Responses-API web_search tool; Gemini grounds natively via the
7875 // Google Search tool (plan 46b9ea wired the Gemini dispatch — every shipped Gemini chat
7876 // model is 2.x/3.x and grounds, matching that path's empty opt-out list, so all catalog
7877 // Gemini models are supported here).
7878 if (!class_exists('MxChat_Model_Catalog')) {
7879 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7880 }
7881 $chat_catalog = MxChat_Model_Catalog::chat_models();
7882 $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
7883 ? array_keys($chat_catalog['openai']['models'])
7884 : array();
7885 $gemini_models = (isset($chat_catalog['gemini']['models']) && is_array($chat_catalog['gemini']['models']))
7886 ? array_keys($chat_catalog['gemini']['models'])
7887 : array();
7888
7889 $is_capable = in_array($current_model, $openai_models) || in_array($current_model, $gemini_models);
7890 $is_supported = $is_capable && !in_array($current_model, $unsupported_models);
7891
7892 // Wrapper div with data attributes for JS to show/hide. data-openai-models is kept for
7893 // back-compat; data-gemini-models is the added second provider the JS now also honors.
7894 echo '<div id="web-search-toggle-wrapper" data-openai-models="' . esc_attr(implode(',', $openai_models)) . '" data-gemini-models="' . esc_attr(implode(',', $gemini_models)) . '" data-unsupported-models="' . esc_attr(implode(',', $unsupported_models)) . '"' . (!$is_supported ? ' style="display:none;"' : '') . '>';
7895
7896 echo '<label class="toggle-switch">';
7897 echo sprintf(
7898 '<input type="checkbox" id="enable_web_search" name="enable_web_search" value="on" %s />',
7899 esc_attr($checked)
7900 );
7901 echo '<span class="slider"></span>';
7902 echo '</label>';
7903
7904 echo '</div>';
7905
7906 // Message shown when a model that can't ground (Claude, Grok, DeepSeek, OpenRouter, etc.) is selected
7907 echo '<p id="web-search-unavailable-message" class="description" style="color: #666;' . ($is_supported ? ' display:none;' : '') . '">';
7908 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle; margin-right: 4px;"></span>';
7909 echo esc_html__('Web search is only available for OpenAI and Gemini models.', 'mxchat');
7910 echo '</p>';
7911 }
7912
7913 // AJAX handler to fetch OpenRouter models
7914 public function fetch_openrouter_models() {
7915 check_ajax_referer('mxchat_fetch_openrouter_models', 'nonce');
7916
7917 // plan-mxchat-20260731-c63fb6 — nonce is not authorization.
7918 if (!current_user_can('manage_options')) {
7919 wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403);
7920 }
7921
7922 $api_key = isset($_POST['api_key']) ? sanitize_text_field($_POST['api_key']) : '';
7923
7924 if (empty($api_key)) {
7925 wp_send_json_error(array('message' => 'API key is required'));
7926 }
7927
7928 $response = wp_remote_get('https://openrouter.ai/api/v1/models', array(
7929 'headers' => array(
7930 'Authorization' => 'Bearer ' . $api_key,
7931 'Content-Type' => 'application/json',
7932 ),
7933 'timeout' => 15,
7934 ));
7935
7936 if (is_wp_error($response)) {
7937 wp_send_json_error(array('message' => $response->get_error_message()));
7938 }
7939
7940 $body = wp_remote_retrieve_body($response);
7941 $data = json_decode($body, true);
7942
7943 if (isset($data['data']) && is_array($data['data'])) {
7944 // Format the models for the frontend
7945 $models = array_map(function($model) {
7946 return array(
7947 'id' => $model['id'],
7948 'name' => $model['name'] ?? $model['id'],
7949 'description' => $model['description'] ?? '',
7950 'context_length' => $model['context_length'] ?? 0,
7951 'pricing' => array(
7952 'prompt' => $model['pricing']['prompt'] ?? 0,
7953 'completion' => $model['pricing']['completion'] ?? 0,
7954 ),
7955 );
7956 }, $data['data']);
7957
7958 wp_send_json_success(array('models' => $models));
7959 } else {
7960 wp_send_json_error(array('message' => 'Invalid response from OpenRouter'));
7961 }
7962 }
7963
7964 // Callback function for embedding model selection
7965 public function embedding_model_callback() {
7966 $models = array(
7967 esc_html__('OpenAI Embeddings', 'mxchat') => array(
7968 'text-embedding-3-small' => esc_html__('TE3 Small (1536, Efficient)', 'mxchat'),
7969 'text-embedding-ada-002' => esc_html__('Ada 2 (1536, Recommended)', 'mxchat'),
7970 'text-embedding-3-large' => esc_html__('TE3 Large (3072, Powerful)', 'mxchat'),
7971 ),
7972 esc_html__('Voyage AI Embeddings', 'mxchat') => array(
7973 'voyage-3-large' => esc_html__('Voyage-3 Large (2048, Most Capable)', 'mxchat'),
7974 ),
7975 esc_html__('Google Gemini Embeddings', 'mxchat') => array(
7976 'gemini-embedding-001' => esc_html__('Gemini Embedding (1536, Stable)', 'mxchat'),
7977 )
7978 );
7979 $selected_model = isset($this->options['embedding_model']) ? esc_attr($this->options['embedding_model']) : 'text-embedding-ada-002';
7980 echo '<select id="embedding_model" name="embedding_model">';
7981 foreach ($models as $group_label => $group_models) {
7982 echo '<optgroup label="' . esc_attr($group_label) . '">';
7983 foreach ($group_models as $model_value => $model_label) {
7984 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
7985 }
7986 echo '</optgroup>';
7987 }
7988 echo '</select>';
7989
7990 // API Key Status Messages for Embedding Models
7991 $has_openai_key = !empty($this->options['api_key']);
7992 $has_voyage_key = !empty($this->options['voyage_api_key']);
7993 $has_gemini_key = !empty($this->options['gemini_api_key']);
7994
7995 // OpenAI Embeddings
7996 echo '<p class="mxchat-embedding-api-status" data-provider="openai" style="display:none;">';
7997 if ($has_openai_key) {
7998 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
7999 } else {
8000 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8001 }
8002 echo '</p>';
8003
8004 // Voyage AI Embeddings
8005 echo '<p class="mxchat-embedding-api-status" data-provider="voyage" style="display:none;">';
8006 if ($has_voyage_key) {
8007 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Voyage AI detected', 'mxchat') . '</span>';
8008 } else {
8009 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Voyage AI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8010 }
8011 echo '</p>';
8012
8013 // Gemini Embeddings
8014 echo '<p class="mxchat-embedding-api-status" data-provider="gemini" style="display:none;">';
8015 if ($has_gemini_key) {
8016 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8017 } else {
8018 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8019 }
8020 echo '</p>';
8021
8022 }
8023
8024
8025 public function mxchat_top_bar_title_callback() {
8026 // Retrieve the current value of the top bar title from saved options
8027 $top_bar_title = isset($this->options['top_bar_title']) ? esc_attr($this->options['top_bar_title']) : '';
8028
8029 // Render the input field
8030 echo '<input type="text" id="top_bar_title" name="top_bar_title" value="' . $top_bar_title . '" />';
8031 }
8032 public function mxchat_ai_agent_text_callback() {
8033 // Retrieve the current value of the AI agent text from saved options
8034 $ai_agent_text = isset($this->options['ai_agent_text']) ? esc_attr($this->options['ai_agent_text']) : '';
8035 // Render the input field
8036 echo '<input type="text" id="ai_agent_text" name="ai_agent_text" value="' . $ai_agent_text . '" />';
8037 }
8038
8039
8040 public function enable_email_block_callback() {
8041 // Load full plugin options array
8042 $all_options = get_option('mxchat_options', []);
8043
8044 // Get the value, default to 'off'
8045 $enable_email_block = isset($all_options['enable_email_block']) ? $all_options['enable_email_block'] : 'off';
8046
8047 // Check if it's 'on'
8048 $checked = ($enable_email_block === 'on') ? 'checked' : '';
8049
8050 echo '<label class="toggle-switch">';
8051 echo sprintf(
8052 '<input type="checkbox" id="enable_email_block" name="enable_email_block" value="on" %s />',
8053 esc_attr($checked)
8054 );
8055 echo '<span class="slider"></span>';
8056 echo '</label>';
8057 }
8058
8059 public function email_blocker_header_content_callback() {
8060 // Load the entire 'mxchat_options' array
8061 $all_options = get_option('mxchat_options', []);
8062
8063 // Retrieve the saved content or default to empty
8064 $content = isset($all_options['email_blocker_header_content'])
8065 ? $all_options['email_blocker_header_content']
8066 : '';
8067
8068 // Render the textarea - IMPORTANT: name should be just "email_blocker_header_content"
8069 echo '<textarea
8070 id="email_blocker_header_content"
8071 name="email_blocker_header_content"
8072 rows="5"
8073 cols="70"
8074 data-setting="email_blocker_header_content"
8075 >' . esc_textarea($content) . '</textarea>';
8076 }
8077
8078 public function email_blocker_button_text_callback() {
8079 // Load the entire 'mxchat_options' array
8080 $all_options = get_option('mxchat_options', []);
8081
8082 // Retrieve the saved button text or default to empty
8083 $button_text = isset($all_options['email_blocker_button_text'])
8084 ? $all_options['email_blocker_button_text']
8085 : '';
8086
8087 // Use esc_attr to safely render the existing text
8088 echo '<input type="text" id="email_blocker_button_text" name="email_blocker_button_text" value="' . esc_attr($button_text) . '" style="width: 300px;" />';
8089 }
8090
8091 //Enable name field callback
8092 public function enable_name_field_callback() {
8093 // Load full plugin options array
8094 $all_options = get_option('mxchat_options', []);
8095 // Get the value, default to 'off'
8096 $enable_name_field = isset($all_options['enable_name_field']) ? $all_options['enable_name_field'] : 'off';
8097 // Check if it's 'on'
8098 $checked = ($enable_name_field === 'on') ? 'checked' : '';
8099 echo '<label class="toggle-switch">';
8100 echo sprintf(
8101 '<input type="checkbox" id="enable_name_field" name="enable_name_field" value="on" %s />',
8102 esc_attr($checked)
8103 );
8104 echo '<span class="slider"></span>';
8105 echo '</label>';
8106 }
8107 //Name field placeholder callback
8108 public function name_field_placeholder_callback() {
8109 $all_options = get_option('mxchat_options', []);
8110 $placeholder = isset($all_options['name_field_placeholder'])
8111 ? $all_options['name_field_placeholder']
8112 : esc_html__('Enter your name', 'mxchat');
8113
8114 echo '<input type="text" id="name_field_placeholder" name="name_field_placeholder" value="' . esc_attr($placeholder) . '" style="width: 300px;" />';
8115 }
8116
8117
8118
8119 public function mxchat_intro_message_callback() {
8120 // Load the entire 'mxchat_options' array
8121 $all_options = get_option('mxchat_options', []);
8122 // Retrieve the saved intro message or use the default
8123 $default_message = __('Hello! How can I assist you today?', 'mxchat');
8124 $saved_message = isset($all_options['intro_message']) ? $all_options['intro_message'] : $default_message;
8125 // Escape on output (esc_textarea) — neutralizes any payload already stored before the
8126 // Wordfence Stored-XSS fix (CWE-79, plan-3f8158) and prevents </textarea> context-breakout.
8127 ?>
8128 <textarea id="intro_message" name="intro_message" rows="5" cols="50"><?php echo esc_textarea( $saved_message ); ?></textarea>
8129 <p class="description" style="margin-top: 8px;">
8130 <?php esc_html_e('Use {visitor_name} to personalize greetings when lead capture is enabled.', 'mxchat'); ?><br>
8131 <code style="font-size: 12px;"><?php esc_html_e('Example: Hello {visitor_name}! How can I help you today?', 'mxchat'); ?></code>
8132 </p>
8133 <?php
8134 }
8135
8136 public function mxchat_input_copy_callback() {
8137 // Load the entire 'mxchat_options' array
8138 $all_options = get_option('mxchat_options', []);
8139
8140 // Retrieve the saved input copy or use the default value
8141 $default_copy = __('How can I assist?', 'mxchat');
8142 $input_copy = isset($all_options['input_copy']) ? $all_options['input_copy'] : $default_copy;
8143
8144 // Output the input field with the saved value
8145 printf(
8146 '<input type="text" id="input_copy" name="input_copy" value="%s" placeholder="%s" />',
8147 esc_attr($input_copy),
8148 esc_attr__('How can I assist?', 'mxchat')
8149 );
8150 }
8151
8152
8153 public function mxchat_append_to_body_callback() {
8154 // Fetch fresh options to ensure we have the latest saved values
8155 $options = get_option('mxchat_options', array());
8156
8157 // Get value from options array, default to 'off'
8158 $append_to_body = isset($options['append_to_body']) ? $options['append_to_body'] : 'off';
8159 $checked = ($append_to_body === 'on') ? 'checked' : '';
8160
8161 // Get post type visibility settings
8162 $visibility_mode = isset($options['post_type_visibility_mode']) ? $options['post_type_visibility_mode'] : 'all';
8163 $visibility_list = isset($options['post_type_visibility_list']) ? $options['post_type_visibility_list'] : array();
8164 if (!is_array($visibility_list)) {
8165 $visibility_list = array();
8166 }
8167
8168 echo '<div class="mxchat-autosave-section">';
8169
8170 // Main toggle
8171 echo '<label class="toggle-switch">';
8172 echo sprintf(
8173 '<input type="checkbox" id="append_to_body" name="append_to_body" value="on" %s />',
8174 esc_attr($checked)
8175 );
8176 echo '<span class="slider"></span>';
8177 echo '</label>';
8178
8179 // Post Type Visibility Options (only visible when auto-display is ON)
8180 $display_style = ($append_to_body === 'on') ? '' : 'display: none;';
8181 echo '<div id="post-type-visibility-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8182
8183 echo '<div class="mxchat-post-type-visibility-header">';
8184 echo '<h4>' . esc_html__('Post Type Visibility', 'mxchat') . '</h4>';
8185 echo '</div>';
8186
8187 // Mode selector (radio buttons)
8188 echo '<div class="mxchat-visibility-mode">';
8189
8190 echo '<label class="mxchat-radio-label">';
8191 echo '<input type="radio" name="post_type_visibility_mode" value="all" ' . checked($visibility_mode, 'all', false) . ' />';
8192 echo '<span>' . esc_html__('Show on all post types', 'mxchat') . '</span>';
8193 echo '</label>';
8194
8195 echo '<label class="mxchat-radio-label">';
8196 echo '<input type="radio" name="post_type_visibility_mode" value="include" ' . checked($visibility_mode, 'include', false) . ' />';
8197 echo '<span>' . esc_html__('Only show on selected post types', 'mxchat') . '</span>';
8198 echo '</label>';
8199
8200 echo '<label class="mxchat-radio-label">';
8201 echo '<input type="radio" name="post_type_visibility_mode" value="exclude" ' . checked($visibility_mode, 'exclude', false) . ' />';
8202 echo '<span>' . esc_html__('Hide on selected post types', 'mxchat') . '</span>';
8203 echo '</label>';
8204
8205 echo '</div>';
8206
8207 // Post type checkboxes (only visible when mode is include or exclude)
8208 $list_display = ($visibility_mode !== 'all') ? '' : 'display: none;';
8209 echo '<div id="post-type-list" class="mxchat-post-type-list" style="' . esc_attr($list_display) . '">';
8210
8211 // Get all public post types
8212 $post_types = get_post_types(array('public' => true), 'objects');
8213
8214 foreach ($post_types as $post_type) {
8215 // Skip attachments
8216 if ($post_type->name === 'attachment') {
8217 continue;
8218 }
8219
8220 $is_checked = in_array($post_type->name, $visibility_list) ? 'checked' : '';
8221
8222 echo '<label class="mxchat-checkbox-label">';
8223 echo '<input type="checkbox" name="post_type_visibility_list[]" value="' . esc_attr($post_type->name) . '" ' . $is_checked . ' />';
8224 echo '<span>' . esc_html($post_type->label) . '</span>';
8225 echo '</label>';
8226 }
8227
8228 echo '</div>'; // End post-type-list
8229 echo '</div>'; // End post-type-visibility-options
8230 echo '</div>'; // End mxchat-autosave-section
8231 }
8232
8233 public function mxchat_contextual_awareness_callback() {
8234 // Get value from options array, default to 'off'
8235 $contextual_awareness = isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off';
8236 $checked = ($contextual_awareness === 'on') ? 'checked' : '';
8237 echo '<label class="toggle-switch">';
8238 echo sprintf(
8239 '<input type="checkbox" id="contextual_awareness_toggle" name="contextual_awareness_toggle" value="on" %s />',
8240 esc_attr($checked)
8241 );
8242 echo '<span class="slider"></span>';
8243 echo '</label>';
8244 }
8245
8246 public function mxchat_citation_links_toggle_callback() {
8247 // Get value from options array, default to 'on' (enabled by default)
8248 $citation_links = isset($this->options['citation_links_toggle']) ? $this->options['citation_links_toggle'] : 'on';
8249 $checked = ($citation_links === 'on') ? 'checked' : '';
8250 echo '<label class="toggle-switch">';
8251 echo sprintf(
8252 '<input type="checkbox" id="citation_links_toggle" name="citation_links_toggle" value="on" %s />',
8253 esc_attr($checked)
8254 );
8255 echo '<span class="slider"></span>';
8256 echo '</label>';
8257 }
8258
8259 /**
8260 * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
8261 * Default ON. The widget reads this through the localized object; the
8262 * mxchat_satisfaction_rating_enabled filter still lets developers force
8263 * the value site-wide.
8264 *
8265 * The 5 customization fields (idle/question/thanks/placeholder/saved) are
8266 * rendered inline here inside a single wrapper div whose initial display
8267 * is set server-side from the toggle value (plan-29caac). Mirrors the
8268 * auto-display chatbot pattern at mxchat_append_to_body_callback — no
8269 * DOMContentLoaded race because rows exist as direct children of this
8270 * callback's output, and the wrapper's display: none is inline at render
8271 * time so refresh shows the correct state with no flash.
8272 */
8273 public function mxchat_satisfaction_rating_toggle_callback() {
8274 $options = $this->options;
8275 $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
8276 $checked = ($value === 'on') ? 'checked' : '';
8277
8278 $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
8279 $idle = max(5, min(600, $idle));
8280 $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
8281 $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
8282 $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
8283 $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
8284
8285 echo '<div class="mxchat-autosave-section">';
8286
8287 echo '<label class="toggle-switch">';
8288 echo sprintf(
8289 '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
8290 esc_attr($checked)
8291 );
8292 echo '<span class="slider"></span>';
8293 echo '</label>';
8294
8295 ?>
8296 <style>
8297 .mxchat-sub-options-field { margin: 12px 0; }
8298 .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
8299 #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
8300 </style>
8301 <?php
8302
8303 $display_style = ($value === 'on') ? '' : 'display: none;';
8304 echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8305
8306 echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
8307
8308 echo '<div class="mxchat-sub-options-field">';
8309 echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
8310 printf(
8311 '<input type="number" id="satisfaction_rating_idle_seconds" name="satisfaction_rating_idle_seconds" value="%d" min="5" max="600" step="1" class="small-text" /> <span class="description">%s</span>',
8312 (int) $idle,
8313 esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
8314 );
8315 echo '</div>';
8316
8317 echo '<div class="mxchat-sub-options-field">';
8318 echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
8319 printf(
8320 '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8321 esc_attr($question),
8322 esc_attr__('Was this helpful?', 'mxchat')
8323 );
8324 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
8325 echo '</div>';
8326
8327 echo '<div class="mxchat-sub-options-field">';
8328 echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
8329 printf(
8330 '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
8331 esc_attr($thanks),
8332 esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
8333 );
8334 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
8335 echo '</div>';
8336
8337 echo '<div class="mxchat-sub-options-field">';
8338 echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
8339 printf(
8340 '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8341 esc_attr($placeholder),
8342 esc_attr__('Tell us what could be better…', 'mxchat')
8343 );
8344 echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
8345 echo '</div>';
8346
8347 echo '<div class="mxchat-sub-options-field">';
8348 echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
8349 printf(
8350 '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8351 esc_attr($saved),
8352 esc_attr__('Thanks for the feedback.', 'mxchat')
8353 );
8354 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
8355 echo '</div>';
8356
8357 echo '</div>'; // #satisfaction-rating-sub-options
8358 echo '</div>'; // .mxchat-autosave-section
8359
8360 ?>
8361 <script>
8362 (function() {
8363 document.addEventListener('DOMContentLoaded', function() {
8364 var toggle = document.getElementById('satisfaction_rating_enabled');
8365 var subOptions = document.getElementById('satisfaction-rating-sub-options');
8366 if (!toggle || !subOptions) return;
8367 toggle.addEventListener('change', function() {
8368 subOptions.style.display = toggle.checked ? '' : 'none';
8369 });
8370 });
8371 })();
8372 </script>
8373 <?php
8374 }
8375
8376 public function mxchat_privacy_toggle_callback() {
8377 // Load from mxchat_options array
8378 $options = get_option('mxchat_options', []);
8379
8380 // Get privacy toggle value with fallback
8381 $privacy_toggle = isset($options['privacy_toggle']) ? $options['privacy_toggle'] : 'off';
8382 $checked = ($privacy_toggle === 'on') ? 'checked' : '';
8383
8384 // Get privacy text with fallback
8385 $privacy_text = isset($options['privacy_text'])
8386 ? $options['privacy_text']
8387 : __('By chatting, you agree to our <a href="https://example.com/privacy-policy" target="_blank">privacy policy</a>.', 'mxchat');
8388
8389 // Output the toggle switch
8390 echo '<label class="toggle-switch">';
8391 echo sprintf(
8392 '<input type="checkbox" id="privacy_toggle" name="privacy_toggle" value="on" %s />',
8393 esc_attr($checked)
8394 );
8395 echo '<span class="slider"></span>';
8396 echo '</label>';
8397
8398 // Output the custom text input field
8399 echo sprintf(
8400 '<textarea id="privacy_text" name="privacy_text" rows="5" cols="50" class="regular-text">%s</textarea>',
8401 esc_textarea($privacy_text)
8402 );
8403 }
8404
8405
8406 public function mxchat_complianz_toggle_callback() {
8407 // Load from mxchat_options array
8408 $options = get_option('mxchat_options', []);
8409
8410 // Get complianz toggle value with fallback
8411 $complianz_toggle = isset($options['complianz_toggle']) ? $options['complianz_toggle'] : 'off';
8412 $checked = ($complianz_toggle === 'on') ? 'checked' : '';
8413
8414 // Output the toggle switch
8415 echo '<label class="toggle-switch">';
8416 echo sprintf(
8417 '<input type="checkbox" id="complianz_toggle" name="complianz_toggle" value="on" %s />',
8418 esc_attr($checked)
8419 );
8420 echo '<span class="slider"></span>';
8421 echo '</label>';
8422 }
8423
8424 public function mxchat_link_target_toggle_callback() {
8425 // Load from mxchat_options array
8426 $options = get_option('mxchat_options', []);
8427
8428 // Get link target toggle value with fallback
8429 $link_target_toggle = isset($options['link_target_toggle']) ? $options['link_target_toggle'] : 'off';
8430 $checked = ($link_target_toggle === 'on') ? 'checked' : '';
8431
8432 // Output the toggle switch
8433 echo '<label class="toggle-switch">';
8434 echo sprintf(
8435 '<input type="checkbox" id="link_target_toggle" name="link_target_toggle" value="on" %s />',
8436 esc_attr($checked)
8437 );
8438 echo '<span class="slider"></span>';
8439 echo '</label>';
8440 }
8441
8442 public function mxchat_chat_persistence_toggle_callback() {
8443 // Load from mxchat_options array
8444 $options = get_option('mxchat_options', []);
8445
8446 // Get chat persistence toggle value with fallback
8447 $chat_persistence_toggle = isset($options['chat_persistence_toggle']) ? $options['chat_persistence_toggle'] : 'off';
8448 $checked = ($chat_persistence_toggle === 'on') ? 'checked' : '';
8449
8450 // Output the toggle switch
8451 echo '<label class="toggle-switch">';
8452 echo sprintf(
8453 '<input type="checkbox" id="chat_persistence_toggle" name="chat_persistence_toggle" value="on" %s />',
8454 esc_attr($checked)
8455 );
8456 echo '<span class="slider"></span>';
8457 echo '</label>';
8458 }
8459
8460 public function mxchat_print_button_toggle_callback() {
8461 // Load from mxchat_options array
8462 $options = get_option('mxchat_options', []);
8463
8464 // Default ON — the option was previously unexposed and the button always showed.
8465 $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
8466 $checked = ($print_button_enabled === 'on') ? 'checked' : '';
8467
8468 // Output the toggle switch
8469 echo '<label class="toggle-switch">';
8470 echo sprintf(
8471 '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
8472 esc_attr($checked)
8473 );
8474 echo '<span class="slider"></span>';
8475 echo '</label>';
8476 }
8477
8478 /**
8479 * Editor Assistant toggle (plan-8cb0cb). Standalone option, NOT in mxchat_options
8480 * (bypasses the mxchat_sanitize strip-trap + autosave normalization). Default OFF.
8481 * Saved by the mxchat_editor_assistant_enabled case in class-ajax-handler.php.
8482 */
8483 public function mxchat_editor_assistant_toggle_callback() {
8484 $enabled = get_option('mxchat_editor_assistant_enabled', 'off');
8485 $checked = ($enabled === 'on') ? 'checked' : '';
8486
8487 echo '<label class="toggle-switch">';
8488 echo sprintf(
8489 '<input type="checkbox" id="mxchat_editor_assistant_enabled" name="mxchat_editor_assistant_enabled" value="on" %s />',
8490 esc_attr($checked)
8491 );
8492 echo '<span class="slider"></span>';
8493 echo '</label>';
8494 }
8495
8496 public function mxchat_reset_chat_toggle_callback() {
8497 // Load from mxchat_options array. plan ac2e81 — default OFF (new, opt-in).
8498 $options = get_option('mxchat_options', []);
8499 $reset_chat_enabled = isset($options['reset_chat_enabled']) ? $options['reset_chat_enabled'] : 'off';
8500 $checked = ($reset_chat_enabled === 'on') ? 'checked' : '';
8501
8502 echo '<label class="toggle-switch">';
8503 echo sprintf(
8504 '<input type="checkbox" id="reset_chat_enabled" name="reset_chat_enabled" value="on" %s />',
8505 esc_attr($checked)
8506 );
8507 echo '<span class="slider"></span>';
8508 echo '</label>';
8509 }
8510
8511 public function mxchat_reset_chat_label_callback() {
8512 // Editable label for the "Start new chat" menu item. plan ac2e81.
8513 $options = get_option('mxchat_options', []);
8514 $reset_chat_label = isset($options['reset_chat_label']) ? $options['reset_chat_label'] : '';
8515
8516 printf(
8517 '<input type="text" id="reset_chat_label" name="reset_chat_label" value="%s" placeholder="%s" class="regular-text" />',
8518 esc_attr($reset_chat_label),
8519 esc_attr__('Start new chat', 'mxchat')
8520 );
8521 }
8522
8523 public function mxchat_popular_question_1_callback() {
8524 // Load the full plugin options array
8525 $all_options = get_option('mxchat_options', []);
8526
8527 // Retrieve the specific option for popular_question_1
8528 $popular_question_1 = isset($all_options['popular_question_1']) ? $all_options['popular_question_1'] : '';
8529
8530 // Render the input field
8531 printf(
8532 '<input type="text" id="popular_question_1" name="popular_question_1" value="%s" placeholder="%s" class="regular-text" />',
8533 esc_attr($popular_question_1),
8534 esc_attr__('Enter Quick Question 1', 'mxchat')
8535 );
8536 }
8537
8538
8539 public function mxchat_popular_question_2_callback() {
8540 // Load the full plugin options array
8541 $all_options = get_option('mxchat_options', []);
8542
8543 // Retrieve the specific option for popular_question_2
8544 $popular_question_2 = isset($all_options['popular_question_2']) ? $all_options['popular_question_2'] : '';
8545
8546 // Render the input field
8547 printf(
8548 '<input type="text" id="popular_question_2" name="popular_question_2" value="%s" placeholder="%s" class="regular-text" />',
8549 esc_attr($popular_question_2),
8550 esc_attr__('Enter Quick Question 2', 'mxchat')
8551 );
8552 }
8553
8554
8555 public function mxchat_popular_question_3_callback() {
8556 // Load the full plugin options array
8557 $all_options = get_option('mxchat_options', []);
8558
8559 // Retrieve the specific option for popular_question_3
8560 $popular_question_3 = isset($all_options['popular_question_3']) ? $all_options['popular_question_3'] : '';
8561
8562 // Render the input field
8563 printf(
8564 '<input type="text" id="popular_question_3" name="popular_question_3" value="%s" placeholder="%s" class="regular-text" />',
8565 esc_attr($popular_question_3),
8566 esc_attr(__('Enter Quick Question 3', 'mxchat'))
8567 );
8568 }
8569
8570 public function mxchat_additional_popular_questions_callback() {
8571 $options = get_option('mxchat_options', []);
8572 $additional_questions = isset($options['additional_popular_questions'])
8573 ? $options['additional_popular_questions']
8574 : get_option('additional_popular_questions', array());
8575
8576 echo '<div id="mxchat-additional-questions-container">';
8577 if (!empty($additional_questions)) {
8578 foreach ($additional_questions as $index => $question) {
8579 printf(
8580 '<div class="mxchat-question-row">
8581 <input type="text" name="additional_popular_questions[]"
8582 value="%s"
8583 placeholder="%s"
8584 class="regular-text mxchat-question-input"
8585 data-question-index="%d" />
8586 <button type="button" class="button mxchat-remove-question"
8587 aria-label="%s">%s</button>
8588 </div>',
8589 esc_attr($question),
8590 esc_attr(sprintf(__('Enter Additional Quick Question %d', 'mxchat'), $index + 4)),
8591 $index,
8592 esc_attr(__('Remove question', 'mxchat')),
8593 esc_html__('Remove', 'mxchat')
8594 );
8595 }
8596 } else {
8597 printf(
8598 '<div class="mxchat-question-row">
8599 <input type="text" name="additional_popular_questions[]"
8600 value=""
8601 placeholder="%s"
8602 class="regular-text mxchat-question-input"
8603 data-question-index="0" />
8604 <button type="button" class="button mxchat-remove-question"
8605 aria-label="%s">%s</button>
8606 </div>',
8607 esc_attr(__('Enter Additional Quick Question 4', 'mxchat')),
8608 esc_attr(__('Remove question', 'mxchat')),
8609 esc_html__('Remove', 'mxchat')
8610 );
8611 }
8612 echo '</div>';
8613 printf(
8614 '<button type="button" class="button mxchat-add-question" aria-label="%s">%s</button>',
8615 esc_attr(__('Add question', 'mxchat')),
8616 esc_html__('Add Question', 'mxchat')
8617 );
8618 }
8619
8620 public function mxchat_brave_api_key_callback() {
8621 $brave_api_key = isset($this->options['brave_api_key']) ? esc_attr($this->options['brave_api_key']) : '';
8622 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8623
8624 echo '<div class="api-key-wrapper">';
8625 echo sprintf(
8626 '<input type="text" id="brave_api_key" name="brave_api_key" value="%s" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="%s" />',
8627 $brave_api_key,
8628 $nonce
8629 );
8630 echo '<button type="button" id="toggleBraveApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8631 echo '</div>';
8632 echo '<p class="description">' . __('Required for Brave Search integration. Get your API key from Brave Search API.', 'mxchat') . '</p>';
8633 }
8634
8635 public function mxchat_brave_image_count_callback() {
8636 $brave_image_count = isset($this->options['brave_image_count'])
8637 ? intval($this->options['brave_image_count'])
8638 : 4;
8639 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8640
8641 echo '<div class="mxchat-field-wrapper">';
8642 echo sprintf(
8643 '<input type="number" id="brave_image_count" name="brave_image_count"
8644 value="%d" min="1" max="6" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8645 $brave_image_count,
8646 $nonce
8647 );
8648 echo '</div>';
8649 echo '<p class="description">' . __('Select the number of images to return (1-6).', 'mxchat') . '</p>';
8650 }
8651
8652 public function mxchat_brave_safe_search_callback() {
8653 $brave_safe_search = isset($this->options['brave_safe_search'])
8654 ? esc_attr($this->options['brave_safe_search'])
8655 : 'strict';
8656 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8657
8658 echo '<div class="mxchat-field-wrapper">';
8659 echo '<select id="brave_safe_search" name="brave_safe_search" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
8660 echo sprintf(
8661 '<option value="strict" %s>%s</option>',
8662 selected($brave_safe_search, 'strict', false),
8663 __('Strict', 'mxchat')
8664 );
8665 echo sprintf(
8666 '<option value="off" %s>%s</option>',
8667 selected($brave_safe_search, 'off', false),
8668 __('Off', 'mxchat')
8669 );
8670 echo '</select>';
8671 echo '</div>';
8672 echo '<p class="description">' .
8673 esc_html__('Set the Safe Search level for image searches. Brave Search only supports "Strict" and "Off" options.', 'mxchat') .
8674 '</p>';
8675 }
8676
8677 public function mxchat_brave_news_count_callback() {
8678 $brave_news_count = isset($this->options['brave_news_count'])
8679 ? intval($this->options['brave_news_count'])
8680 : 3;
8681 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8682
8683 echo '<div class="mxchat-field-wrapper">';
8684 echo sprintf(
8685 '<input type="number" id="brave_news_count" name="brave_news_count"
8686 value="%d" min="1" max="10" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8687 $brave_news_count,
8688 $nonce
8689 );
8690 echo '</div>';
8691 echo '<p class="description">' . esc_html__('Select the number of news articles to retrieve (1-10).', 'mxchat') . '</p>';
8692 }
8693
8694 public function mxchat_brave_country_callback() {
8695 $brave_country = isset($this->options['brave_country'])
8696 ? esc_attr($this->options['brave_country'])
8697 : 'us';
8698 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8699
8700 echo '<div class="mxchat-field-wrapper">';
8701 echo sprintf(
8702 '<input type="text" id="brave_country" name="brave_country"
8703 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8704 $brave_country,
8705 $nonce
8706 );
8707 echo '</div>';
8708 echo '<p class="description">' . esc_html__('Enter the country code (e.g., "us" for United States).', 'mxchat') . '</p>';
8709 }
8710
8711 public function mxchat_brave_language_callback() {
8712 $brave_language = isset($this->options['brave_language'])
8713 ? esc_attr($this->options['brave_language'])
8714 : 'en';
8715 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8716
8717 echo '<div class="mxchat-field-wrapper">';
8718 echo sprintf(
8719 '<input type="text" id="brave_language" name="brave_language"
8720 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8721 $brave_language,
8722 $nonce
8723 );
8724 echo '</div>';
8725 echo '<p class="description">' . esc_html__('Enter the language code (e.g., "en" for English).', 'mxchat') . '</p>';
8726 }
8727
8728
8729
8730
8731
8732 // Section Callback
8733 public function mxchat_pdf_intent_section_callback() {
8734 echo '<p>' . esc_html__('Configure the intent settings for the Chat with PDF feature.', 'mxchat') . '</p>';
8735 }
8736
8737 public function mxchat_chat_toolbar_toggle_callback() {
8738 // Get chat toolbar toggle value with fallback
8739 $chat_toolbar_toggle = isset($this->options['chat_toolbar_toggle']) ? $this->options['chat_toolbar_toggle'] : 'off';
8740 $checked = ($chat_toolbar_toggle === 'on') ? 'checked' : '';
8741
8742 // Output the toggle switch
8743 echo '<label class="toggle-switch">';
8744 echo sprintf(
8745 '<input type="checkbox" id="chat_toolbar_toggle" name="chat_toolbar_toggle" value="on" %s />',
8746 esc_attr($checked)
8747 );
8748 echo '<span class="slider"></span>';
8749 echo '</label>';
8750
8751 echo '<p class="description">' . esc_html__('Enable to display the chat toolbar, adding two icons below the chatbot input field for uploading PDF and Word documents (default is hidden).', 'mxchat') . '</p>';
8752 }
8753
8754 /**
8755 * Callback for PDF upload button toggle setting
8756 */
8757 public function mxchat_show_pdf_upload_button_callback() {
8758 // Get toggle value with fallback
8759 $show_pdf_button = isset($this->options['show_pdf_upload_button']) ? $this->options['show_pdf_upload_button'] : 'on';
8760 $checked = ($show_pdf_button === 'on') ? 'checked' : '';
8761
8762 // Output the toggle switch
8763 echo '<label class="toggle-switch">';
8764 echo sprintf(
8765 '<input type="checkbox" id="show_pdf_upload_button" name="show_pdf_upload_button" value="on" %s />',
8766 esc_attr($checked)
8767 );
8768 echo '<span class="slider"></span>';
8769 echo '</label>';
8770
8771 echo '<p class="description">' . esc_html__('Enable to show the PDF upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
8772 }
8773
8774 /**
8775 * Callback for Word upload button toggle setting
8776 */
8777 public function mxchat_show_word_upload_button_callback() {
8778 // Get toggle value with fallback
8779 $show_word_button = isset($this->options['show_word_upload_button']) ? $this->options['show_word_upload_button'] : 'on';
8780 $checked = ($show_word_button === 'on') ? 'checked' : '';
8781
8782 // Output the toggle switch
8783 echo '<label class="toggle-switch">';
8784 echo sprintf(
8785 '<input type="checkbox" id="show_word_upload_button" name="show_word_upload_button" value="on" %s />',
8786 esc_attr($checked)
8787 );
8788 echo '<span class="slider"></span>';
8789 echo '</label>';
8790
8791 echo '<p class="description">' . esc_html__('Enable to show the Word document upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
8792 }
8793
8794 public function mxchat_pdf_intent_trigger_text_callback() {
8795 $default_text = __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
8796
8797 echo sprintf(
8798 '<textarea id="pdf_intent_trigger_text"
8799 name="pdf_intent_trigger_text"
8800 rows="3"
8801 cols="50"
8802 placeholder="%s">%s</textarea>',
8803 esc_attr__('Enter trigger text', 'mxchat'),
8804 isset($this->options['pdf_intent_trigger_text'])
8805 ? esc_textarea($this->options['pdf_intent_trigger_text'])
8806 : esc_textarea($default_text)
8807 );
8808 echo '<p class="description">' . esc_html__('Text displayed when the intent is triggered.', 'mxchat') . '</p>';
8809 }
8810
8811 public function mxchat_pdf_intent_success_text_callback() {
8812 $default_text = __("I've processed the PDF. What questions do you have about it?", 'mxchat');
8813
8814 echo sprintf(
8815 '<textarea id="pdf_intent_success_text"
8816 name="pdf_intent_success_text"
8817 rows="3"
8818 cols="50"
8819 placeholder="%s">%s</textarea>',
8820 esc_attr__('Enter success text', 'mxchat'),
8821 isset($this->options['pdf_intent_success_text'])
8822 ? esc_textarea($this->options['pdf_intent_success_text'])
8823 : esc_textarea($default_text)
8824 );
8825 echo '<p class="description">' . esc_html__('Text displayed when the intent is successful.', 'mxchat') . '</p>';
8826 }
8827
8828 public function mxchat_pdf_intent_error_text_callback() {
8829 $default_text = __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
8830
8831 echo sprintf(
8832 '<textarea id="pdf_intent_error_text"
8833 name="pdf_intent_error_text"
8834 rows="3"
8835 cols="50"
8836 placeholder="%s">%s</textarea>',
8837 esc_attr__('Enter error text', 'mxchat'),
8838 isset($this->options['pdf_intent_error_text'])
8839 ? esc_textarea($this->options['pdf_intent_error_text'])
8840 : esc_textarea($default_text)
8841 );
8842 echo '<p class="description">' . esc_html__('Text displayed when an error occurs during the intent.', 'mxchat') . '</p>';
8843 }
8844
8845 public function mxchat_pdf_max_pages_callback() {
8846 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
8847
8848 echo sprintf(
8849 '<input type="range"
8850 id="pdf_max_pages"
8851 name="pdf_max_pages"
8852 min="1"
8853 max="69"
8854 value="%d"
8855 class="range-slider" />',
8856 esc_attr($max_pages)
8857 );
8858 echo '<span id="pdf_max_pages_output">' . esc_html($max_pages) . '</span>';
8859 echo '<p class="description">' . esc_html__('Set the maximum number of document pages users can upload for processing. (1-69 pages)', 'mxchat') . '</p>';
8860 }
8861
8862 public function mxchat_live_agent_status_callback() {
8863 // Always get fresh options instead of using cached $this->options
8864 $fresh_options = get_option('mxchat_options');
8865 $status = isset($fresh_options['live_agent_status']) ? $fresh_options['live_agent_status'] : 'off';
8866
8867 echo '<label class="toggle-switch">';
8868 echo sprintf(
8869 '<input type="checkbox" id="live_agent_status" name="live_agent_status" value="on" %s />',
8870 checked($status, 'on', false)
8871 );
8872 echo '<span class="slider"></span>';
8873 echo '</label>';
8874 echo '<label for="live_agent_status" class="mxchat-status-label">';
8875 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
8876 echo '</label>';
8877 }
8878
8879 /**
8880 * Availability-schedule editor (plans 8ccaa2 + 99d7a4).
8881 *
8882 * ONE callback, parameterized by channel ('slack' | 'telegram' via the
8883 * add_settings_field $args) — the markup and CSS are shared, only the bound
8884 * option and the helper text differ. Each channel's editor renders under its
8885 * own Integrations tab and governs ONLY that channel's handoff. While the
8886 * master toggle is off the day grid is inert and handoff availability stays
8887 * governed solely by that channel's manual status toggle.
8888 *
8889 * The day inputs carry NO name attribute and are marked .mxchat-la-field so the
8890 * generic autosave skips them; the editor JS folds them into the channel's
8891 * hidden live_agent_schedule_<channel> input and fires one change, reusing the
8892 * existing autosave transport rather than growing a second one. All hooks the
8893 * JS needs are CLASSES scoped inside .mxchat-la-schedule, never ids — the
8894 * markup exists twice on the page.
8895 */
8896 public function mxchat_live_agent_schedule_callback($args = array()) {
8897 if (!class_exists('MxChat_Live_Agent_Schedule')) {
8898 return;
8899 }
8900 $channel = (isset($args['channel']) && $args['channel'] === 'telegram') ? 'telegram' : 'slack';
8901 $field = 'live_agent_schedule_' . $channel;
8902 $sched = MxChat_Live_Agent_Schedule::get($channel);
8903 $labels = MxChat_Live_Agent_Schedule::day_labels();
8904 $tz = MxChat_Live_Agent_Schedule::timezone_label();
8905 $enabled = !empty($sched['enabled']);
8906 $channel_label = ($channel === 'telegram') ? __('Telegram', 'mxchat') : __('Slack', 'mxchat');
8907 ?>
8908 <div class="mxchat-la-schedule<?php echo $enabled ? ' is-active' : ''; ?>"
8909 id="mxchat-la-schedule-<?php echo esc_attr($channel); ?>"
8910 data-channel="<?php echo esc_attr($channel); ?>">
8911 <label class="toggle-switch">
8912 <input type="checkbox" id="<?php echo esc_attr($field); ?>_enabled"
8913 class="mxchat-la-field mxchat-la-enabled" <?php checked($enabled); ?> />
8914 <span class="slider"></span>
8915 </label>
8916 <label for="<?php echo esc_attr($field); ?>_enabled" class="mxchat-status-label">
8917 <span class="status-text mxchat-la-status-text">
8918 <?php echo $enabled
8919 ? esc_html__('Scheduled hours', 'mxchat')
8920 : esc_html__('Always available', 'mxchat'); ?>
8921 </span>
8922 </label>
8923
8924 <div class="mxchat-la-days" aria-hidden="<?php echo $enabled ? 'false' : 'true'; ?>">
8925 <?php foreach ($labels as $n => $label) :
8926 $day = $sched['days'][$n];
8927 $on = !empty($day['enabled']);
8928 ?>
8929 <div class="mxchat-la-day<?php echo $on ? ' is-on' : ''; ?>" data-day="<?php echo esc_attr($n); ?>">
8930 <label class="mxchat-la-day-label">
8931 <input type="checkbox" class="mxchat-la-field mxchat-la-day-enabled"
8932 data-day="<?php echo esc_attr($n); ?>" <?php checked($on); ?> />
8933 <span class="mxchat-la-day-name"><?php echo esc_html($label); ?></span>
8934 </label>
8935 <div class="mxchat-la-times">
8936 <input type="time" class="mxchat-la-field mxchat-la-start"
8937 data-day="<?php echo esc_attr($n); ?>"
8938 value="<?php echo esc_attr($day['start']); ?>"
8939 aria-label="<?php echo esc_attr(sprintf(__('%s start time', 'mxchat'), $label)); ?>" />
8940 <span class="mxchat-la-dash">&ndash;</span>
8941 <input type="time" class="mxchat-la-field mxchat-la-end"
8942 data-day="<?php echo esc_attr($n); ?>"
8943 value="<?php echo esc_attr($day['end']); ?>"
8944 aria-label="<?php echo esc_attr(sprintf(__('%s end time', 'mxchat'), $label)); ?>" />
8945 </div>
8946 </div>
8947 <?php endforeach; ?>
8948 </div>
8949
8950 <input type="hidden" name="<?php echo esc_attr($field); ?>" id="<?php echo esc_attr($field); ?>"
8951 class="mxchat-la-hidden" value="<?php echo esc_attr(wp_json_encode($sched)); ?>" />
8952 </div>
8953 <p class="description">
8954 <?php printf(
8955 /* translators: 1: the handoff channel, e.g. Slack or Telegram; 2: the site's timezone, e.g. America/New_York */
8956 esc_html__('When on, visitors are only offered a %1$s human handoff during these hours — outside them the chatbot keeps answering and never offers to fetch an agent. Applies to %1$s only. Times use the site timezone (%2$s). Set a day\'s end time earlier than its start for an overnight shift; identical start and end means all day.', 'mxchat'),
8957 '<strong>' . esc_html($channel_label) . '</strong>',
8958 '<strong>' . esc_html($tz) . '</strong>'
8959 ); ?>
8960 </p>
8961 <?php
8962 }
8963
8964 public function mxchat_live_agent_away_message_callback() {
8965 $message = isset($this->options['live_agent_away_message'])
8966 ? $this->options['live_agent_away_message']
8967 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
8968
8969 printf(
8970 '<textarea id="live_agent_away_message" name="live_agent_away_message" rows="3" cols="50">%s</textarea>',
8971 esc_textarea($message)
8972 );
8973 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
8974 }
8975
8976 public function mxchat_live_agent_notification_message_callback() {
8977 $message = isset($this->options['live_agent_notification_message'])
8978 ? $this->options['live_agent_notification_message']
8979 : __('Live agent has been notified.', 'mxchat');
8980
8981 printf(
8982 '<textarea id="live_agent_notification_message" name="live_agent_notification_message" rows="3" cols="50">%s</textarea>',
8983 esc_textarea($message)
8984 );
8985 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
8986 }
8987
8988 public function mxchat_live_agent_webhook_url_callback() {
8989 $webhook_url = isset($this->options['live_agent_webhook_url'])
8990 ? esc_url($this->options['live_agent_webhook_url'])
8991 : esc_url(get_option('live_agent_webhook_url', ''));
8992
8993 printf(
8994 '<input type="password" id="live_agent_webhook_url" name="live_agent_webhook_url" value="%s" class="regular-text" />',
8995 $webhook_url
8996 );
8997 echo '<button type="button" id="toggleWebhookUrlVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8998 echo '<p class="description">' . esc_html__('Enter your Slack webhook URL for live agent notifications.', 'mxchat') . '</p>';
8999 }
9000
9001 public function mxchat_live_agent_secret_key_callback() {
9002 printf(
9003 '<input type="password" id="live_agent_secret_key" name="live_agent_secret_key" value="%s" class="regular-text" />',
9004 isset($this->options['live_agent_secret_key']) ? esc_attr($this->options['live_agent_secret_key']) : ''
9005 );
9006 echo '<button type="button" id="toggleSecretKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9007 echo '<p class="description">' . esc_html__('Secret key for validating Slack requests. Keep this secure.', 'mxchat') . '</p>';
9008 }
9009
9010 public function mxchat_live_agent_bot_token_callback() {
9011 printf(
9012 '<input type="password" id="live_agent_bot_token" name="live_agent_bot_token" value="%s" class="regular-text" />',
9013 isset($this->options['live_agent_bot_token']) ? esc_attr($this->options['live_agent_bot_token']) : ''
9014 );
9015 echo '<button type="button" id="toggleBotTokenVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9016 echo '<p class="description">' . esc_html__('Your Slack Bot OAuth Token (starts with xoxb-). Keep this secure.', 'mxchat') . '</p>';
9017 }
9018
9019 public function mxchat_live_agent_user_ids_callback() {
9020 $user_ids = isset($this->options['live_agent_user_ids'])
9021 ? esc_textarea($this->options['live_agent_user_ids'])
9022 : '';
9023
9024 printf(
9025 '<textarea id="live_agent_user_ids" name="live_agent_user_ids" rows="4" class="large-text">%s</textarea>',
9026 $user_ids
9027 );
9028 echo '<p class="description">' . esc_html__('Enter Slack User IDs of agents who should be automatically invited to chat channels (one per line). Find user IDs in Slack profiles under "More" → "Copy member ID". Example: U1234567890', 'mxchat') . '</p>';
9029 }
9030
9031 public function mxchat_live_agent_shared_channel_callback() {
9032 $value = isset($this->options['live_agent_shared_channel']) ? $this->options['live_agent_shared_channel'] : '';
9033 printf(
9034 '<input type="text" id="live_agent_shared_channel" name="live_agent_shared_channel" value="%s" class="regular-text" placeholder="#support or C0123456789" />',
9035 esc_attr($value)
9036 );
9037 echo '<p class="description">' . esc_html__('Optional. Route ALL live agent handoffs into this one existing Slack channel — each conversation becomes its own thread there. Enter a channel ID (starts with C) or a #channel-name, and invite your bot to the channel first (/invite @YourBot). Agents reply inside a conversation\'s thread; !endchat inside the thread ends that chat. Leave blank to keep creating a separate chat- channel per conversation.', 'mxchat') . '</p>';
9038
9039 // Surface the last failed handoff so a wrong name / missing invite is
9040 // visible right where it gets fixed. A successful handoff clears this.
9041 $shared_error = get_option('mxchat_slack_shared_channel_error');
9042 if (!empty($shared_error['error']) && trim((string) $value) !== '' && ($shared_error['configured'] ?? '') === trim((string) $value)) {
9043 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this channel', 'mxchat') . '</strong> — '
9044 . esc_html(sprintf(
9045 /* translators: %s: Slack API error code */
9046 __('Slack said "%s". Handoffs are falling back to per-conversation channels until this is fixed. Check the channel name or ID and make sure the bot has been invited to it.', 'mxchat'),
9047 $shared_error['error']
9048 )) . '</p>';
9049 }
9050 }
9051
9052 public function mxchat_live_agent_archive_on_end_callback() {
9053 $value = isset($this->options['live_agent_archive_on_end_toggle']) ? $this->options['live_agent_archive_on_end_toggle'] : 'off';
9054 printf(
9055 '<input type="checkbox" id="live_agent_archive_on_end_toggle" name="live_agent_archive_on_end_toggle" value="on" %s />',
9056 checked('on', $value, false)
9057 );
9058 echo '<label for="live_agent_archive_on_end_toggle" class="mxchat-status-label">' . esc_html__('Archive the conversation\'s chat- channel when an agent ends the chat with !endchat', 'mxchat') . '</label>';
9059 echo '<p class="description">' . esc_html__('Keeps your Slack sidebar tidy on busy sites — each ended conversation\'s channel is archived instead of living forever. Archived channels stay searchable in Slack, so the record is never lost. Only applies to per-conversation chat- channels; a Shared Handoff Channel is never archived. If a returning visitor requests an agent again, a fresh channel is created automatically.', 'mxchat') . '</p>';
9060 }
9061
9062 /**
9063 * Telegram Integration Callbacks
9064 */
9065 public function mxchat_telegram_section_callback() {
9066 echo '<p>' . esc_html__('Configure Telegram integration for live agent support.', 'mxchat') . '</p>';
9067 }
9068
9069 public function mxchat_telegram_status_callback() {
9070 $fresh_options = get_option('mxchat_options');
9071 $status = isset($fresh_options['telegram_status']) ? $fresh_options['telegram_status'] : 'off';
9072
9073 echo '<label class="toggle-switch">';
9074 echo sprintf(
9075 '<input type="checkbox" id="telegram_status" name="telegram_status" value="on" %s />',
9076 checked($status, 'on', false)
9077 );
9078 echo '<span class="slider"></span>';
9079 echo '</label>';
9080 echo '<label for="telegram_status" class="mxchat-status-label">';
9081 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9082 echo '</label>';
9083 }
9084
9085 public function mxchat_telegram_notification_message_callback() {
9086 $message = isset($this->options['telegram_notification_message'])
9087 ? $this->options['telegram_notification_message']
9088 : __("I've notified a support agent. Please allow a moment for them to respond.", 'mxchat');
9089
9090 printf(
9091 '<textarea id="telegram_notification_message" name="telegram_notification_message" rows="3" cols="50">%s</textarea>',
9092 esc_textarea($message)
9093 );
9094 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9095 }
9096
9097 public function mxchat_telegram_away_message_callback() {
9098 $message = isset($this->options['telegram_away_message'])
9099 ? $this->options['telegram_away_message']
9100 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9101
9102 printf(
9103 '<textarea id="telegram_away_message" name="telegram_away_message" rows="3" cols="50">%s</textarea>',
9104 esc_textarea($message)
9105 );
9106 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9107 }
9108
9109 public function mxchat_telegram_bot_token_callback() {
9110 printf(
9111 '<input type="password" id="telegram_bot_token" name="telegram_bot_token" value="%s" class="regular-text" />',
9112 isset($this->options['telegram_bot_token']) ? esc_attr($this->options['telegram_bot_token']) : ''
9113 );
9114 echo '<button type="button" class="button button-secondary" onclick="var f=document.getElementById(\'telegram_bot_token\'); if(f.type===\'password\'){f.type=\'text\';this.textContent=\'' . esc_js(__('Hide', 'mxchat')) . '\';}else{f.type=\'password\';this.textContent=\'' . esc_js(__('Show', 'mxchat')) . '\';}">' . esc_html__('Show', 'mxchat') . '</button>';
9115 echo '<p class="description">' . esc_html__('Your Telegram Bot Token from @BotFather (e.g., 123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ).', 'mxchat') . '</p>';
9116 }
9117
9118 public function mxchat_telegram_group_id_callback() {
9119 printf(
9120 '<input type="text" id="telegram_group_id" name="telegram_group_id" value="%s" class="regular-text" />',
9121 isset($this->options['telegram_group_id']) ? esc_attr($this->options['telegram_group_id']) : ''
9122 );
9123 echo '<p class="description">' . esc_html__('Your Telegram supergroup ID (starts with -100). The group must have forum topics enabled.', 'mxchat') . '</p>';
9124 }
9125
9126 public function mxchat_telegram_webhook_secret_callback() {
9127 $secret = isset($this->options['telegram_webhook_secret']) ? $this->options['telegram_webhook_secret'] : '';
9128
9129 // Auto-generate secret if empty
9130 if (empty($secret)) {
9131 $secret = wp_generate_password(64, false, false);
9132 $options = get_option('mxchat_options', []);
9133 $options['telegram_webhook_secret'] = $secret;
9134 update_option('mxchat_options', $options);
9135 $this->options['telegram_webhook_secret'] = $secret;
9136 }
9137
9138 printf(
9139 '<input type="password" id="telegram_webhook_secret" name="telegram_webhook_secret" value="%s" class="regular-text" />',
9140 esc_attr($secret)
9141 );
9142 echo '<button type="button" class="button button-secondary" onclick="var f=document.getElementById(\'telegram_webhook_secret\'); if(f.type===\'password\'){f.type=\'text\';this.textContent=\'' . esc_js(__('Hide', 'mxchat')) . '\';}else{f.type=\'password\';this.textContent=\'' . esc_js(__('Show', 'mxchat')) . '\';}">' . esc_html__('Show', 'mxchat') . '</button>';
9143 echo '<p class="description">' . esc_html__('Secret token for webhook verification. Use this when setting up your Telegram webhook with the secret_token parameter.', 'mxchat') . '</p>';
9144 }
9145
9146 public function mxchat_similarity_threshold_callback() {
9147 // Load from mxchat_options array
9148 $options = get_option('mxchat_options', []);
9149
9150 // Get value from options array with default of 35
9151 $threshold = isset($options['similarity_threshold']) ? $options['similarity_threshold'] : 35;
9152
9153 echo '<div class="slider-container">';
9154 echo sprintf(
9155 '<input type="range"
9156 id="similarity_threshold"
9157 name="similarity_threshold"
9158 min="20"
9159 max="85"
9160 step="1"
9161 value="%s"
9162 class="range-slider" />',
9163 esc_attr($threshold)
9164 );
9165 echo sprintf(
9166 '<span id="threshold_value" class="range-value">%s</span>',
9167 esc_html($threshold)
9168 );
9169 echo '</div>';
9170 }
9171
9172 public function mxchat_max_input_length_callback() {
9173 // Load from mxchat_options array (plan a3fae2 part C).
9174 $options = get_option('mxchat_options', []);
9175
9176 // 0 = unlimited (default — preserves current behavior, no cap).
9177 $max_input_length = isset($options['max_input_length']) ? intval($options['max_input_length']) : 0;
9178
9179 echo sprintf(
9180 '<input type="number"
9181 id="max_input_length"
9182 name="max_input_length"
9183 min="0"
9184 max="100000"
9185 step="1"
9186 value="%s"
9187 placeholder="0"
9188 class="mxch-input mxch-input-sm" />',
9189 esc_attr($max_input_length)
9190 );
9191 }
9192
9193 public function mxchat_rag_sources_limit_callback() {
9194 // Load from mxchat_options array
9195 $options = get_option('mxchat_options', []);
9196
9197 // Get value from options array with default of 3
9198 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
9199
9200 echo '<div class="slider-container">';
9201 echo sprintf(
9202 '<input type="range"
9203 id="rag_sources_limit"
9204 name="rag_sources_limit"
9205 min="3"
9206 max="10"
9207 step="1"
9208 value="%s"
9209 class="range-slider" />',
9210 esc_attr($rag_sources_limit)
9211 );
9212 echo sprintf(
9213 '<span id="rag_sources_limit_value" class="range-value">%s</span>',
9214 esc_html($rag_sources_limit)
9215 );
9216 echo '</div>';
9217 }
9218
9219 public function mxchat_rag_chunks_limit_callback() {
9220 // Load from mxchat_options array
9221 $options = get_option('mxchat_options', []);
9222
9223 // Get value from options array with default of 15
9224 $rag_chunks_limit = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
9225
9226 echo '<div class="slider-container">';
9227 echo sprintf(
9228 '<input type="range"
9229 id="rag_chunks_limit"
9230 name="rag_chunks_limit"
9231 min="8"
9232 max="20"
9233 step="1"
9234 value="%s"
9235 class="range-slider" />',
9236 esc_attr($rag_chunks_limit)
9237 );
9238 echo sprintf(
9239 '<span id="rag_chunks_limit_value" class="range-value">%s</span>',
9240 esc_html($rag_chunks_limit)
9241 );
9242 echo '</div>';
9243 }
9244
9245 /**
9246 * Add body class on the Onboarding wizard page so CSS-only chrome surgery
9247 * (collapsing the WP admin sidebar) only applies on this page.
9248 * Plan: plan-mxchat-20260527-905439.
9249 */
9250 public function mxchat_add_onboarding_body_class($classes) {
9251 if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
9252 $classes .= ' mxchat-onboarding-focused';
9253 }
9254 return $classes;
9255 }
9256
9257 public function mxchat_enqueue_admin_assets($hook_suffix = '') {
9258 // Authorization gate (plan-mxchat-20260731-c63fb6). Previously the ONLY
9259 // guard here was the strpos() on $_GET['page'] below — which is
9260 // attacker-controlled — so ANY logged-in user (Subscriber included) could
9261 // load a page they are legitimately allowed to see, e.g.
9262 // /wp-admin/profile.php?page=mxchat, and receive every nonce this method
9263 // localizes. Three of the handlers behind those nonces verified the nonce
9264 // but checked no capability. Every MxChat menu page is registered
9265 // 'manage_options' (see mxchat_add_admin_menu), so this is a no-op for
9266 // legitimate users.
9267 if (!current_user_can('manage_options')) {
9268 return;
9269 }
9270
9271 // Get plugin version
9272 $version = MXCHAT_VERSION;
9273
9274 // Use file modification time for development (remove in production)
9275 if (defined('WP_DEBUG') && WP_DEBUG) {
9276 $version = filemtime(plugin_dir_path(__FILE__) . '../mxchat-basic.php');
9277 }
9278
9279 $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
9280 $plugin_url = plugin_dir_url(__FILE__) . '../';
9281
9282 // Only load on MxChat pages. Prefer the server-derived $hook_suffix that
9283 // admin_enqueue_scripts passes us — unlike $_GET['page'] it cannot be
9284 // forged onto a screen MxChat does not own. Falls back to the legacy
9285 // $_GET check only when the hook is unavailable (direct/legacy callers).
9286 if ($hook_suffix !== '') {
9287 if (strpos($hook_suffix, 'mxchat') === false) {
9288 return;
9289 }
9290 } elseif (strpos($current_page, 'mxchat') === false) {
9291 return;
9292 }
9293
9294 // Always load these on all MxChat pages
9295 $this->enqueue_core_admin_assets($plugin_url, $version);
9296 $this->enqueue_page_specific_assets($current_page, $plugin_url, $version);
9297 $this->localize_admin_scripts($current_page);
9298 }
9299 private function enqueue_core_admin_assets($plugin_url, $version) {
9300 // Core admin styles
9301 wp_enqueue_style('mxchat-admin-css', $plugin_url . 'css/admin-style.css', array(), $version);
9302 wp_enqueue_style('mxchat-knowledge-css', $plugin_url . 'css/knowledge-style.css', array(), $version);
9303
9304 // New sidebar navigation styles (for main settings page)
9305 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9306
9307 // Core admin scripts
9308 wp_enqueue_script('mxchat-admin-js', $plugin_url . 'js/mxchat-admin.js', array('jquery'), $version, true);
9309 }
9310 private function enqueue_page_specific_assets($current_page, $plugin_url, $version) {
9311 switch ($current_page) {
9312 case 'mxchat-prompts':
9313 // Knowledge processing page assets
9314 wp_enqueue_style('mxchat-content-selector-css', $plugin_url . 'css/content-selector.css', array(), $version);
9315 wp_enqueue_script('mxchat-content-selector-js', $plugin_url . 'js/content-selector.js', array('jquery'), $version, true);
9316 // Add the knowledge processing script (common script needed for WordPress dismiss functionality)
9317 wp_enqueue_script('mxchat-knowledge-processing', $plugin_url . 'js/knowledge-processing.js', array('jquery', 'common'), $version, true);
9318 // Per-entry "View indexed content" inspector (plan-d8cb4b) reuses the
9319 // Testing tab's match-card / chunk-detail components, which are scoped
9320 // under .mxch-testing-results. Load that stylesheet here so the inspector
9321 // modal renders identically to the Testing tab.
9322 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css'), $version);
9323 break;
9324
9325 case 'mxchat-transcripts':
9326 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9327 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9328 // Load transcripts-specific styles
9329 wp_enqueue_style('mxchat-chat-transcripts-css', $plugin_url . 'css/chat-transcripts.css', array('mxchat-admin-sidebar-css'), $version);
9330 wp_enqueue_script('mxchat-transcripts-js', $plugin_url . 'js/mxchat_transcripts.js', array('jquery'), $version, true);
9331 break;
9332
9333 case 'mxchat-actions':
9334 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9335 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9336 // Load actions-specific styles
9337 wp_enqueue_style('mxchat-actions-css', $plugin_url . 'css/actions.css', array('mxchat-admin-sidebar-css'), $version);
9338 wp_enqueue_script('mxchat-actions-js', $plugin_url . 'js/mxchat_actions.js', array('jquery'), $version, true);
9339
9340 // Localize script data for actions page
9341 $is_activated = get_option('mxchat_pro_license_status') === 'active';
9342 wp_localize_script('mxchat-actions-js', 'mxchActionsData', array(
9343 'ajaxUrl' => admin_url('admin-ajax.php'),
9344 'nonce' => wp_create_nonce('mxchat_actions_nonce'),
9345 'addNonce' => wp_create_nonce('mxchat_add_intent_nonce'),
9346 'editNonce' => wp_create_nonce('mxchat_edit_intent'),
9347 'deleteNonce' => wp_create_nonce('mxchat_delete_intent_nonce'),
9348 'toggleNonce' => wp_create_nonce('mxchat_actions_nonce'),
9349 'addPhraseNonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
9350 'deletePhraseNonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
9351 'getPhrasesNonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
9352 'deleteLegacyNonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
9353 'isActivated' => $is_activated,
9354 'i18n' => array(
9355 'confirmDelete' => __('Are you sure you want to delete this trigger phrase?', 'mxchat'),
9356 'confirmBulkDelete' => __('Are you sure you want to delete the selected trigger phrases?', 'mxchat'),
9357 'saving' => __('Saving...', 'mxchat'),
9358 'saved' => __('Saved successfully!', 'mxchat'),
9359 'error' => __('An error occurred. Please try again.', 'mxchat'),
9360 'proRequired' => __('This feature requires MxChat Pro.', 'mxchat'),
9361 'addonRequired' => __('This feature requires an add-on.', 'mxchat')
9362 )
9363 ));
9364 break;
9365
9366 case 'mxchat-activation':
9367 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9368 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9369 // Load pro page-specific styles
9370 wp_enqueue_style('mxchat-pro-css', $plugin_url . 'css/admin-pro.css', array('mxchat-admin-sidebar-css'), $version);
9371 // Load pro page JavaScript
9372 wp_enqueue_script('mxchat-pro-js', $plugin_url . 'js/mxchat_pro.js', array('jquery'), $version, true);
9373 // Load activation script for license activation/deactivation
9374 wp_enqueue_script('mxchat-activation-js', $plugin_url . 'js/activation-script.js', array('jquery'), $version, true);
9375 break;
9376
9377 case 'mxchat-content':
9378 // Load admin sidebar CSS (shared styles for sidebar navigation)
9379 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9380 // Load content page-specific styles
9381 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
9382 // Load content page JavaScript
9383 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
9384 break;
9385
9386 case 'mxchat-api-access':
9387 // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
9388 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9389 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9390 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9391 'copied' => __('Copied', 'mxchat'),
9392 ));
9393 break;
9394
9395 case 'mxchat-max':
9396 case 'mxchat-onboarding':
9397 // Onboarding page — uses the shared admin shell PLUS the wizard
9398 // overlay (plan-905439). admin-onboarding-wizard.css scopes the
9399 // WP-chrome surgery to body.mxchat-onboarding-focused so it only
9400 // applies on THIS page. The body class is added below via the
9401 // admin_body_class filter.
9402 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9403 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9404 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9405 'copied' => __('Copied', 'mxchat'),
9406 ));
9407 // admin-style.css provides the .mxchat-instructions-modal-* classes
9408 // the new Behavior step's "View Sample Instructions" modal needs.
9409 // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
9410 // so the wizard's own rules win where they collide. plan-a2e4d6.
9411 wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
9412 wp_enqueue_style('mxchat-admin-onboarding-wizard-css', $plugin_url . 'css/admin-onboarding-wizard.css', array('mxchat-admin-sidebar-css', 'mxchat-admin-style-css'), $version);
9413 wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
9414 break;
9415 default:
9416 wp_enqueue_script(
9417 'mxchat-test-streaming-js',
9418 $plugin_url . 'js/mxchat-test-streaming.js',
9419 ['jquery'],
9420 $version,
9421 true
9422 );
9423
9424 wp_localize_script('mxchat-test-streaming-js', 'mxchatTestStreamingAjax', [
9425 'ajax_url' => admin_url('admin-ajax.php'),
9426 'nonce' => wp_create_nonce('mxchat_test_streaming_nonce'),
9427 'settings_nonce' => wp_create_nonce('mxchat_save_setting_nonce')
9428 ]);
9429
9430 // Testing Tab: Load chatbot assets for the embedded testing chatbot
9431 wp_enqueue_style('mxchat-chat-css', $plugin_url . 'css/chat-style.css', array(), $version);
9432 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css', 'mxchat-chat-css'), $version);
9433
9434 wp_enqueue_script('mxchat-chat-js', $plugin_url . 'js/chat-script.js', array('jquery'), $version, true);
9435 wp_enqueue_script('mxchat-admin-testing-js', $plugin_url . 'js/admin-testing-tab.js', array('jquery', 'mxchat-chat-js'), $version, true);
9436
9437 // Allow add-ons to enqueue their public CSS/JS for the testing chatbot
9438 do_action('mxchat_enqueue_testing_tab_assets');
9439
9440 // Localize mxchatChat for the chatbot JS (same settings as frontend)
9441 $options = get_option('mxchat_options', array());
9442 $prompts_options = get_option('mxchat_prompts_options', array());
9443 $theme_options = get_option('mxchat_theme_options', array());
9444 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9445 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9446 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9447
9448 wp_localize_script('mxchat-chat-js', 'mxchatChat', array(
9449 'ajax_url' => admin_url('admin-ajax.php'),
9450 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9451 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
9452 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
9453 'contextual_awareness_toggle' => isset($options['contextual_awareness_toggle']) ? $options['contextual_awareness_toggle'] : 'off',
9454 'link_target_toggle' => $options['link_target_toggle'] ?? 'off',
9455 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9456 'complianz_toggle' => isset($options['complianz_toggle']) && $options['complianz_toggle'] === 'on',
9457 'user_message_bg_color' => $options['user_message_bg_color'] ?? '#fff',
9458 'user_message_font_color' => $options['user_message_font_color'] ?? '#212121',
9459 'bot_message_bg_color' => $options['bot_message_bg_color'] ?? '#212121',
9460 'bot_message_font_color' => $options['bot_message_font_color'] ?? '#fff',
9461 'top_bar_bg_color' => $options['top_bar_bg_color'] ?? '#212121',
9462 'send_button_font_color' => $options['send_button_font_color'] ?? '#212121',
9463 'close_button_color' => $options['close_button_color'] ?? '#fff',
9464 'chatbot_background_color' => $options['chatbot_background_color'] ?? '#212121',
9465 'chatbot_bg_color' => $options['chatbot_bg_color'] ?? '#fff',
9466 'icon_color' => $options['icon_color'] ?? '#fff',
9467 'chat_input_font_color' => $options['chat_input_font_color'] ?? '#212121',
9468 'chat_persistence_toggle' => 'off', // Always off for testing chatbot
9469 'appendWidgetToBody' => 'off',
9470 'live_agent_message_bg_color' => $options['live_agent_message_bg_color'] ?? '#ffffff',
9471 'live_agent_message_font_color' => $options['live_agent_message_font_color'] ?? '#333333',
9472 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
9473 'mode_indicator_bg_color' => $options['mode_indicator_bg_color'] ?? '#767676',
9474 'mode_indicator_font_color' => $options['mode_indicator_font_color'] ?? '#ffffff',
9475 'toolbar_icon_color' => $options['toolbar_icon_color'] ?? '#212121',
9476 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9477 'email_collection_enabled' => 'off', // No email collection in testing
9478 'initial_email_state' => null,
9479 'skip_email_check' => true,
9480 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9481 'skip_inline_colors' => $skip_inline_colors,
9482 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9483 ));
9484
9485 // Localize testing tab script data
9486 wp_localize_script('mxchat-admin-testing-js', 'mxchatAdminTestData', array(
9487 'ajaxUrl' => admin_url('admin-ajax.php'),
9488 'nonce' => wp_create_nonce('mxchat_test_nonce'),
9489 'isAdmin' => true,
9490 'testingEnabled' => true
9491 ));
9492
9493 // Add testing enabled flag for the chatbot to return debug data
9494 add_action('admin_footer', function() {
9495 echo '<script>window.mxchatTestingEnabled = true;</script>';
9496 });
9497
9498 break;
9499 }
9500 }
9501 private function localize_admin_scripts($current_page) {
9502 // Base localization data for main admin script
9503 $base_data = array(
9504 'ajax_url' => admin_url('admin-ajax.php'),
9505 'nonce' => wp_create_nonce('mxchat_admin_nonce'),
9506 'admin_url' => admin_url(),
9507 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce'),
9508 'inline_edit_nonce' => wp_create_nonce('mxchat_save_inline_nonce'),
9509 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9510 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
9511 'actions_nonce' => wp_create_nonce('mxchat_actions_nonce'),
9512 'add_intent_nonce' => wp_create_nonce('mxchat_add_intent_nonce'),
9513 'edit_intent_nonce' => wp_create_nonce('mxchat_edit_intent'),
9514 'add_phrase_nonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
9515 'delete_phrase_nonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
9516 'get_phrases_nonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
9517 'delete_legacy_nonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
9518 'toggle_action_nonce' => wp_create_nonce('mxchat_actions_nonce'),
9519 'fetch_openrouter_models_nonce' => wp_create_nonce('mxchat_fetch_openrouter_models'),
9520 'is_activated' => $this->is_activated ? '1' : '0',
9521 'status_refresh_interval' => 5000,
9522 'discard_changes_confirm' => __('Discard your unsaved changes?', 'mxchat'),
9523 // Live-agent schedule status text (plan 8ccaa2).
9524 'i18n_scheduled_hours' => __('Scheduled hours', 'mxchat'),
9525 'i18n_always_available' => __('Always available', 'mxchat'),
9526 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9527 'ajaxurl' => admin_url('admin-ajax.php')
9528 );
9529
9530 // Localize main admin script with base data
9531 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
9532
9533 // Canonical chat-model catalog for the modal picker grid
9534 // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
9535 // automatically appears here.
9536 if (!class_exists('MxChat_Model_Catalog')) {
9537 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9538 }
9539 wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
9540
9541 // Page-specific localizations
9542 $this->localize_page_specific_scripts($current_page);
9543 }
9544 private function localize_page_specific_scripts($current_page) {
9545 switch ($current_page) {
9546 case 'mxchat-prompts':
9547 // Status updater localization
9548 wp_localize_script('mxchat-status-updater', 'mxchat_status_data', array(
9549 'ajax_url' => admin_url('admin-ajax.php'),
9550 'nonce' => wp_create_nonce('mxchat_status_nonce')
9551 ));
9552
9553 // Content selector localization
9554 $mxchat_options_for_selector = get_option('mxchat_options', array());
9555 $acf_pdf_extract_default = !empty($mxchat_options_for_selector['acf_pdf_extract_default']);
9556 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
9557 'ajaxurl' => admin_url('admin-ajax.php'),
9558 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
9559 'acfPdfExtractDefault' => $acf_pdf_extract_default ? 1 : 0,
9560 'i18n' => array(
9561 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
9562 'selectAll' => __('Select All', 'mxchat'),
9563 'process' => __('Process Selected', 'mxchat'),
9564 'cancel' => __('Cancel', 'mxchat'),
9565 'noResults' => __('No content found.', 'mxchat'),
9566 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
9567 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
9568 )
9569 ));
9570
9571 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array(
9572 'ajax_url' => admin_url('admin-ajax.php'),
9573 'status_nonce' => wp_create_nonce('mxchat_status_nonce'),
9574 'queue_nonce' => wp_create_nonce('mxchat_queue_nonce'),
9575 'stop_nonce' => wp_create_nonce('mxchat_stop_processing_action'),
9576 'settings_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9577 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9578 'entries_nonce' => wp_create_nonce('mxchat_entries_nonce'),
9579 'admin_url' => admin_url(),
9580 'ajaxurl' => admin_url('admin-ajax.php'),
9581 'status_refresh_interval' => 2000,
9582 'bot_id' => isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default'
9583 ));
9584 break;
9585
9586 case 'mxchat-activation':
9587 wp_localize_script('mxchat-activation-js', 'mxchatAdmin', array(
9588 'ajax_url' => admin_url('admin-ajax.php'),
9589 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce')
9590 ));
9591 break;
9592
9593 case 'mxchat-content':
9594 wp_localize_script('mxchat-content-js', 'mxchatContent', array(
9595 'ajaxUrl' => admin_url('admin-ajax.php'),
9596 'nonce' => wp_create_nonce('mxchat_content_nonce'),
9597 'settingNonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9598 'previewUrl' => home_url('/?p='),
9599 'isActivated' => $this->is_activated(),
9600 'hasAdvancedContent' => apply_filters('mxchat_content_pro_feature', false, 'seo_readability'),
9601 'hasGSC' => apply_filters('mxchat_content_pro_feature', false, 'gsc_integration'),
9602 // Whether Search Console is actually LINKED — distinct from hasGSC
9603 // (add-on active). Reads the same option the add-on's settings
9604 // card keys off; harmless false when the add-on is absent.
9605 'gscLinked' => (bool) get_option('mxchat_gsc_connected', false),
9606 'seoOptimize' => array(
9607 'meta_description' => ($options['seo_optimize_meta_desc'] ?? 'on') === 'on',
9608 'seo_title' => ($options['seo_optimize_seo_title'] ?? 'on') === 'on',
9609 'slug' => ($options['seo_optimize_slug'] ?? 'on') === 'on',
9610 'readability' => ($options['seo_optimize_readability'] ?? 'on') === 'on',
9611 'internal_links' => ($options['seo_optimize_internal_links'] ?? 'on') === 'on',
9612 'img_alt' => ($options['seo_optimize_img_alt'] ?? 'on') === 'on',
9613 'featured_img' => ($options['seo_optimize_featured_img'] ?? 'on') === 'on',
9614 ),
9615 'i18n' => array(
9616 'generating' => __('Generating...', 'mxchat'),
9617 'planning' => __('Planning content structure...', 'mxchat'),
9618 'images' => __('Generating images...', 'mxchat'),
9619 'writing' => __('Writing full content...', 'mxchat'),
9620 'creating' => __('Creating WordPress post...', 'mxchat'),
9621 'done' => __('Content generated successfully!', 'mxchat'),
9622 'error' => __('An error occurred. Please try again.', 'mxchat'),
9623 'editSuccess' => __('Content updated successfully.', 'mxchat'),
9624 'promptEmpty' => __('Please enter a prompt.', 'mxchat'),
9625 )
9626 ));
9627 break;
9628
9629 case 'mxchat-transcripts':
9630 // Get chart data for localization
9631 $chart_data = $this->get_transcripts_chart_data();
9632
9633 wp_localize_script('mxchat-transcripts-js', 'mxchatAdmin', array(
9634 'ajax_url' => admin_url('admin-ajax.php'),
9635 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
9636 'delete_nonce' => wp_create_nonce('mxchat_delete_chat_history'),
9637 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9638 'translate_nonce' => wp_create_nonce('mxchat_translate_messages')
9639 ));
9640
9641 // Localize chart data separately - use array_values to ensure proper JSON array encoding
9642 wp_localize_script('mxchat-transcripts-js', 'mxchatChartData', array(
9643 'labels' => array_values($chart_data['labels']),
9644 'chats' => array_values($chart_data['chats']),
9645 'messages' => array_values($chart_data['messages'])
9646 ));
9647 break;
9648
9649 case 'mxchat-settings':
9650 default:
9651 // The 'mxchatStyleSettings' localize that used to live here was
9652 // REMOVED by plan-mxchat-20260731-c63fb6.
9653 //
9654 // It targeted the script handle 'mxchat-color-picker', which is
9655 // registered/enqueued NOWHERE in the plugin — so wp_localize_script()
9656 // returned false and printed nothing. That accident was the only
9657 // thing keeping it from being a live secret disclosure: the payload
9658 // carried loops_api_key, live_agent_secret_key and
9659 // live_agent_bot_token in full, and this whole method ran for any
9660 // logged-in user (see the capability gate added to
9661 // mxchat_enqueue_admin_assets). One future
9662 // wp_enqueue_script('mxchat-color-picker', ...) would have printed
9663 // three secrets into page HTML for Subscribers.
9664 //
9665 // Nothing consumed the object: a tree-wide grep for
9666 // 'mxchatStyleSettings' returns only this call site, and js/ names
9667 // loops_api_key only at mxchat-admin.js:572, as a field-NAME string
9668 // in an autosave allowlist — not a value read. Removed entirely
9669 // rather than deleting just the three secret keys, so there is no
9670 // dead payload left for someone to re-arm.
9671 break;
9672 }
9673
9674 // Additional localization that was in the original code
9675 wp_localize_script('mxchat-admin-js', 'mxchatPromptsAdmin', array(
9676 'ajax_url' => admin_url('admin-ajax.php'),
9677 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9678 ));
9679 }
9680
9681 public function mxchat_sanitize($input) {
9682 $new_input = array();
9683
9684 if (isset($input['api_key'])) {
9685 $new_input['api_key'] = sanitize_text_field($input['api_key']);
9686 }
9687
9688 if (isset($input['similarity_threshold'])) {
9689 $new_input['similarity_threshold'] = absint($input['similarity_threshold']); // Ensure it's an integer
9690 $new_input['similarity_threshold'] = min(max($new_input['similarity_threshold'], 20), 85); // Enforce range
9691 }
9692
9693 if (isset($input['rag_sources_limit'])) {
9694 $new_input['rag_sources_limit'] = absint($input['rag_sources_limit']); // Ensure it's an integer
9695 $new_input['rag_sources_limit'] = min(max($new_input['rag_sources_limit'], 3), 10); // Enforce range 3-10
9696 }
9697
9698 if (isset($input['rag_chunks_limit'])) {
9699 $new_input['rag_chunks_limit'] = absint($input['rag_chunks_limit']); // Ensure it's an integer
9700 $new_input['rag_chunks_limit'] = min(max($new_input['rag_chunks_limit'], 8), 20); // Enforce range 8-20
9701 }
9702
9703 if (isset($input['xai_api_key'])) {
9704 $new_input['xai_api_key'] = sanitize_text_field($input['xai_api_key']);
9705 }
9706
9707 if (isset($input['claude_api_key'])) {
9708 $new_input['claude_api_key'] = sanitize_text_field($input['claude_api_key']);
9709 }
9710
9711 if (isset($input['enable_streaming_toggle'])) {
9712 $new_input['enable_streaming_toggle'] = ($input['enable_streaming_toggle'] === 'on') ? 'on' : 'off';
9713 } else {
9714 // If checkbox not checked, it won't be in $input, so set to 'off'
9715 $new_input['enable_streaming_toggle'] = 'off';
9716 }
9717
9718 if (isset($input['enable_web_search'])) {
9719 $new_input['enable_web_search'] = ($input['enable_web_search'] === 'on') ? 'on' : 'off';
9720 } else {
9721 // If checkbox not checked, it won't be in $input, so set to 'off'
9722 $new_input['enable_web_search'] = 'off';
9723 }
9724
9725 if (isset($input['deepseek_api_key'])) {
9726 $new_input['deepseek_api_key'] = sanitize_text_field($input['deepseek_api_key']);
9727 }
9728
9729 if (isset($input['gemini_api_key'])) {
9730 $new_input['gemini_api_key'] = sanitize_text_field($input['gemini_api_key']);
9731 }
9732
9733 if (isset($input['enable_woocommerce_integration'])) {
9734 $new_input['enable_woocommerce_integration'] = $input['enable_woocommerce_integration'] === 'on' ? 'on' : 'off';
9735 }
9736
9737 if (isset($input['privacy_toggle'])) {
9738 $new_input['privacy_toggle'] = $input['privacy_toggle'];
9739 }
9740
9741 if (isset($input['complianz_toggle'])) {
9742 $new_input['complianz_toggle'] = $input['complianz_toggle'];
9743 }
9744
9745 // Handle custom privacy text input
9746 if (isset($input['privacy_text'])) {
9747 // Allow basic HTML for links
9748 $new_input['privacy_text'] = wp_kses_post($input['privacy_text']);
9749 }
9750
9751 if (isset($input['system_prompt_instructions'])) {
9752 $new_input['system_prompt_instructions'] = sanitize_textarea_field($input['system_prompt_instructions']);
9753 }
9754
9755 if (isset($input['mxchat_pro_email'])) {
9756 $new_input['mxchat_pro_email'] = sanitize_email($input['mxchat_pro_email']);
9757 }
9758
9759 if (isset($input['mxchat_activation_key'])) {
9760 $new_input['mxchat_activation_key'] = sanitize_text_field($input['mxchat_activation_key']);
9761 }
9762
9763 if (isset($input['append_to_body'])) {
9764 $new_input['append_to_body'] = $input['append_to_body'] === 'on' ? 'on' : 'off';
9765 }
9766
9767 // Post type visibility settings
9768 if (isset($input['post_type_visibility_mode'])) {
9769 $allowed_modes = array('all', 'include', 'exclude');
9770 $new_input['post_type_visibility_mode'] = in_array($input['post_type_visibility_mode'], $allowed_modes)
9771 ? $input['post_type_visibility_mode']
9772 : 'all';
9773 }
9774
9775 if (isset($input['post_type_visibility_list'])) {
9776 if (is_array($input['post_type_visibility_list'])) {
9777 $new_input['post_type_visibility_list'] = array_map('sanitize_key', $input['post_type_visibility_list']);
9778 } else {
9779 $new_input['post_type_visibility_list'] = array();
9780 }
9781 }
9782
9783 if (isset($input['contextual_awareness_toggle'])) {
9784 $new_input['contextual_awareness_toggle'] = $input['contextual_awareness_toggle'] === 'on' ? 'on' : 'off';
9785 }
9786
9787 if (isset($input['citation_links_toggle'])) {
9788 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
9789 }
9790
9791 // Satisfaction rating prompt — defaults ON when unchecked (so first save
9792 // doesn't accidentally disable it). The form posts 'on' when checked;
9793 // unchecked checkboxes don't post a value at all, so we infer 'off' only
9794 // when the autosave/PHP request explicitly clears it via empty string.
9795 if (array_key_exists('satisfaction_rating_enabled', $input)) {
9796 $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
9797 }
9798
9799 // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
9800 // the 4 text strings are sanitized + capped server-side. Blank values are
9801 // preserved so the integrator falls back to translated defaults.
9802 if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
9803 $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
9804 }
9805
9806 // Max input length in characters (plan a3fae2 part C). 0 = unlimited (default,
9807 // preserves current behavior). Clamp to a sane ceiling so a typo can't lock out
9808 // all input. REQUIRED here — mxchat_sanitize() rebuilds the array from a whitelist,
9809 // so without this isset-handler the key is stripped on the next save of ANY field
9810 // (the 2c02ea global-rate-limit footgun).
9811 if (array_key_exists('max_input_length', $input)) {
9812 $new_input['max_input_length'] = max(0, min(100000, intval($input['max_input_length'])));
9813 }
9814 if (array_key_exists('satisfaction_rating_question', $input)) {
9815 $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
9816 }
9817 if (array_key_exists('satisfaction_rating_thanks', $input)) {
9818 $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
9819 }
9820 if (array_key_exists('satisfaction_rating_placeholder', $input)) {
9821 $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
9822 }
9823 if (array_key_exists('satisfaction_rating_saved', $input)) {
9824 $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
9825 }
9826
9827 if (isset($input['top_bar_title'])) {
9828 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
9829 }
9830
9831 if (isset($input['ai_agent_text'])) {
9832 $new_input['ai_agent_text'] = sanitize_text_field($input['ai_agent_text']);
9833 }
9834
9835 if (isset($input['enable_email_block'])) {
9836 $new_input['enable_email_block'] = sanitize_text_field($input['enable_email_block']);
9837 }
9838
9839 if (isset($input['email_blocker_header_content'])) {
9840 // wp_kses_post() allows standard HTML tags permitted by WordPress
9841 $new_input['email_blocker_header_content'] = wp_kses_post($input['email_blocker_header_content']);
9842 }
9843 if (isset($input['email_blocker_button_text'])) {
9844 $new_input['email_blocker_button_text'] = sanitize_text_field($input['email_blocker_button_text']);
9845 }
9846 // Sanitize name field toggle
9847 if (isset($input['enable_name_field'])) {
9848 $new_input['enable_name_field'] = ($input['enable_name_field'] === 'on') ? 'on' : 'off';
9849 } else {
9850 $new_input['enable_name_field'] = 'off';
9851 }
9852 // Sanitize name field placeholder
9853 if (isset($input['name_field_placeholder'])) {
9854 $new_input['name_field_placeholder'] = sanitize_text_field($input['name_field_placeholder']);
9855 }
9856 if (isset($input['intro_message'])) {
9857 $new_input['intro_message'] = wp_kses_post($input['intro_message']); // Use wp_kses_post instead
9858 }
9859
9860 if (isset($input['input_copy'])) {
9861 $new_input['input_copy'] = sanitize_text_field($input['input_copy']);
9862 }
9863
9864 if (isset($input['rate_limit_message'])) {
9865 $new_input['rate_limit_message'] = sanitize_text_field($input['rate_limit_message']);
9866 }
9867
9868 // Handle the new rate limits format
9869 if (isset($input['rate_limits']) && is_array($input['rate_limits'])) {
9870 $new_input['rate_limits'] = array();
9871 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
9872 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
9873
9874 foreach ($input['rate_limits'] as $role_id => $settings) {
9875 $new_input['rate_limits'][$role_id] = array();
9876
9877 // Sanitize limit
9878 if (isset($settings['limit'])) {
9879 $limit = sanitize_text_field($settings['limit']);
9880 // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
9881 // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
9882 // the custom path the per-role custom input was dropped and reset to the role
9883 // default on every save (plan-7e23e7).
9884 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
9885 $new_input['rate_limits'][$role_id]['limit'] = $limit;
9886 } else {
9887 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
9888 }
9889 }
9890
9891 // Preserve the per-role custom value (mirrors the global branch's limit_custom).
9892 if (isset($settings['limit_custom'])) {
9893 $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
9894 }
9895
9896 // Sanitize timeframe
9897 if (isset($settings['timeframe'])) {
9898 $timeframe = sanitize_text_field($settings['timeframe']);
9899 if (in_array($timeframe, $allowed_timeframes, true)) {
9900 $new_input['rate_limits'][$role_id]['timeframe'] = $timeframe;
9901 } else {
9902 $new_input['rate_limits'][$role_id]['timeframe'] = 'daily'; // Default
9903 }
9904 }
9905
9906 // Sanitize message
9907 if (isset($settings['message'])) {
9908 $new_input['rate_limits'][$role_id]['message'] = sanitize_textarea_field($settings['message']);
9909 }
9910 }
9911 }
9912
9913 // Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
9914 // Mirrors the per-role block above, adapted to the single global shape. Without
9915 // this branch the whitelist-rebuild dropped rate_limits_global entirely on every
9916 // save, so the global cap silently fell back to its 'unlimited' default.
9917 // MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
9918 if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
9919 $g = $input['rate_limits_global'];
9920 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
9921 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
9922 $global_out = array();
9923
9924 if (isset($g['limit'])) {
9925 $limit = sanitize_text_field($g['limit']);
9926 // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
9927 // autosave handler / renderer), OR any positive integer (custom value).
9928 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
9929 $global_out['limit'] = $limit;
9930 } else {
9931 $global_out['limit'] = 'unlimited';
9932 }
9933 }
9934
9935 if (isset($g['limit_custom'])) {
9936 $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
9937 }
9938
9939 if (isset($g['timeframe'])) {
9940 $timeframe = sanitize_text_field($g['timeframe']);
9941 $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
9942 }
9943
9944 if (isset($g['message'])) {
9945 $global_out['message'] = sanitize_textarea_field($g['message']);
9946 }
9947
9948 $new_input['rate_limits_global'] = $global_out;
9949 }
9950
9951 if (isset($input['pre_chat_message'])) {
9952 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
9953 }
9954
9955 if (isset($input['voyage_api_key'])) {
9956 $new_input['voyage_api_key'] = sanitize_text_field($input['voyage_api_key']);
9957 }
9958
9959 // Add to your sanitize function
9960 if (isset($input['embedding_model'])) {
9961 // Catalog refactor (plan-d14e89): allowlist derived from the canonical
9962 // catalog in includes/class-mxchat-model-catalog.php.
9963 if (!class_exists('MxChat_Model_Catalog')) {
9964 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9965 }
9966 $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
9967 if (in_array($input['embedding_model'], $allowed_models)) {
9968 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
9969 }
9970 }
9971
9972 if (isset($input['model'])) {
9973 if ($input['model'] === 'openrouter') {
9974 $new_input['model'] = 'openrouter';
9975 } else {
9976 if (!class_exists('MxChat_Model_Catalog')) {
9977 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9978 }
9979 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
9980
9981 if (in_array($input['model'], $allowed_models)) {
9982 $new_input['model'] = sanitize_text_field($input['model']);
9983 } else {
9984 // Fallback for any deprecated model
9985 $new_input['model'] = 'gpt-5.6-sol';
9986 }
9987 }
9988 }
9989
9990 if (isset($input['openrouter_selected_model'])) {
9991 // Just sanitize it, don't validate against a whitelist
9992 $new_input['openrouter_selected_model'] = sanitize_text_field($input['openrouter_selected_model']);
9993 }
9994
9995 // ADD THIS:
9996 if (isset($input['openrouter_selected_model_name'])) {
9997 $new_input['openrouter_selected_model_name'] = sanitize_text_field($input['openrouter_selected_model_name']);
9998 }
9999
10000 if (isset($input['openrouter_api_key'])) {
10001 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
10002 }
10003
10004 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
10005 if (isset($input['custom_provider_base_url'])) {
10006 $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
10007 }
10008 if (isset($input['custom_provider_api_key'])) {
10009 $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
10010 }
10011 if (isset($input['custom_provider_model'])) {
10012 $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
10013 }
10014 if (isset($input['custom_provider_auth_scheme'])) {
10015 $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
10016 $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
10017 }
10018 if (isset($input['custom_provider_for_embeddings'])) {
10019 $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
10020 }
10021 if (isset($input['custom_provider_for_images'])) {
10022 $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
10023 }
10024 if (isset($input['custom_provider_embedding_model'])) {
10025 $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
10026 }
10027 if (isset($input['custom_provider_api_version'])) {
10028 $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
10029 }
10030
10031
10032
10033 if (isset($input['woocommerce_consumer_key'])) {
10034 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
10035 }
10036
10037 if (isset($input['woocommerce_consumer_secret'])) {
10038 $new_input['woocommerce_consumer_secret'] = sanitize_text_field($input['woocommerce_consumer_secret']);
10039 }
10040
10041
10042 // Sanitize link_target_toggle
10043 if (isset($input['link_target_toggle'])) {
10044 $new_input['link_target_toggle'] = $input['link_target_toggle'] === 'on' ? 'on' : 'off';
10045 }
10046
10047 // Sanitize Loops API Key
10048 if (isset($input['loops_api_key'])) {
10049 $new_input['loops_api_key'] = sanitize_text_field($input['loops_api_key']);
10050 }
10051
10052 if (isset($input['chat_persistence_toggle'])) {
10053 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
10054 }
10055
10056 // No else clause: an absent key stays absent, so the front-end default ('on') applies
10057 // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
10058 if (isset($input['print_button_enabled'])) {
10059 $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
10060 }
10061
10062 // plan ac2e81 — "Start new chat" toggle (default OFF) + editable label.
10063 // No else on the toggle: an absent key stays absent so the front-end '?? off'
10064 // default applies, and the full-array autosave rebuild preserves a saved value.
10065 if (isset($input['reset_chat_enabled'])) {
10066 $new_input['reset_chat_enabled'] = $input['reset_chat_enabled'] === 'on' ? 'on' : 'off';
10067 }
10068
10069 if (isset($input['reset_chat_label'])) {
10070 $new_input['reset_chat_label'] = sanitize_text_field($input['reset_chat_label']);
10071 }
10072
10073 if (isset($input['popular_question_1'])) {
10074 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
10075 }
10076
10077 if (isset($input['popular_question_2'])) {
10078 $new_input['popular_question_2'] = sanitize_text_field($input['popular_question_2']);
10079 }
10080
10081 if (isset($input['popular_question_3'])) {
10082 $new_input['popular_question_3'] = sanitize_text_field($input['popular_question_3']);
10083 }
10084
10085 if (isset($input['additional_popular_questions']) && is_array($input['additional_popular_questions'])) {
10086 $new_input['additional_popular_questions'] = array_map('sanitize_text_field', $input['additional_popular_questions']);
10087 }
10088
10089 // Sanitize Loops Mailing List
10090 if (isset($input['loops_mailing_list'])) {
10091 $new_input['loops_mailing_list'] = sanitize_text_field($input['loops_mailing_list']);
10092 }
10093
10094 // Sanitize Triggered Phrase Response
10095 if (isset($input['triggered_phrase_response'])) {
10096 $new_input['triggered_phrase_response'] = wp_kses_post($input['triggered_phrase_response']);
10097 }
10098 if (isset($input['email_capture_response'])) {
10099 $new_input['email_capture_response'] = wp_kses_post($input['email_capture_response']);
10100 }
10101
10102 // Sanitize Brave Search Settings
10103 if (isset($input['brave_api_key'])) {
10104 $new_input['brave_api_key'] = sanitize_text_field($input['brave_api_key']);
10105 }
10106
10107 if (isset($input['brave_image_count'])) {
10108 $image_count = intval($input['brave_image_count']);
10109 $new_input['brave_image_count'] = ($image_count >=1 && $image_count <=6) ? $image_count : 4;
10110 }
10111
10112 if (isset($input['brave_safe_search'])) {
10113 $allowed = array('strict', 'off');
10114 $new_input['brave_safe_search'] = in_array($input['brave_safe_search'], $allowed, true) ? $input['brave_safe_search'] : 'strict';
10115 }
10116
10117 if (isset($input['brave_news_count'])) {
10118 $news_count = intval($input['brave_news_count']);
10119 $new_input['brave_news_count'] = ($news_count >=1 && $news_count <=10) ? $news_count : 3;
10120 }
10121
10122 if (isset($input['brave_country'])) {
10123 $new_input['brave_country'] = sanitize_text_field($input['brave_country']);
10124 }
10125
10126 if (isset($input['brave_language'])) {
10127 $new_input['brave_language'] = sanitize_text_field($input['brave_language']);
10128 }
10129
10130 if (isset($input['chat_toolbar_toggle'])) {
10131 $new_input['chat_toolbar_toggle'] = $input['chat_toolbar_toggle'] === 'on' ? 'on' : 'off';
10132 }
10133
10134 // Sanitize PDF upload button toggle
10135 if (isset($input['show_pdf_upload_button'])) {
10136 $new_input['show_pdf_upload_button'] = $input['show_pdf_upload_button'] === 'on' ? 'on' : 'off';
10137 } else {
10138 $new_input['show_pdf_upload_button'] = 'off'; // If checkbox is unchecked
10139 }
10140
10141 // Sanitize Word upload button toggle
10142 if (isset($input['show_word_upload_button'])) {
10143 $new_input['show_word_upload_button'] = $input['show_word_upload_button'] === 'on' ? 'on' : 'off';
10144 } else {
10145 $new_input['show_word_upload_button'] = 'off'; // If checkbox is unchecked
10146 }
10147
10148 if (isset($input['pdf_intent_trigger_text'])) {
10149 $new_input['pdf_intent_trigger_text'] = sanitize_text_field($input['pdf_intent_trigger_text']);
10150 }
10151
10152 if (isset($input['pdf_intent_success_text'])) {
10153 $new_input['pdf_intent_success_text'] = sanitize_text_field($input['pdf_intent_success_text']);
10154 }
10155
10156 if (isset($input['pdf_intent_error_text'])) {
10157 $new_input['pdf_intent_error_text'] = sanitize_text_field($input['pdf_intent_error_text']);
10158 }
10159
10160 if (isset($input['pdf_max_pages'])) {
10161 $new_input['pdf_max_pages'] = intval($input['pdf_max_pages']);
10162 if ($new_input['pdf_max_pages'] < 1 || $new_input['pdf_max_pages'] > 69) {
10163 $new_input['pdf_max_pages'] = 69; // Default to 69 if out of range
10164 }
10165 }
10166
10167 if (isset($input['live_agent_webhook_url'])) {
10168 $new_input['live_agent_webhook_url'] = esc_url_raw($input['live_agent_webhook_url']);
10169 }
10170 if (isset($input['live_agent_secret_key'])) {
10171 $new_input['live_agent_secret_key'] = sanitize_text_field($input['live_agent_secret_key']);
10172 }
10173
10174 // Live Agent Integration
10175 if (isset($input['live_agent_bot_token'])) {
10176 $new_input['live_agent_bot_token'] = sanitize_text_field($input['live_agent_bot_token']);
10177 }
10178
10179 if (isset($input['live_agent_shared_channel'])) {
10180 $new_input['live_agent_shared_channel'] = sanitize_text_field($input['live_agent_shared_channel']);
10181 }
10182
10183 // Default-OFF toggle: absent key stays absent (unchecked box = off).
10184 if (isset($input['live_agent_archive_on_end_toggle'])) {
10185 $new_input['live_agent_archive_on_end_toggle'] = ($input['live_agent_archive_on_end_toggle'] === 'on') ? 'on' : 'off';
10186 }
10187
10188 if (isset($input['live_agent_user_ids'])) {
10189 $new_input['live_agent_user_ids'] = sanitize_textarea_field($input['live_agent_user_ids']);
10190 }
10191
10192 if (isset($input['live_agent_status'])) {
10193 $new_input['live_agent_status'] = ($input['live_agent_status'] === 'on') ? 'on' : 'off';
10194 }
10195 if (isset($input['live_agent_away_message'])) {
10196 $new_input['live_agent_away_message'] = sanitize_textarea_field($input['live_agent_away_message']);
10197 }
10198 if (isset($input['live_agent_notification_message'])) {
10199 $new_input['live_agent_notification_message'] = sanitize_textarea_field($input['live_agent_notification_message']);
10200 }
10201
10202 // Telegram Integration
10203 if (isset($input['telegram_status'])) {
10204 $new_input['telegram_status'] = ($input['telegram_status'] === 'on') ? 'on' : 'off';
10205 }
10206 if (isset($input['telegram_bot_token'])) {
10207 $new_input['telegram_bot_token'] = sanitize_text_field($input['telegram_bot_token']);
10208 }
10209 if (isset($input['telegram_group_id'])) {
10210 $new_input['telegram_group_id'] = sanitize_text_field($input['telegram_group_id']);
10211 }
10212 if (isset($input['telegram_webhook_secret'])) {
10213 $new_input['telegram_webhook_secret'] = sanitize_text_field($input['telegram_webhook_secret']);
10214 }
10215 if (isset($input['telegram_notification_message'])) {
10216 $new_input['telegram_notification_message'] = sanitize_textarea_field($input['telegram_notification_message']);
10217 }
10218 if (isset($input['telegram_away_message'])) {
10219 $new_input['telegram_away_message'] = sanitize_textarea_field($input['telegram_away_message']);
10220 }
10221
10222 // Sanitize script loading strategy
10223 if (isset($input['script_loading_strategy'])) {
10224 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
10225 $new_input['script_loading_strategy'] = in_array($input['script_loading_strategy'], $allowed_strategies)
10226 ? $input['script_loading_strategy']
10227 : 'default';
10228 }
10229
10230 // Sanitize debug mode
10231 if (isset($input['debug_mode'])) {
10232 $new_input['debug_mode'] = ($input['debug_mode'] === 'on') ? 'on' : 'off';
10233 }
10234
10235 // Content Generator Settings
10236 if (isset($input['content_model'])) {
10237 $new_input['content_model'] = sanitize_text_field($input['content_model']);
10238 }
10239 if (isset($input['content_image_model'])) {
10240 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
10241 }
10242 if (isset($input['content_image_quality'])) {
10243 $q = sanitize_text_field($input['content_image_quality']);
10244 $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
10245 }
10246 if (isset($input['content_enable_images'])) {
10247 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
10248 }
10249 if (isset($input['content_use_placeholders'])) {
10250 $new_input['content_use_placeholders'] = ($input['content_use_placeholders'] === 'on') ? 'on' : 'off';
10251 }
10252 if (isset($input['content_internal_linking'])) {
10253 $new_input['content_internal_linking'] = ($input['content_internal_linking'] === 'on') ? 'on' : 'off';
10254 }
10255 if (isset($input['content_tool_use'])) {
10256 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
10257 }
10258 if (isset($input['content_image_count'])) {
10259 $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
10260 }
10261
10262 // SEO Optimize toggle fields
10263 foreach (array('seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img') as $seo_key) {
10264 if (isset($input[$seo_key])) {
10265 $new_input[$seo_key] = ($input[$seo_key] === 'on') ? 'on' : 'off';
10266 }
10267 }
10268
10269 // Preserve content generator settings when saving from main settings page
10270 // (where content fields are not in the form submission)
10271 $existing = get_option('mxchat_options', array());
10272 foreach (array('content_model', 'content_image_model', 'content_image_quality', 'content_image_count', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img') as $key) {
10273 if (!isset($new_input[$key]) && isset($existing[$key])) {
10274 $new_input[$key] = $existing[$key];
10275 }
10276 }
10277
10278 return $new_input;
10279 }
10280
10281 /**
10282 * Log a debug message if debug mode is enabled
10283 *
10284 * @param string $type The type of log entry (settings_save, api_error, activation, etc.)
10285 * @param string $message The log message
10286 * @param array $data Optional additional data to log
10287 * @return bool Whether the message was logged
10288 */
10289 public static function mxchat_log_debug( $type, $message, $data = array() ) {
10290 $options = get_option( 'mxchat_options', array() );
10291
10292 // Check if debug mode is enabled
10293 if ( ! isset( $options['debug_mode'] ) || $options['debug_mode'] !== 'on' ) {
10294 return false;
10295 }
10296
10297 // Get current log
10298 $log = get_option( 'mxchat_debug_log', array() );
10299 if ( ! is_array( $log ) ) {
10300 $log = array();
10301 }
10302
10303 // Add new entry
10304 $entry = array(
10305 'time' => current_time( 'Y-m-d H:i:s' ),
10306 'type' => sanitize_key( $type ),
10307 'message' => sanitize_text_field( $message ),
10308 );
10309
10310 if ( ! empty( $data ) ) {
10311 $entry['data'] = $data;
10312 }
10313
10314 // Add to beginning of array (newest first)
10315 array_unshift( $log, $entry );
10316
10317 // Keep only last 100 entries
10318 if ( count( $log ) > 100 ) {
10319 $log = array_slice( $log, 0, 100 );
10320 }
10321
10322 // Save log
10323 update_option( 'mxchat_debug_log', $log, false );
10324
10325 return true;
10326 }
10327
10328 /**
10329 * Get the debug log entries
10330 *
10331 * @return array The debug log entries
10332 */
10333 public static function mxchat_get_debug_log() {
10334 $log = get_option( 'mxchat_debug_log', array() );
10335 return is_array( $log ) ? $log : array();
10336 }
10337
10338 /**
10339 * Clear the debug log
10340 *
10341 * @return bool Whether the log was cleared
10342 */
10343 public static function mxchat_clear_debug_log() {
10344 return delete_option( 'mxchat_debug_log' );
10345 }
10346
10347 /**
10348 * Export settings as JSON with masked API keys
10349 *
10350 * @return array The sanitized settings array
10351 */
10352 public static function mxchat_export_settings() {
10353 $options = get_option( 'mxchat_options', array() );
10354
10355 if ( ! is_array( $options ) ) {
10356 return array();
10357 }
10358
10359 // List of API key fields to mask
10360 $api_key_fields = array(
10361 'api_key',
10362 'xai_api_key',
10363 'claude_api_key',
10364 'deepseek_api_key',
10365 'voyage_api_key',
10366 'gemini_api_key',
10367 'openrouter_api_key',
10368 'loops_api_key',
10369 'brave_api_key',
10370 'live_agent_secret_key',
10371 'live_agent_bot_token',
10372 'telegram_bot_token',
10373 'telegram_webhook_secret',
10374 'woocommerce_consumer_key',
10375 'woocommerce_consumer_secret',
10376 );
10377
10378 // Mask API keys (show only last 4 characters)
10379 foreach ( $api_key_fields as $field ) {
10380 if ( isset( $options[ $field ] ) && ! empty( $options[ $field ] ) ) {
10381 $value = $options[ $field ];
10382 if ( strlen( $value ) > 4 ) {
10383 $options[ $field ] = str_repeat( '*', strlen( $value ) - 4 ) . substr( $value, -4 );
10384 } else {
10385 $options[ $field ] = '****';
10386 }
10387 }
10388 }
10389
10390 // Add metadata
10391 $export = array(
10392 'plugin_version' => defined( 'MXCHAT_VERSION' ) ? MXCHAT_VERSION : 'unknown',
10393 'export_date' => current_time( 'Y-m-d H:i:s' ),
10394 'wordpress_version' => get_bloginfo( 'version' ),
10395 'php_version' => phpversion(),
10396 'settings' => $options,
10397 );
10398
10399 return $export;
10400 }
10401
10402 /**
10403 * Reset all settings to defaults
10404 *
10405 * @return bool Whether the reset was successful
10406 */
10407 public static function mxchat_reset_all_settings() {
10408 // Delete main options
10409 $deleted = delete_option( 'mxchat_options' );
10410
10411 // Also clear the debug log
10412 delete_option( 'mxchat_debug_log' );
10413
10414 // Log the reset (will create new log since we just cleared it)
10415 // We need to temporarily enable debug mode to log this
10416 $temp_options = array( 'debug_mode' => 'on' );
10417 update_option( 'mxchat_options', $temp_options );
10418
10419 self::mxchat_log_debug( 'reset', 'All settings have been reset to defaults' );
10420
10421 // Now delete again to trigger re-initialization
10422 delete_option( 'mxchat_options' );
10423
10424 return $deleted;
10425 }
10426
10427 // Method to append the chatbot to the body
10428 public function mxchat_append_chatbot_to_body() {
10429 $options = get_option('mxchat_options');
10430 if (isset($options['append_to_body']) && $options['append_to_body'] === 'on') {
10431 echo do_shortcode('[mxchat_chatbot floating="yes"]');
10432 }
10433 }
10434
10435
10436
10437
10438
10439 private function mxchat_fetch_loops_mailing_lists($api_key) {
10440 $url = 'https://app.loops.so/api/v1/lists';
10441 $response = wp_remote_get($url, array(
10442 'headers' => array(
10443 'Authorization' => 'Bearer ' . $api_key,
10444 'Content-Type' => 'application/json'
10445 )
10446 ));
10447
10448 if (is_wp_error($response)) {
10449 return array();
10450 }
10451
10452 $body = wp_remote_retrieve_body($response);
10453 $lists = json_decode($body, true);
10454
10455 return isset($lists) && is_array($lists) ? $lists : array();
10456 }
10457
10458 function mxchat_calculate_cosine_similarity($vec1, $vec2) {
10459 if (empty($vec1) || empty($vec2)) {
10460 return 0.0;
10461 }
10462
10463 $dot_product = 0.0;
10464 $norm_a = 0.0;
10465 $norm_b = 0.0;
10466
10467 for ($i = 0; $i < count($vec1); $i++) {
10468 $dot_product += $vec1[$i] * $vec2[$i];
10469 $norm_a += pow($vec1[$i], 2);
10470 $norm_b += pow($vec2[$i], 2);
10471 }
10472
10473 if ($norm_a == 0.0 || $norm_b == 0.0) {
10474 return 0.0;
10475 } else {
10476 return $dot_product / (sqrt($norm_a) * sqrt($norm_b));
10477 }
10478 }
10479
10480 /**
10481 * Validates nonce and deletes all chat prompts
10482 */
10483 public function mxchat_handle_delete_all_prompts() {
10484 //error_log('=== DELETE ALL DEBUG START ===');
10485
10486 // Verify nonce
10487 if (!isset($_POST['mxchat_delete_all_prompts_nonce']) || !wp_verify_nonce($_POST['mxchat_delete_all_prompts_nonce'], 'mxchat_delete_all_prompts_action')) {
10488 //error_log('DEBUG: Nonce verification failed');
10489 wp_die(__('Nonce verification failed.', 'mxchat'));
10490 }
10491
10492 // Check permissions
10493 if (!current_user_can('manage_options')) {
10494 //error_log('DEBUG: Permission check failed');
10495 wp_die(__('You do not have sufficient permissions to delete all prompts.', 'mxchat'));
10496 }
10497
10498 // Get bot_id and content type filter from POST data
10499 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
10500 $content_type_filter = isset($_POST['content_type_filter']) ? sanitize_text_field($_POST['content_type_filter']) : '';
10501
10502 $success = true;
10503 $error_messages = array();
10504
10505 // Get bot-specific Pinecone configuration
10506 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
10507 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
10508
10509 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10510
10511 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10512 // Delete from Pinecone (with optional content type filter)
10513 $result = $pinecone_manager->mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter);
10514
10515 if (!$result['success']) {
10516 $success = false;
10517 $error_messages[] = $result['message'];
10518 }
10519
10520 } else {
10521 // Delete from WordPress database
10522 global $wpdb;
10523 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10524
10525 // Build WHERE conditions
10526 $where_clauses = array();
10527 $where_values = array();
10528
10529 // Bot filter
10530 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
10531 $where_clauses[] = 'bot_id = %s';
10532 $where_values[] = $bot_id;
10533 }
10534
10535 // Content type filter
10536 if (!empty($content_type_filter)) {
10537 $where_clauses[] = 'content_type = %s';
10538 $where_values[] = $content_type_filter;
10539 }
10540
10541 if (!empty($where_clauses)) {
10542 $where_sql = implode(' AND ', $where_clauses);
10543 $result = $wpdb->query($wpdb->prepare("DELETE FROM {$table_name} WHERE {$where_sql}", $where_values));
10544 } else {
10545 // No filters — delete all
10546 $result = $wpdb->query("DELETE FROM {$table_name}");
10547 }
10548
10549 if ($result === false) {
10550 $success = false;
10551 $error_messages[] = 'Failed to delete from WordPress database';
10552 }
10553 }
10554
10555 // Redirect back with a success message and bot_id
10556 $redirect_url = add_query_arg(array(
10557 'page' => 'mxchat-prompts',
10558 'bot_id' => $bot_id,
10559 'all_deleted' => $success ? 'true' : 'false'
10560 ), admin_url('admin.php'));
10561
10562 wp_safe_redirect($redirect_url);
10563 exit;
10564 }
10565
10566
10567
10568 /**
10569 * Handles deletion prompt with nonce validation
10570 */
10571 public function mxchat_handle_delete_prompt() {
10572 // Sanitize and validate nonce
10573 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
10574 if (empty($nonce) || !wp_verify_nonce($nonce, 'mxchat_delete_prompt_nonce')) {
10575 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
10576 }
10577
10578 // Check permissions
10579 if (!current_user_can('manage_options')) {
10580 wp_die(esc_html__('You do not have sufficient permissions to delete prompts.', 'mxchat'));
10581 }
10582
10583 // Get ID and source parameters
10584 $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : '';
10585 $source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : '';
10586
10587 if (empty($id)) {
10588 wp_die(esc_html__('Invalid prompt ID.', 'mxchat'));
10589 }
10590
10591 $success = false;
10592 $error_message = '';
10593
10594 // Check if Pinecone is enabled and determine source automatically if not specified
10595 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10596 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10597
10598 // If source is not specified, determine based on Pinecone configuration
10599 if (empty($source)) {
10600 $source = $use_pinecone ? 'pinecone' : 'wordpress';
10601 }
10602
10603 if ($source === 'pinecone' || $use_pinecone) {
10604 //error_log('[MXCHAT-DELETE] Deleting from Pinecone, ID: ' . $id);
10605
10606 // Handle Pinecone deletion
10607 if (empty($pinecone_options['mxchat_pinecone_host']) ||
10608 empty($pinecone_options['mxchat_pinecone_api_key'])) {
10609 wp_die(esc_html__('Pinecone configuration is missing.', 'mxchat'));
10610 }
10611
10612 // Delete from Pinecone using the vector ID directly
10613 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
10614 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
10615 $id,
10616 $pinecone_options['mxchat_pinecone_api_key'],
10617 $pinecone_options['mxchat_pinecone_host']
10618 );
10619 if ($result['success']) {
10620 $success = true;
10621
10622 // Remove from vector cache
10623 $pinecone_manager->mxchat_remove_from_pinecone_vector_cache($id);
10624 $pinecone_manager->mxchat_remove_from_processed_content_caches($id);
10625
10626 set_transient('mxchat_admin_notice_success',
10627 esc_html__('Vector deleted successfully from Pinecone.', 'mxchat'), 30);
10628 } else {
10629 $error_message = $result['message'];
10630 set_transient('mxchat_admin_notice_error',
10631 esc_html__('Failed to delete from Pinecone: ', 'mxchat') . esc_html($error_message), 30);
10632 }
10633 } else {
10634 //error_log('[MXCHAT-DELETE] Deleting from WordPress database, ID: ' . $id);
10635
10636 // Handle WordPress database deletion
10637 global $wpdb;
10638 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10639
10640 // Clear cache and delete prompt
10641 wp_cache_delete('prompt_' . $id, 'mxchat_prompts');
10642
10643 $result = $wpdb->delete(
10644 $table_name,
10645 array('id' => intval($id)),
10646 array('%d')
10647 );
10648
10649 if ($result !== false) {
10650 $success = true;
10651 set_transient('mxchat_admin_notice_success',
10652 esc_html__('Entry deleted successfully.', 'mxchat'), 30);
10653 } else {
10654 set_transient('mxchat_admin_notice_error',
10655 esc_html__('Failed to delete entry from database.', 'mxchat'), 30);
10656 }
10657 }
10658
10659 // Redirect back to the prompts page
10660 wp_safe_redirect(add_query_arg(
10661 array(
10662 'page' => 'mxchat-prompts',
10663 'deleted' => $success ? 'true' : 'false'
10664 ),
10665 admin_url('admin.php')
10666 ));
10667 exit;
10668 }
10669
10670
10671
10672
10673 /**
10674 * Averages multiple vectors into a single vector
10675 */
10676 private function mxchat_average_vectors($vectors) {
10677 $vector_length = count($vectors[0]);
10678 $sum_vector = array_fill(0, $vector_length, 0);
10679
10680 foreach ($vectors as $vector) {
10681 for ($i = 0; $i < $vector_length; $i++) {
10682 $sum_vector[$i] += $vector[$i];
10683 }
10684 }
10685
10686 // Divide each component by the number of vectors to get the average
10687 $num_vectors = count($vectors);
10688 for ($i = 0; $i < $vector_length; $i++) {
10689 $sum_vector[$i] /= $num_vectors;
10690 }
10691
10692 return $sum_vector;
10693 }
10694
10695
10696
10697
10698 /**
10699 * Generates embeddings from input text for MXChat
10700 */
10701 public function mxchat_generate_embedding($text) {
10702 // Enable detailed logging for debugging
10703 //error_log('[MXCHAT-EMBED] Starting embedding generation. Text length: ' . strlen($text) . ' bytes');
10704 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
10705
10706 $options = get_option('mxchat_options');
10707 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
10708 //error_log('[MXCHAT-EMBED] Selected embedding model: ' . $selected_model);
10709
10710 // Determine provider and endpoint
10711 if (strpos($selected_model, 'voyage') === 0) {
10712 $api_key = $options['voyage_api_key'] ?? '';
10713 $endpoint = 'https://api.voyageai.com/v1/embeddings';
10714 $provider_name = 'Voyage AI';
10715 //error_log('[MXCHAT-EMBED] Using Voyage AI API');
10716 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
10717 $api_key = $options['gemini_api_key'] ?? '';
10718 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
10719 $provider_name = 'Google Gemini';
10720 //error_log('[MXCHAT-EMBED] Using Google Gemini API');
10721 } else {
10722 $api_key = $options['api_key'] ?? '';
10723 $endpoint = 'https://api.openai.com/v1/embeddings';
10724 $provider_name = 'OpenAI';
10725 //error_log('[MXCHAT-EMBED] Using OpenAI API');
10726 }
10727
10728 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
10729
10730 if (empty($api_key)) {
10731 $error_message = sprintf('Missing %s API key. Please configure your API key in the MxChat settings.', $provider_name);
10732 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
10733 return $error_message;
10734 }
10735
10736 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
10737 $estimated_tokens = ceil(str_word_count($text) / 0.75);
10738 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
10739
10740 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
10741 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
10742 // Consider truncating text here
10743 }
10744
10745 // Prepare request body based on provider
10746 if (strpos($selected_model, 'gemini-embedding') === 0) {
10747 // Gemini API format
10748 $request_body = array(
10749 'model' => 'models/' . $selected_model,
10750 'content' => array(
10751 'parts' => array(
10752 array('text' => $text)
10753 )
10754 )
10755 );
10756
10757 // Set output dimensionality to 1536 for consistency with other models
10758 $request_body['outputDimensionality'] = 1536;
10759 } else {
10760 // OpenAI/Voyage API format
10761 $request_body = array(
10762 'model' => $selected_model,
10763 'input' => $text
10764 );
10765
10766 // Add output_dimension for voyage-3-large model
10767 if ($selected_model === 'voyage-3-large') {
10768 $request_body['output_dimension'] = 2048;
10769 }
10770 }
10771
10772 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
10773
10774 // Prepare headers based on provider
10775 if (strpos($selected_model, 'gemini-embedding') === 0) {
10776 // Gemini uses API key as query parameter
10777 $endpoint .= '?key=' . $api_key;
10778 $headers = array(
10779 'Content-Type' => 'application/json'
10780 );
10781 } else {
10782 // OpenAI/Voyage use Bearer token
10783 $headers = array(
10784 'Authorization' => 'Bearer ' . $api_key,
10785 'Content-Type' => 'application/json'
10786 );
10787 }
10788
10789 // Make API request
10790 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
10791 $response = wp_remote_post($endpoint, array(
10792 'body' => wp_json_encode($request_body),
10793 'headers' => $headers,
10794 'timeout' => 60 // Increased timeout for large inputs
10795 ));
10796
10797 // Handle wp_remote_post errors
10798 if (is_wp_error($response)) {
10799 $error_message = $response->get_error_message();
10800 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
10801 return 'Connection error: ' . $error_message;
10802 }
10803
10804 // Get and check HTTP response code
10805 $http_code = wp_remote_retrieve_response_code($response);
10806 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
10807
10808 if ($http_code !== 200) {
10809 $error_body = wp_remote_retrieve_body($response);
10810 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
10811
10812 // Try to parse error for more details
10813 $error_json = json_decode($error_body, true);
10814 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
10815 $error_type = $error_json['error']['type'] ?? 'unknown';
10816 $error_message = $error_json['error']['message'] ?? 'No message';
10817 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
10818 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
10819
10820 // Customize error message for common API errors
10821 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
10822 $error_message = sprintf('Invalid %s API key. Please check your API key in the MxChat settings.', $provider_name);
10823 } elseif ($error_type === 'authentication_error') {
10824 $error_message = sprintf('%s authentication failed. Please verify your API key in the MxChat settings.', $provider_name);
10825 }
10826
10827 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
10828 return $error_message;
10829 }
10830
10831 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding", $http_code);
10832 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
10833 return $error_message;
10834 }
10835
10836 // Parse response body
10837 $response_body = wp_remote_retrieve_body($response);
10838 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
10839
10840 $response_data = json_decode($response_body, true);
10841
10842 if (json_last_error() !== JSON_ERROR_NONE) {
10843 $error = json_last_error_msg();
10844 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
10845 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
10846 return "Failed to parse API response: $error";
10847 }
10848
10849 // Handle different response formats based on provider
10850 if (strpos($selected_model, 'gemini-embedding') === 0) {
10851 // Gemini API response format
10852 if (isset($response_data['embedding']['values'])) {
10853 $embedding_dimensions = count($response_data['embedding']['values']);
10854 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
10855
10856 // Check if embedding dimensions are as expected (should be 1536)
10857 if ($embedding_dimensions !== 1536) {
10858 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
10859 }
10860
10861 MxChat_Utils::stamp_active_embedding_model($selected_model);
10862 return $response_data['embedding']['values'];
10863 } else {
10864 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
10865 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
10866
10867 if (isset($response_data['error'])) {
10868 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
10869 //error_log('[MXCHAT-EMBED] ' . $error_message);
10870 return $error_message;
10871 }
10872
10873 $error_message = "Invalid Gemini API response format: No embedding found";
10874 //error_log('[MXCHAT-EMBED] ' . $error_message);
10875 return $error_message;
10876 }
10877 } else {
10878 // OpenAI/Voyage API response format
10879 if (isset($response_data['data'][0]['embedding'])) {
10880 $embedding_dimensions = count($response_data['data'][0]['embedding']);
10881 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
10882
10883 // Check if embedding dimensions are as expected
10884 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
10885 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
10886 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
10887 }
10888
10889 MxChat_Utils::stamp_active_embedding_model($selected_model);
10890 return $response_data['data'][0]['embedding'];
10891 } else {
10892 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
10893 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
10894
10895 if (isset($response_data['error'])) {
10896 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
10897 //error_log('[MXCHAT-EMBED] ' . $error_message);
10898 return $error_message;
10899 }
10900
10901 $error_message = "Invalid API response format: No embedding found";
10902 //error_log('[MXCHAT-EMBED] ' . $error_message);
10903 return $error_message;
10904 }
10905 }
10906 }
10907
10908
10909
10910 }
10911 ?>
10912