PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.18
MxChat – AI Chatbot & Content Generation for WordPress v3.2.18
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.18, at includes/class-mxchat-admin.php

10,987 lines 467.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 $options = get_option('mxchat_options', array());
203 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
204 // The standard dropdown is inert while custom embeddings are on —
205 // whatever value it posts, the next embed uses the custom identity.
206 $new_model = MxChat_Utils::get_selected_embedding_model($options);
207 }
208 $active_model = MxChat_Utils::get_active_embedding_model();
209
210 $is_mismatch = !empty($active_model) && !empty($new_model) && $active_model !== $new_model;
211
212 $active_dims = MxChat_Utils::embedding_model_dimensions($active_model);
213 $new_dims = MxChat_Utils::embedding_model_dimensions($new_model);
214
215 wp_send_json_success(array(
216 'is_mismatch' => $is_mismatch,
217 'active_model' => $active_model,
218 'active_label' => MxChat_Utils::embedding_model_label($active_model),
219 'new_model' => $new_model,
220 'new_label' => MxChat_Utils::embedding_model_label($new_model),
221 'dims_differ' => ($active_dims > 0 && $new_dims > 0 && $active_dims !== $new_dims),
222 'active_dims' => $active_dims,
223 'new_dims' => $new_dims,
224 ));
225 }
226
227 /**
228 * 3.2.3: Dismiss the persistent mismatch banner. Tied to the active+selected
229 * pair so the banner reappears on the next switch event.
230 */
231 public function mxchat_dismiss_embedding_mismatch_ajax() {
232 check_ajax_referer('mxchat_admin_nonce', 'security');
233 if (!current_user_can('manage_options')) {
234 wp_send_json_error(__('Unauthorized', 'mxchat'));
235 }
236
237 $options = get_option('mxchat_options', array());
238 $selected = MxChat_Utils::get_selected_embedding_model($options);
239 $active = MxChat_Utils::get_active_embedding_model();
240 update_option('mxchat_dismissed_embedding_mismatch', $active . '|' . $selected, false);
241 wp_send_json_success();
242 }
243
244 /**
245 * 3.2.3: Persistent admin banner shown whenever the active embedding model
246 * (last used to actually embed something) differs from the currently
247 * selected model. Pure option comparison — no DB queries on every page
248 * load. The banner auto-clears once both match again, i.e. after a delete
249 * + re-embed cycle.
250 */
251 public function mxchat_embedding_mismatch_notice() {
252 if (!current_user_can('manage_options')) {
253 return;
254 }
255
256 $options = get_option('mxchat_options', array());
257 $selected = MxChat_Utils::get_selected_embedding_model($options);
258 $active = MxChat_Utils::get_active_embedding_model();
259
260 if (empty($active) || empty($selected) || $active === $selected) {
261 return;
262 }
263
264 $dismissed = get_option('mxchat_dismissed_embedding_mismatch', '');
265 if ($dismissed === $active . '|' . $selected) {
266 return;
267 }
268
269 $active_label = MxChat_Utils::embedding_model_label($active);
270 $selected_label = MxChat_Utils::embedding_model_label($selected);
271 $active_dims = MxChat_Utils::embedding_model_dimensions($active);
272 $selected_dims = MxChat_Utils::embedding_model_dimensions($selected);
273 $dims_differ = ($active_dims > 0 && $selected_dims > 0 && $active_dims !== $selected_dims);
274
275 $kb_url = admin_url('admin.php?page=mxchat-prompts');
276 $actions_url = admin_url('admin.php?page=mxchat-actions');
277
278 ?>
279 <div class="notice notice-error is-dismissible mxchat-embedding-mismatch-notice"
280 data-active="<?php echo esc_attr($active); ?>"
281 data-selected="<?php echo esc_attr($selected); ?>">
282 <p><strong><?php esc_html_e('MxChat: Embedding model mismatch detected', 'mxchat'); ?></strong></p>
283 <p>
284 <?php
285 printf(
286 /* translators: 1: previously-used model name, 2: currently-selected model name */
287 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'),
288 '<code>' . esc_html($active_label) . '</code>',
289 '<code>' . esc_html($selected_label) . '</code>'
290 );
291 ?>
292 </p>
293 <?php if ($dims_differ) : ?>
294 <p>
295 <strong><?php esc_html_e('Dimension mismatch:', 'mxchat'); ?></strong>
296 <?php
297 printf(
298 /* translators: 1: old dim count, 2: new dim count */
299 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'),
300 (int) $active_dims,
301 (int) $selected_dims
302 );
303 ?>
304 </p>
305 <?php endif; ?>
306 <p>
307 <?php esc_html_e('To fix this:', 'mxchat'); ?>
308 <a href="<?php echo esc_url($kb_url); ?>"><?php esc_html_e('Delete all knowledge base entries', 'mxchat'); ?></a> ·
309 <a href="<?php echo esc_url($actions_url); ?>"><?php esc_html_e('Delete all actions', 'mxchat'); ?></a> ·
310 <?php esc_html_e('then re-import / re-add them with the new model selected.', 'mxchat'); ?>
311 </p>
312 </div>
313 <script>
314 (function($){
315 $(document).on('click', '.mxchat-embedding-mismatch-notice .notice-dismiss', function(){
316 $.post(ajaxurl, {
317 action: 'mxchat_dismiss_embedding_mismatch',
318 security: '<?php echo esc_js(wp_create_nonce('mxchat_admin_nonce')); ?>'
319 });
320 });
321 })(jQuery);
322 </script>
323 <?php
324 }
325
326 private function is_license_active() {
327 $license_status = get_option('mxchat_license_status', 'inactive');
328 return ($license_status === 'active');
329 }
330
331 private function initialize_default_options() {
332 $default_options = array(
333 'api_key' => '',
334 'xai_api_key' => '',
335 'claude_api_key' => '',
336 'deepseek_api_key' => '',
337 'voyage_api_key' => '',
338 'gemini_api_key' => '',
339 'enable_streaming_toggle' => 'off',
340 'enable_web_search' => 'off',
341 'embedding_model' => 'text-embedding-ada-002',
342 '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:
343
344 # Response Style - CRITICALLY IMPORTANT
345 - MAXIMUM LENGTH: 1-3 short sentences per response
346 - Ultra-concise: Get straight to the answer with no filler
347 - No introductions like "Sure!" or "I\'d be happy to help"
348 - No phrases like "based on my knowledge" or "according to information"
349 - No explanatory text before giving the answer
350 - No summaries or repetition
351 - Hyperlink all URLs
352 - Respond in user\'s language
353 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
354
355 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
356 - ONLY answer questions using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
357 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
358 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
359 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
360 - Better to admit insufficient information than provide inaccurate answers',
361 'model' => esc_html__('gpt-5.6-sol', 'mxchat'),
362 'rate_limit_logged_out' => esc_html__('100', 'mxchat'),
363 'role_rate_limits' => array(),
364 'rate_limit_message' => esc_html__('Rate limit exceeded. Please try again later.', 'mxchat'),
365 'enable_email_block' => '',
366 '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'),
367 'email_blocker_button_text' => esc_html__('Start Chat', 'mxchat'),
368 'enable_name_field' => 'off', // NEW
369 'name_field_placeholder' => esc_html__('Enter your name', 'mxchat'), // NEW
370 'top_bar_title' => esc_html__('MxChat', 'mxchat'),
371 'intro_message' => __('Hello! How can I assist you today?', 'mxchat'),
372 'ai_agent_text' => esc_html__('AI Agent', 'mxchat'),
373 'input_copy' => esc_html__('How can I assist?', 'mxchat'),
374 'append_to_body' => esc_html__('off', 'mxchat'),
375 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude'
376 'post_type_visibility_list' => array(), // Array of post type slugs
377 'contextual_awareness_toggle' => 'off',
378 'citation_links_toggle' => 'on',
379 'satisfaction_rating_enabled' => 'off',
380 'satisfaction_rating_idle_seconds' => 60,
381 'satisfaction_rating_question' => '',
382 'satisfaction_rating_thanks' => '',
383 'satisfaction_rating_placeholder' => '',
384 'satisfaction_rating_saved' => '',
385 'close_button_color' => esc_html__('#fff', 'mxchat'),
386 'chatbot_bg_color' => esc_html__('#fff', 'mxchat'),
387 'user_message_bg_color' => esc_html__('#fff', 'mxchat'),
388 'user_message_font_color' => esc_html__('#212121', 'mxchat'),
389 'bot_message_bg_color' => esc_html__('#212121', 'mxchat'),
390 'bot_message_font_color' => esc_html__('#fff', 'mxchat'),
391 'top_bar_bg_color' => esc_html__('#212121', 'mxchat'),
392 'send_button_font_color' => esc_html__('#212121', 'mxchat'),
393 'chat_input_font_color' => esc_html__('#212121', 'mxchat'),
394 'chatbot_background_color' => esc_html__('#212121', 'mxchat'),
395 'icon_color' => esc_html__('#fff', 'mxchat'),
396 'enable_woocommerce_integration' => esc_html__('0', 'mxchat'),
397 'link_target_toggle' => esc_html__('off', 'mxchat'),
398 'pre_chat_message' => esc_html__('Hey there! Ask me anything!', 'mxchat'),
399
400 // New fields for Loops Integration
401 'loops_api_key' => '',
402 'loops_mailing_list' => '',
403 'triggered_phrase_response' => __('Would you like to join our mailing list? Please provide your email below.', 'mxchat'),
404 'email_capture_response' => __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat'),
405 'popular_question_1' => '',
406 'popular_question_2' => '',
407 'popular_question_3' => '',
408 'pdf_intent_trigger_text' => __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat'),
409 'pdf_intent_success_text' => __("I've processed the PDF. What questions do you have about it?", 'mxchat'),
410 'pdf_intent_error_text' => __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat'),
411 'pdf_max_pages' => 69,
412 'show_pdf_upload_button' => 'on',
413 'show_word_upload_button' => 'on',
414
415 // Live Agent Integration (Slack)
416 'live_agent_webhook_url' => '',
417 'live_agent_secret_key' => '',
418 'live_agent_bot_token' => '',
419 'live_agent_message_bg_color' => esc_html__('#ffffff', 'mxchat'),
420 'live_agent_message_font_color' => esc_html__('#333333', 'mxchat'),
421
422 // Telegram Integration
423 'telegram_status' => 'off',
424 'telegram_bot_token' => '',
425 'telegram_group_id' => '',
426 'telegram_webhook_secret' => '',
427 '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'),
428 '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'),
429
430 'chat_toolbar_toggle' => esc_html__('off', 'mxchat'),
431 'mode_indicator_bg_color' => esc_html__('#767676', 'mxchat'),
432 'mode_indicator_font_color' => esc_html__('#ffffff', 'mxchat'),
433 'toolbar_icon_color' => esc_html__('#212121', 'mxchat'),
434
435 // Optimization settings
436 'script_loading_strategy' => 'default',
437
438 // Debug settings
439 'debug_mode' => 'off',
440 );
441
442
443 // Merge existing options with defaults
444 $existing_options = get_option('mxchat_options', array());
445 $merged_options = wp_parse_args($existing_options, $default_options);
446
447 // Update the options if they have changed
448 if ($existing_options !== $merged_options) {
449 update_option('mxchat_options', $merged_options);
450 }
451
452 // Add default limits for each role
453 $roles = wp_roles()->get_names();
454 foreach ($roles as $role_id => $role_name) {
455 $default_options['role_rate_limits'][$role_id] = esc_html__('100', 'mxchat');
456 }
457
458 return $default_options;
459
460 // Update the $this->options property
461 $this->options = $merged_options;
462 }
463
464 public function mxchat_add_plugin_page() {
465 // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file).
466 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
467
468 // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu
469 // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now
470 // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding).
471 add_menu_page(
472 esc_html__('MxChat', 'mxchat'),
473 esc_html__('MxChat', 'mxchat'),
474 'manage_options',
475 'mxchat-max',
476 array($this, 'mxchat_create_dashboard_page'),
477 'dashicons-testimonial',
478 6
479 );
480
481 // Onboarding submenu — first child under MxChat (plan-d14e89).
482 // First registration uses menu_slug === parent slug 'mxchat-max' →
483 // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the
484 // first child shows as "Onboarding" instead of a redundant pair.
485 add_submenu_page(
486 'mxchat-max',
487 esc_html__('MxChat Onboarding', 'mxchat'),
488 esc_html__('Onboarding', 'mxchat'),
489 'manage_options',
490 'mxchat-max',
491 array($this, 'mxchat_create_dashboard_page')
492 );
493 // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link
494 // + legacy redirects still target this slug). Parent === null keeps it
495 // out of the menu while remaining accessible by URL.
496 add_submenu_page(
497 null,
498 esc_html__('MxChat Onboarding', 'mxchat'),
499 esc_html__('Onboarding', 'mxchat'),
500 'manage_options',
501 'mxchat-onboarding',
502 array($this, 'mxchat_create_dashboard_page')
503 );
504
505 // Settings submenu — same callback as before, just at a new slug.
506 add_submenu_page(
507 'mxchat-max',
508 esc_html__('MxChat Settings', 'mxchat'),
509 esc_html__('Settings', 'mxchat'),
510 'manage_options',
511 'mxchat-settings',
512 array($this, 'mxchat_create_admin_page')
513 );
514
515 // Submenu page for Knowledge
516 add_submenu_page(
517 'mxchat-max',
518 esc_html__('Prompts', 'mxchat'),
519 esc_html__('Knowledge', 'mxchat'),
520 'manage_options',
521 'mxchat-prompts',
522 array($this, 'mxchat_create_prompts_page')
523 );
524
525 add_submenu_page(
526 'mxchat-max',
527 esc_html__('Chat Transcripts', 'mxchat'),
528 esc_html__('Transcripts', 'mxchat'),
529 'manage_options',
530 'mxchat-transcripts',
531 array($this, 'mxchat_create_transcripts_page')
532 );
533
534 add_submenu_page(
535 'mxchat-max',
536 esc_html__('MxChat Actions', 'mxchat'),
537 esc_html__('Actions', 'mxchat'),
538 'manage_options',
539 'mxchat-actions',
540 array($this, 'mxchat_actions_page_html')
541 );
542
543 // Content Generator page
544 add_submenu_page(
545 'mxchat-max',
546 esc_html__('Content', 'mxchat'),
547 esc_html__('Content', 'mxchat'),
548 'manage_options',
549 'mxchat-content',
550 array($this, 'mxchat_create_content_page')
551 );
552
553 }
554
555 /**
556 * Register the Pro & Extensions submenu on a later admin_menu priority so it
557 * always renders as the bottom-most item in the MxChat sidebar — below
558 * configuration pages like API Access (priority 20).
559 */
560 public function mxchat_add_pro_extensions_page() {
561 add_submenu_page(
562 'mxchat-max',
563 esc_html__('Pro & Extensions', 'mxchat'),
564 esc_html__('Pro & Extensions', 'mxchat'),
565 'manage_options',
566 'mxchat-activation',
567 array($this, 'mxchat_create_activation_page')
568 );
569 }
570
571 public function mxchat_create_addons_page() {
572 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
573 $addons_page = new MxChat_Addons();
574 $addons_page->render_page();
575 }
576
577 /**
578 * Render the Content Generator admin page
579 */
580 public function mxchat_create_content_page() {
581 require_once plugin_dir_path(__FILE__) . 'admin-content-page.php';
582 // The Editor Assistant toggle on the Settings tab renders through
583 // mxchat_render_field_wrapper(), which lives in admin-settings-page.php
584 // (plan-f7df40 relocation) — load it the same way the settings page does.
585 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
586 mxchat_render_content_page($this);
587 }
588
589 /**
590 * Test actual streaming functionality in the WordPress environment
591 */
592 public function mxchat_handle_test_streaming_actual() {
593 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
594
595 // A nonce is not authorization (plan-mxchat-20260731-c63fb6). This handler
596 // reads the site's provider keys and spends them on an outbound call, so it
597 // is billable abuse for any logged-in user who obtained the nonce.
598 if (!current_user_can('manage_options')) {
599 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
600 }
601
602 // Check if headers have already been sent
603 if (headers_sent()) {
604 wp_send_json_error(['message' => 'Headers already sent - streaming not possible']);
605 return;
606 }
607
608 // Check for required functions
609 if (!function_exists('curl_init')) {
610 wp_send_json_error(['message' => 'cURL not available - streaming requires cURL']);
611 return;
612 }
613
614 // Get user's selected model and API key
615 $options = get_option('mxchat_options', []);
616 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
617
618 // Get the provider from the model
619 $model_parts = explode('-', $selected_model);
620 $provider = strtolower($model_parts[0]);
621
622 // Get the appropriate API key
623 $api_key = '';
624 switch ($provider) {
625 case 'gpt':
626 case 'o1':
627 $api_key = $options['api_key'] ?? '';
628 break;
629 case 'claude':
630 $api_key = $options['claude_api_key'] ?? '';
631 break;
632 case 'grok':
633 $api_key = $options['xai_api_key'] ?? '';
634 break;
635 case 'deepseek':
636 $api_key = $options['deepseek_api_key'] ?? '';
637 break;
638 case 'gemini':
639 $api_key = $options['gemini_api_key'] ?? '';
640 break;
641 default:
642 // Default to OpenAI for unknown models
643 $api_key = $options['api_key'] ?? '';
644 $provider = 'gpt';
645 break;
646 }
647
648 if (empty($api_key)) {
649 wp_send_json_error(['message' => "API key not configured for {$provider} provider"]);
650 return;
651 }
652
653 // Test streaming with the selected model and provider
654 try {
655 $this->perform_streaming_test($provider, $selected_model, $api_key);
656 } catch (Exception $e) {
657 wp_send_json_error(['message' => 'Streaming test exception: ' . $e->getMessage()]);
658 }
659 }
660
661 /**
662 * Perform the actual streaming test
663 */
664 private function perform_streaming_test($provider, $model, $api_key) {
665 // Set streaming headers
666 header('Content-Type: text/event-stream');
667 header('Cache-Control: no-cache');
668 header('X-Accel-Buffering: no'); // Disable nginx buffering
669
670 // Prepare test message
671 $test_message = "Please respond with exactly: 'Streaming test successful!' - send this as a short response for testing.";
672
673 // Configure API request based on provider
674 $url = '';
675 $headers = [];
676 $body = [];
677
678 switch ($provider) {
679 case 'gpt':
680 case 'o1':
681 $url = 'https://api.openai.com/v1/chat/completions';
682 $headers = [
683 'Content-Type: application/json',
684 'Authorization: Bearer ' . $api_key
685 ];
686 $body = [
687 'model' => $model,
688 'messages' => [['role' => 'user', 'content' => $test_message]],
689 'max_tokens' => 50,
690 'temperature' => 0.3,
691 'stream' => true
692 ];
693 break;
694
695 case 'claude':
696 $url = 'https://api.anthropic.com/v1/messages';
697 $headers = [
698 'Content-Type: application/json',
699 'x-api-key: ' . $api_key,
700 'anthropic-version: 2023-06-01'
701 ];
702 $body = [
703 'model' => $model,
704 'messages' => [['role' => 'user', 'content' => $test_message]],
705 'max_tokens' => 50,
706 'temperature' => 0.3,
707 'stream' => true
708 ];
709 // Claude flagships from Opus 4.7 onward reject the temperature
710 // param outright (400). Reuse the catalog's decision — this path
711 // previously sent temperature unconditionally, so the streaming
712 // test was broken for Opus 4.7/4.8, Fable 5 and Sonnet 5.
713 if (class_exists('MxChat_Model_Catalog')
714 && method_exists('MxChat_Model_Catalog', 'supports_temperature')
715 && !MxChat_Model_Catalog::supports_temperature($model)) {
716 unset($body['temperature']);
717 }
718 break;
719
720 case 'grok':
721 $url = 'https://api.x.ai/v1/chat/completions';
722 $headers = [
723 'Content-Type: application/json',
724 'Authorization: Bearer ' . $api_key
725 ];
726 $body = [
727 'model' => $model,
728 'messages' => [['role' => 'user', 'content' => $test_message]],
729 'max_tokens' => 50,
730 'temperature' => 0.3,
731 'stream' => true
732 ];
733 break;
734
735 case 'deepseek':
736 $url = 'https://api.deepseek.com/v1/chat/completions';
737 $headers = [
738 'Content-Type: application/json',
739 'Authorization: Bearer ' . $api_key
740 ];
741 $body = [
742 'model' => $model,
743 'messages' => [['role' => 'user', 'content' => $test_message]],
744 'max_tokens' => 50,
745 'temperature' => 0.3,
746 'stream' => true,
747 // DeepSeek V4 defaults to thinking mode ON — reasoning would
748 // consume the 50-token test budget and return no visible text.
749 'thinking' => ['type' => 'disabled']
750 ];
751 break;
752
753 default:
754 echo "data: " . json_encode(['error' => 'Unsupported provider for streaming test: ' . $provider]) . "\n\n";
755 flush();
756 return;
757 }
758
759 // Initialize cURL
760 $ch = curl_init();
761 curl_setopt($ch, CURLOPT_URL, $url);
762 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
763 curl_setopt($ch, CURLOPT_POST, true);
764 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
765 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
766 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
767 curl_setopt($ch, CURLOPT_TIMEOUT, 30);
768 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($provider) {
769 return $this->process_streaming_test_data($data, $provider);
770 });
771
772 // Send initial test message
773 echo "data: " . json_encode(['content' => '[Starting streaming test...]']) . "\n\n";
774 flush();
775
776 $result = curl_exec($ch);
777 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
778 $curl_error = curl_error($ch);
779 curl_close($ch);
780
781 if ($curl_error) {
782 echo "data: " . json_encode(['error' => 'cURL Error: ' . $curl_error]) . "\n\n";
783 flush();
784 return;
785 }
786
787 if ($http_code !== 200) {
788 echo "data: " . json_encode(['error' => 'API returned HTTP ' . $http_code]) . "\n\n";
789 flush();
790 return;
791 }
792
793 // Send completion signal
794 echo "data: [DONE]\n\n";
795 flush();
796 }
797
798 /**
799 * Process streaming data for the test
800 */
801 private function process_streaming_test_data($data, $provider) {
802 static $chunk_count = 0;
803
804 $lines = explode("\n", $data);
805
806 foreach ($lines as $line) {
807 if (trim($line) === '') {
808 continue;
809 }
810
811 // Handle different provider formats
812 if ($provider === 'claude') {
813 // Claude uses event: and data: format
814 if (strpos($line, 'data: ') === 0) {
815 $json_str = substr($line, 6);
816 $json = json_decode($json_str, true);
817
818 if (isset($json['type']) && $json['type'] === 'content_block_delta') {
819 if (isset($json['delta']['text'])) {
820 $chunk_count++;
821 echo "data: " . json_encode([
822 'content' => $json['delta']['text'],
823 'test_chunk' => $chunk_count
824 ]) . "\n\n";
825 flush();
826 }
827 }
828 }
829 } else {
830 // OpenAI, X.AI, DeepSeek format
831 if (strpos($line, 'data: ') === 0) {
832 $json_str = substr($line, 6);
833
834 if ($json_str === '[DONE]') {
835 // Don't echo [DONE] here, let the main function handle it
836 continue;
837 }
838
839 $json = json_decode($json_str, true);
840 if (isset($json['choices'][0]['delta']['content'])) {
841 $chunk_count++;
842 echo "data: " . json_encode([
843 'content' => $json['choices'][0]['delta']['content'],
844 'test_chunk' => $chunk_count
845 ]) . "\n\n";
846 flush();
847 }
848 }
849 }
850 }
851
852 return strlen($data);
853 }
854
855 /**
856 * Updated version of your existing test method (keep this as a fallback)
857 */
858 public function mxchat_handle_test_streaming() {
859 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
860
861 // plan-mxchat-20260731-c63fb6 — the delegate below checks this too, but
862 // fail here so the alias never becomes a bypass if that call is refactored.
863 if (!current_user_can('manage_options')) {
864 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
865 }
866
867 // Use the actual streaming test instead
868 $this->mxchat_handle_test_streaming_actual();
869 }
870
871
872 public function register_pinecone_settings() {
873 register_setting(
874 'mxchat_pinecone_addon_options',
875 'mxchat_pinecone_addon_options',
876 array(
877 'type' => 'array',
878 'sanitize_callback' => array($this, 'sanitize_pinecone_settings'),
879 'default' => array(
880 'mxchat_use_pinecone' => '0',
881 'mxchat_pinecone_api_key' => '',
882 'mxchat_pinecone_host' => '',
883 'mxchat_pinecone_index' => '',
884 'mxchat_pinecone_environment' => ''
885 )
886 )
887 );
888 }
889
890 public function sanitize_pinecone_settings($input) {
891 $sanitized = array();
892
893 $sanitized['mxchat_use_pinecone'] = isset($input['mxchat_use_pinecone']) ? '1' : '0';
894 $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
895 $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
896 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
897 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
898
899 // Remove https:// from host if present
900 $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']);
901
902 return $sanitized;
903 }
904
905 public function register_openai_vectorstore_settings() {
906 register_setting(
907 'mxchat_openai_vectorstore_options',
908 'mxchat_openai_vectorstore_options',
909 array(
910 'type' => 'array',
911 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'),
912 'default' => array(
913 'mxchat_use_openai_vectorstore' => '0',
914 'mxchat_vectorstore_ids' => '',
915 'mxchat_vectorstore_max_results' => 5
916 )
917 )
918 );
919 }
920
921 public function sanitize_openai_vectorstore_settings($input) {
922 $sanitized = array();
923
924 $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0';
925 $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? '');
926 $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5);
927
928 // Ensure max results is within reasonable range
929 if ($sanitized['mxchat_vectorstore_max_results'] < 1) {
930 $sanitized['mxchat_vectorstore_max_results'] = 1;
931 }
932 if ($sanitized['mxchat_vectorstore_max_results'] > 20) {
933 $sanitized['mxchat_vectorstore_max_results'] = 20;
934 }
935
936 return $sanitized;
937 }
938
939 /**
940 * AJAX handler to test OpenAI Vector Store connection
941 */
942 public function mxchat_test_vectorstore_connection() {
943 check_ajax_referer('mxchat_admin_nonce', 'nonce');
944
945 if (!current_user_can('manage_options')) {
946 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
947 return;
948 }
949
950 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
951 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
952 $mxchat_options = get_option('mxchat_options', array());
953 $api_key = $mxchat_options['api_key'] ?? '';
954
955 if (empty($api_key)) {
956 wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat')));
957 return;
958 }
959
960 if (empty($vectorstore_ids)) {
961 wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat')));
962 return;
963 }
964
965 // Get the first Vector Store ID for testing
966 $ids_array = array_map('trim', explode(',', $vectorstore_ids));
967 $test_id = $ids_array[0];
968
969 // Test by retrieving the Vector Store info
970 $response = wp_remote_get(
971 'https://api.openai.com/v1/vector_stores/' . $test_id,
972 array(
973 'headers' => array(
974 'Authorization' => 'Bearer ' . $api_key,
975 'Content-Type' => 'application/json',
976 'OpenAI-Beta' => 'assistants=v2'
977 ),
978 'timeout' => 30
979 )
980 );
981
982 if (is_wp_error($response)) {
983 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message()));
984 return;
985 }
986
987 $status_code = wp_remote_retrieve_response_code($response);
988 $body = json_decode(wp_remote_retrieve_body($response), true);
989
990 if ($status_code === 200 && isset($body['id'])) {
991 $file_count = $body['file_counts']['completed'] ?? 0;
992 $name = $body['name'] ?? $test_id;
993 wp_send_json_success(array(
994 'message' => sprintf(
995 __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'),
996 esc_html($name),
997 $file_count
998 )
999 ));
1000 } elseif ($status_code === 404) {
1001 wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat')));
1002 } elseif ($status_code === 401) {
1003 wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat')));
1004 } else {
1005 $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat');
1006 wp_send_json_error(array('message' => $error_message));
1007 }
1008 }
1009
1010 /**
1011 * AJAX handler to test Slack connection and validate scopes
1012 */
1013 public function mxchat_test_slack_connection() {
1014 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1015
1016 if (!current_user_can('manage_options')) {
1017 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1018 return;
1019 }
1020
1021 $bot_token = $this->options['live_agent_bot_token'] ?? '';
1022
1023 if (empty($bot_token)) {
1024 wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat')));
1025 return;
1026 }
1027
1028 // Test 1: Validate bot token with auth.test
1029 $auth_response = wp_remote_post('https://slack.com/api/auth.test', array(
1030 'headers' => array(
1031 'Authorization' => 'Bearer ' . $bot_token,
1032 'Content-Type' => 'application/json'
1033 ),
1034 'timeout' => 15
1035 ));
1036
1037 if (is_wp_error($auth_response)) {
1038 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message()));
1039 return;
1040 }
1041
1042 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1043
1044 if (!isset($auth_body['ok']) || !$auth_body['ok']) {
1045 $error = $auth_body['error'] ?? 'unknown_error';
1046 $error_messages = array(
1047 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'),
1048 'not_authed' => __('No authentication token provided.', 'mxchat'),
1049 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'),
1050 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'),
1051 );
1052 $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error);
1053 wp_send_json_error(array('message' => $message));
1054 return;
1055 }
1056
1057 $team_name = $auth_body['team'] ?? 'Unknown Workspace';
1058 $bot_name = $auth_body['user'] ?? 'Unknown Bot';
1059
1060 // Test 2: Check if we can list channels (tests channels:read scope)
1061 $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array(
1062 'headers' => array(
1063 'Authorization' => 'Bearer ' . $bot_token,
1064 'Content-Type' => 'application/json'
1065 ),
1066 'body' => json_encode(array('limit' => 1)),
1067 'timeout' => 15
1068 ));
1069
1070 $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true);
1071 $can_read_channels = isset($channels_body['ok']) && $channels_body['ok'];
1072
1073 // Test 3: Check if we can create channels (tests channels:manage scope)
1074 // We'll just check the error message without actually creating
1075 $create_response = wp_remote_post('https://slack.com/api/conversations.create', array(
1076 'headers' => array(
1077 'Authorization' => 'Bearer ' . $bot_token,
1078 'Content-Type' => 'application/json'
1079 ),
1080 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)),
1081 'timeout' => 15
1082 ));
1083
1084 $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
1085
1086 // If channel was created, delete it immediately
1087 if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) {
1088 wp_remote_post('https://slack.com/api/conversations.archive', array(
1089 'headers' => array(
1090 'Authorization' => 'Bearer ' . $bot_token,
1091 'Content-Type' => 'application/json'
1092 ),
1093 'body' => json_encode(array('channel' => $create_body['channel']['id'])),
1094 'timeout' => 15
1095 ));
1096 $can_create_channels = true;
1097 } else {
1098 $create_error = $create_body['error'] ?? '';
1099 // name_taken means we have permission but channel exists
1100 $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok']));
1101
1102 // Check for missing scope errors
1103 if ($create_error === 'missing_scope') {
1104 $can_create_channels = false;
1105 }
1106 }
1107
1108 // Build result message
1109 $results = array();
1110 $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name));
1111 $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name));
1112 $results[] = '';
1113 $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat');
1114 $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat');
1115
1116 $missing_scopes = array();
1117 if (!$can_read_channels) $missing_scopes[] = 'channels:read';
1118 if (!$can_create_channels) $missing_scopes[] = 'channels:manage';
1119
1120 if (!empty($missing_scopes)) {
1121 wp_send_json_error(array(
1122 'message' => implode("\n", $results),
1123 'missing_scopes' => $missing_scopes,
1124 'partial' => true
1125 ));
1126 } else {
1127 wp_send_json_success(array(
1128 'message' => implode("\n", $results)
1129 ));
1130 }
1131 }
1132
1133 public function mxchat_display_admin_notice() {
1134 // Success notice
1135 if ($message = get_transient('mxchat_admin_notice_success')) {
1136 ?>
1137 <div class="notice notice-success is-dismissible">
1138 <p><?php echo esc_html($message); ?></p>
1139 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1140 </div>
1141 <?php
1142 delete_transient('mxchat_admin_notice_success'); // Clear the transient after displaying
1143 }
1144
1145 // Error notice
1146 if ($message = get_transient('mxchat_admin_notice_error')) {
1147 ?>
1148 <div class="notice notice-error is-dismissible">
1149 <p><?php echo esc_html($message); ?></p>
1150 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1151 </div>
1152 <?php
1153 delete_transient('mxchat_admin_notice_error'); // Clear the transient after displaying
1154 }
1155 }
1156
1157 public function show_live_agent_disabled_banner() {
1158 $show_disabled_notice = get_option('mxchat_show_live_agent_disabled_notice', false);
1159
1160 if ($show_disabled_notice) {
1161 ?>
1162 <div class="mxchat-live-agent-disabled-notice" id="mxchat-disabled-notice">
1163 <div class="mxchat-pro-notification">
1164 <button type="button" class="mxchat-dismiss-btn" onclick="dismissLiveAgentNotice()" aria-label="Dismiss notification">
1165 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1166 <line x1="18" y1="6" x2="6" y2="18"></line>
1167 <line x1="6" y1="6" x2="18" y2="18"></line>
1168 </svg>
1169 </button>
1170 <div class="mxchat-live-agent-content">
1171 <h3>🔧 Live Agent Integration Updated!</h3>
1172 <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>
1173 </div>
1174 </div>
1175 </div>
1176 <?php
1177 }
1178 }
1179 /**
1180 * Render the Onboarding page. Delegates to the procedural renderer in
1181 * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1182 * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1183 * Onboarding submenu).
1184 *
1185 * When the user has dismissed onboarding and lands on `mxchat-max` (the
1186 * legacy URL, since the Onboarding submenu has been removed), redirect to
1187 * Settings instead — the page is "graduated" and we shouldn't dump them
1188 * back onto it. They can still navigate here directly via the unhide link.
1189 */
1190 public function mxchat_create_dashboard_page() {
1191 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1192
1193 $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1194 if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1195 wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1196 exit;
1197 }
1198
1199 if (function_exists('mxchat_render_onboarding_page')) {
1200 mxchat_render_onboarding_page();
1201 return;
1202 }
1203 // Defensive: if the include failed to load, fall back to the old Settings page
1204 // so the top-level menu never lands on an empty screen.
1205 $this->mxchat_create_admin_page();
1206 }
1207
1208 /**
1209 * Hide the Onboarding submenu when the user has dismissed it (either
1210 * manually or via auto-graduation). The page itself remains routable so
1211 * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1212 */
1213 public function mxchat_apply_onboarding_visibility() {
1214 if (!function_exists('mxchat_onboarding_is_dismissed')) {
1215 return;
1216 }
1217 if (mxchat_onboarding_is_dismissed()) {
1218 // The first MxChat child is the same-slug-as-parent registration
1219 // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1220 // Remove it so the menu opens straight to Settings after dismiss.
1221 remove_submenu_page('mxchat-max', 'mxchat-max');
1222 }
1223 }
1224
1225 public function mxchat_create_admin_page() {
1226 $this->add_live_agent_nonce();
1227 $this->add_theme_migration_nonce();
1228
1229 // Include and render the new sidebar-based settings page
1230 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
1231 mxchat_render_settings_page($this);
1232 }
1233
1234 public function dismiss_live_agent_notice() {
1235 // Add debugging
1236 //error_log('dismiss_live_agent_notice called');
1237 //error_log('POST data: ' . print_r($_POST, true));
1238
1239 // Verify nonce
1240 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) {
1241 //error_log('Nonce verification failed');
1242 wp_die('Security check failed');
1243 }
1244
1245 // Remove the notice flag
1246 $deleted = delete_option('mxchat_show_live_agent_disabled_notice');
1247 //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no'));
1248
1249 wp_send_json_success();
1250 }
1251 public function add_live_agent_nonce() {
1252 if (get_option('mxchat_show_live_agent_disabled_notice', false)) {
1253 // Make sure your admin script is enqueued and localize the data
1254 wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array(
1255 'nonce' => wp_create_nonce('dismiss_live_agent_notice'),
1256 'ajaxurl' => admin_url('admin-ajax.php')
1257 ));
1258 }
1259 }
1260
1261 /**
1262 * Show theme migration notice for Pro users with AI-generated themes
1263 * Only shown once - dismissible and stored in options
1264 */
1265 public function show_theme_migration_banner() {
1266 // Only show if Pro is activated
1267 if (!$this->is_activated) {
1268 return;
1269 }
1270
1271 // Check if notice should be shown
1272 $show_notice = get_option('mxchat_show_theme_migration_notice', false);
1273
1274 if ($show_notice) {
1275 ?>
1276 <div class="mxchat-theme-migration-notice" id="mxchat-theme-migration-notice">
1277 <div class="mxchat-pro-notification">
1278 <button type="button" class="mxchat-dismiss-btn" onclick="dismissThemeMigrationNotice()" aria-label="Dismiss notification">
1279 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1280 <line x1="18" y1="6" x2="6" y2="18"></line>
1281 <line x1="6" y1="6" x2="18" y2="18"></line>
1282 </svg>
1283 </button>
1284 <div class="mxchat-theme-migration-content">
1285 <h3>🎨 AI Theme Migration Required</h3>
1286 <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>
1287 </div>
1288 </div>
1289 </div>
1290 <?php
1291 }
1292 }
1293
1294 /**
1295 * Dismiss theme migration notice via AJAX
1296 */
1297 public function dismiss_theme_migration_notice() {
1298 // Verify nonce
1299 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_theme_migration_notice')) {
1300 wp_die('Security check failed');
1301 }
1302
1303 // Remove the notice flag
1304 delete_option('mxchat_show_theme_migration_notice');
1305
1306 wp_send_json_success();
1307 }
1308
1309 /**
1310 * Add nonce for theme migration notice dismiss
1311 */
1312 public function add_theme_migration_nonce() {
1313 if (get_option('mxchat_show_theme_migration_notice', false) && $this->is_activated) {
1314 wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array(
1315 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'),
1316 'ajaxurl' => admin_url('admin-ajax.php')
1317 ));
1318 }
1319 }
1320
1321 public function mxchat_create_transcripts_page() {
1322 global $wpdb;
1323 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1324
1325 // Get basic stats
1326 $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0;
1327 $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0;
1328
1329 // Count unique users with detailed breakdown
1330 $total_users = $wpdb->get_var("
1331 SELECT COUNT(DISTINCT
1332 CASE
1333 WHEN user_email != '' AND user_email IS NOT NULL THEN user_email
1334 WHEN user_id != 0 THEN CONCAT('user_', user_id)
1335 WHEN user_identifier NOT LIKE 'Tech-Savvy User'
1336 AND user_identifier NOT LIKE 'Detail-Oriented User'
1337 AND user_identifier NOT LIKE 'Language Learner'
1338 AND user_identifier NOT LIKE 'Casual Browser'
1339 AND user_identifier NOT LIKE 'Policy Enforcer'
1340 AND user_identifier NOT LIKE 'Researcher'
1341 AND user_identifier NOT LIKE 'Loyalty Member'
1342 AND user_identifier NOT LIKE 'Gift Buyer'
1343 AND user_identifier NOT LIKE 'Parent or Caregiver'
1344 THEN user_identifier
1345 ELSE session_id
1346 END
1347 )
1348 FROM $table_name
1349 WHERE role != 'assistant'
1350 ");
1351
1352 // Get user type breakdown
1353 $registered_users = $wpdb->get_var("
1354 SELECT COUNT(DISTINCT user_email)
1355 FROM $table_name
1356 WHERE user_email != '' AND user_email IS NOT NULL
1357 ");
1358
1359 $guest_users = $wpdb->get_var("
1360 SELECT COUNT(DISTINCT user_identifier)
1361 FROM $table_name
1362 WHERE (user_email = '' OR user_email IS NULL)
1363 AND role != 'assistant'
1364 AND user_identifier NOT LIKE 'Tech-Savvy User'
1365 AND user_identifier NOT LIKE 'Detail-Oriented User'
1366 AND user_identifier NOT LIKE 'Language Learner'
1367 AND user_identifier NOT LIKE 'Casual Browser'
1368 AND user_identifier NOT LIKE 'Policy Enforcer'
1369 AND user_identifier NOT LIKE 'Researcher'
1370 AND user_identifier NOT LIKE 'Loyalty Member'
1371 AND user_identifier NOT LIKE 'Gift Buyer'
1372 AND user_identifier NOT LIKE 'Parent or Caregiver'
1373 ");
1374
1375 // Get agent test messages count
1376 $agent_tests = $wpdb->get_var("
1377 SELECT COUNT(DISTINCT session_id)
1378 FROM $table_name
1379 WHERE user_identifier IN (
1380 'Tech-Savvy User',
1381 'Detail-Oriented User',
1382 'Language Learner',
1383 'Casual Browser',
1384 'Policy Enforcer',
1385 'Researcher',
1386 'Loyalty Member',
1387 'Gift Buyer',
1388 'Parent or Caregiver'
1389 )
1390 ");
1391 // Get activity metrics
1392 $today_chats = $wpdb->get_var("
1393 SELECT COUNT(DISTINCT session_id)
1394 FROM $table_name
1395 WHERE DATE(timestamp) = CURDATE()
1396 ");
1397
1398 $week_chats = $wpdb->get_var("
1399 SELECT COUNT(DISTINCT session_id)
1400 FROM $table_name
1401 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1402 ");
1403
1404 $month_chats = $wpdb->get_var("
1405 SELECT COUNT(DISTINCT session_id)
1406 FROM $table_name
1407 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
1408 ");
1409
1410 // Get daily chat data for last 7 days
1411 $daily_stats = $wpdb->get_results("
1412 SELECT
1413 DATE(timestamp) as date,
1414 COUNT(DISTINCT session_id) as chats,
1415 COUNT(*) as messages
1416 FROM $table_name
1417 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1418 GROUP BY DATE(timestamp)
1419 ORDER BY date ASC
1420 ");
1421
1422 // Get average messages per chat
1423 $avg_messages = $wpdb->get_var("
1424 SELECT AVG(message_count)
1425 FROM (
1426 SELECT session_id, COUNT(*) as message_count
1427 FROM $table_name
1428 GROUP BY session_id
1429 ) as chat_counts
1430 ");
1431 $avg_messages = $avg_messages ? round($avg_messages, 1) : 0;
1432
1433 // Get busiest hour
1434 $busiest_hour = $wpdb->get_row("
1435 SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count
1436 FROM $table_name
1437 GROUP BY HOUR(timestamp)
1438 ORDER BY chat_count DESC
1439 LIMIT 1
1440 ");
1441
1442 // Prepare chart data
1443 $chart_labels = array();
1444 $chart_chats = array();
1445 $chart_messages = array();
1446
1447 // Fill last 7 days with data
1448 for ($i = 6; $i >= 0; $i--) {
1449 $date = date('Y-m-d', strtotime("-$i days"));
1450 $day_name = date('D', strtotime("-$i days"));
1451 $chart_labels[] = $day_name;
1452
1453 $found = false;
1454 foreach ($daily_stats as $stat) {
1455 if ($stat->date === $date) {
1456 $chart_chats[] = (int)$stat->chats;
1457 $chart_messages[] = (int)$stat->messages;
1458 $found = true;
1459 break;
1460 }
1461 }
1462 if (!$found) {
1463 $chart_chats[] = 0;
1464 $chart_messages[] = 0;
1465 }
1466 }
1467
1468 // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1469 $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1470
1471 // Prepare page data for the template
1472 $page_data = array(
1473 'total_chats' => $total_chats,
1474 'total_messages' => $total_messages,
1475 'total_users' => $total_users,
1476 'registered_users' => $registered_users,
1477 'guest_users' => $guest_users,
1478 'agent_tests' => $agent_tests,
1479 'today_chats' => $today_chats,
1480 'week_chats' => $week_chats,
1481 'month_chats' => $month_chats,
1482 'avg_messages' => $avg_messages,
1483 'busiest_hour' => $busiest_hour,
1484 'chart_labels' => $chart_labels,
1485 'chart_chats' => $chart_chats,
1486 'chart_messages' => $chart_messages,
1487 'satisfaction_stats' => $satisfaction_stats,
1488 );
1489
1490 // Include and render the new template
1491 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
1492 mxchat_render_transcripts_page($this, $page_data);
1493 }
1494
1495 /**
1496 * Per-bot satisfaction rating rollup over the last $days days. Used by the
1497 * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1498 *
1499 * @param int $days Window in days.
1500 * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1501 */
1502 public function get_satisfaction_rating_stats($days = 30) {
1503 global $wpdb;
1504 $table = $wpdb->prefix . 'mxchat_session_ratings';
1505 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1506 return array();
1507 }
1508 $days = max(1, (int) $days);
1509 $rows = $wpdb->get_results($wpdb->prepare(
1510 "SELECT bot_id,
1511 COUNT(*) AS total,
1512 SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1513 SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1514 FROM {$table}
1515 WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1516 GROUP BY bot_id
1517 ORDER BY total DESC",
1518 $days
1519 ));
1520 $out = array();
1521 foreach ((array) $rows as $row) {
1522 $total = (int) $row->total;
1523 $positive = (int) $row->positive;
1524 $negative = (int) $row->negative;
1525 $out[] = array(
1526 'bot_id' => $row->bot_id ?: 'default',
1527 'total' => $total,
1528 'positive' => $positive,
1529 'negative' => $negative,
1530 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1531 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1532 );
1533 }
1534 return $out;
1535 }
1536
1537 /**
1538 * Get chart data for transcripts page
1539 * Used by both page render and script localization
1540 *
1541 * @return array Chart data with labels, chats, and messages arrays
1542 */
1543 private function get_transcripts_chart_data() {
1544 global $wpdb;
1545 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1546
1547 // Get daily chat data for last 7 days - same query as mxchat_transcripts_page()
1548 $daily_stats = $wpdb->get_results("
1549 SELECT
1550 DATE(timestamp) as date,
1551 COUNT(DISTINCT session_id) as chats,
1552 COUNT(*) as messages
1553 FROM $table_name
1554 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1555 GROUP BY DATE(timestamp)
1556 ORDER BY date ASC
1557 ");
1558
1559 // Prepare chart data
1560 $chart_labels = array();
1561 $chart_chats = array();
1562 $chart_messages = array();
1563
1564 // Fill last 7 days with data - same logic as mxchat_transcripts_page()
1565 for ($i = 6; $i >= 0; $i--) {
1566 $date = date('Y-m-d', strtotime("-$i days"));
1567 $day_name = date('D', strtotime("-$i days"));
1568 $chart_labels[] = $day_name;
1569
1570 $found = false;
1571 if ($daily_stats) {
1572 foreach ($daily_stats as $stat) {
1573 if ($stat->date === $date) {
1574 $chart_chats[] = (int)$stat->chats;
1575 $chart_messages[] = (int)$stat->messages;
1576 $found = true;
1577 break;
1578 }
1579 }
1580 }
1581 if (!$found) {
1582 $chart_chats[] = 0;
1583 $chart_messages[] = 0;
1584 }
1585 }
1586
1587 return array(
1588 'labels' => $chart_labels,
1589 'chats' => $chart_chats,
1590 'messages' => $chart_messages
1591 );
1592 }
1593
1594 public function mxchat_transcripts_notification_section_callback() {
1595 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>';
1596 }
1597 public function mxchat_enable_notifications_callback() {
1598 $options = get_option('mxchat_transcripts_options', array());
1599 $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0;
1600 ?>
1601 <label for="mxchat_enable_notifications">
1602 <input type="checkbox" id="mxchat_enable_notifications"
1603 name="mxchat_transcripts_options[mxchat_enable_notifications]"
1604 value="1" <?php checked(1, $enabled); ?>>
1605 <?php esc_html_e('Send email notification when a new chat session starts', 'mxchat'); ?>
1606 </label>
1607 <p class="description">
1608 <?php esc_html_e('Enable this option to receive email notifications for new chat sessions.', 'mxchat'); ?>
1609 </p>
1610 <?php
1611 }
1612 public function mxchat_notification_email_callback() {
1613 $options = get_option('mxchat_transcripts_options', array());
1614 $email = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : get_option('admin_email');
1615 ?>
1616 <input type="email" id="mxchat_notification_email"
1617 name="mxchat_transcripts_options[mxchat_notification_email]"
1618 value="<?php echo esc_attr($email); ?>"
1619 class="regular-text">
1620 <p class="description">
1621 <?php esc_html_e('Enter the email address where notifications should be sent. Defaults to the admin email address.', 'mxchat'); ?>
1622 </p>
1623 <?php
1624 }
1625
1626 public function mxchat_auto_delete_transcripts_callback() {
1627 $options = get_option('mxchat_transcripts_options', array());
1628 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1629 ?>
1630 <select id="mxchat_auto_delete_transcripts"
1631 name="mxchat_transcripts_options[mxchat_auto_delete_transcripts]">
1632 <option value="never" <?php selected($interval, 'never'); ?>>
1633 <?php esc_html_e('Never (Keep All Transcripts)', 'mxchat'); ?>
1634 </option>
1635 <option value="1week" <?php selected($interval, '1week'); ?>>
1636 <?php esc_html_e('After 1 Week', 'mxchat'); ?>
1637 </option>
1638 <option value="2weeks" <?php selected($interval, '2weeks'); ?>>
1639 <?php esc_html_e('After 2 Weeks', 'mxchat'); ?>
1640 </option>
1641 <option value="1month" <?php selected($interval, '1month'); ?>>
1642 <?php esc_html_e('After 1 Month', 'mxchat'); ?>
1643 </option>
1644 </select>
1645 <p class="description">
1646 <?php esc_html_e('Automatically delete old chat transcripts after the selected time period. This helps manage database size and privacy.', 'mxchat'); ?>
1647 </p>
1648 <?php
1649 }
1650
1651 /**
1652 * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1653 * and deletes transcripts older than the given number of days. Set to 0 to fall
1654 * back to the dropdown (or "Never" if the dropdown is also Never).
1655 *
1656 * Devs can override the final day count via the `mxchat_transcript_retention_days`
1657 * filter — runs in `cleanup_old_transcripts()` after this option is read.
1658 *
1659 * (plan-mxchat-20260509-9b80b1)
1660 */
1661 public function mxchat_retention_days_callback() {
1662 $options = get_option('mxchat_transcripts_options', array());
1663 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1664 ?>
1665 <input type="number"
1666 id="mxchat_retention_days"
1667 name="mxchat_transcripts_options[mxchat_retention_days]"
1668 value="<?php echo esc_attr($days); ?>"
1669 min="0"
1670 max="3650"
1671 step="1"
1672 style="width: 90px;" />
1673 <p class="description">
1674 <?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'); ?>
1675 <br>
1676 <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1677 <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1678 </p>
1679 <?php
1680 }
1681
1682 public function mxchat_auto_email_transcript_callback() {
1683 $options = get_option('mxchat_transcripts_options', array());
1684 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1685 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
1686 $require_contact = isset($options['mxchat_auto_email_transcript_require_contact']) ? $options['mxchat_auto_email_transcript_require_contact'] : 0;
1687 ?>
1688 <label>
1689 <input type="checkbox"
1690 id="mxchat_auto_email_transcript_enabled"
1691 name="mxchat_transcripts_options[mxchat_auto_email_transcript_enabled]"
1692 value="1"
1693 <?php checked($enabled, 1); ?>>
1694 <?php esc_html_e('Enable Auto-Email of Full Transcript', 'mxchat'); ?>
1695 </label>
1696 <br><br>
1697 <label for="mxchat_auto_email_transcript_delay">
1698 <?php esc_html_e('Send transcript after:', 'mxchat'); ?>
1699 </label>
1700 <select id="mxchat_auto_email_transcript_delay"
1701 name="mxchat_transcripts_options[mxchat_auto_email_transcript_delay]">
1702 <option value="15" <?php selected($delay, '15'); ?>>
1703 <?php esc_html_e('15 minutes', 'mxchat'); ?>
1704 </option>
1705 <option value="30" <?php selected($delay, '30'); ?>>
1706 <?php esc_html_e('30 minutes', 'mxchat'); ?>
1707 </option>
1708 <option value="60" <?php selected($delay, '60'); ?>>
1709 <?php esc_html_e('1 hour', 'mxchat'); ?>
1710 </option>
1711 </select>
1712 <br><br>
1713 <label>
1714 <input type="checkbox"
1715 id="mxchat_auto_email_transcript_require_contact"
1716 name="mxchat_transcripts_options[mxchat_auto_email_transcript_require_contact]"
1717 value="1"
1718 <?php checked($require_contact, 1); ?>>
1719 <?php esc_html_e('Only send if visitor provided contact info', 'mxchat'); ?>
1720 </label>
1721 <p class="description">
1722 <?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'); ?>
1723 <br>
1724 <?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'); ?>
1725 </p>
1726 <?php
1727 }
1728
1729
1730
1731 public function sanitize_transcripts_options($input) {
1732 $sanitized = array();
1733
1734 $sanitized['mxchat_enable_notifications'] = isset($input['mxchat_enable_notifications']) ? 1 : 0;
1735
1736 if (isset($input['mxchat_notification_email'])) {
1737 $sanitized['mxchat_notification_email'] = sanitize_email($input['mxchat_notification_email']);
1738 if (!is_email($sanitized['mxchat_notification_email'])) {
1739 add_settings_error(
1740 'mxchat_transcripts_options',
1741 'invalid_email',
1742 __('Please enter a valid email address for notifications.', 'mxchat'),
1743 'error'
1744 );
1745 $sanitized['mxchat_notification_email'] = get_option('admin_email');
1746 }
1747 }
1748
1749 // Sanitize auto-delete setting
1750 $valid_intervals = array('never', '1week', '2weeks', '1month');
1751 if (isset($input['mxchat_auto_delete_transcripts'])) {
1752 $sanitized['mxchat_auto_delete_transcripts'] = in_array($input['mxchat_auto_delete_transcripts'], $valid_intervals)
1753 ? $input['mxchat_auto_delete_transcripts']
1754 : 'never';
1755 } else {
1756 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1757 }
1758
1759 // Sanitize custom retention-days override (plan-9b80b1).
1760 if (isset($input['mxchat_retention_days'])) {
1761 $days = (int) $input['mxchat_retention_days'];
1762 $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1763 } else {
1764 $sanitized['mxchat_retention_days'] = 0;
1765 }
1766
1767 // Get old values to check if auto-delete or retention-days changed.
1768 $old_options = get_option('mxchat_transcripts_options');
1769 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1770 $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1771
1772 // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1773 // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1774 // reason to keep the daily cron registered.
1775 $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1776 $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1777 if ($interval_changed || $retention_changed) {
1778 $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1779 $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1780 }
1781
1782 // Sanitize auto-email transcript settings
1783 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
1784
1785 $valid_delays = array('15', '30', '60');
1786 if (isset($input['mxchat_auto_email_transcript_delay'])) {
1787 $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays)
1788 ? $input['mxchat_auto_email_transcript_delay']
1789 : '30';
1790 } else {
1791 $sanitized['mxchat_auto_email_transcript_delay'] = '30';
1792 }
1793
1794 // Sanitize require contact info setting
1795 $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0;
1796
1797 return $sanitized;
1798 }
1799
1800 /**
1801 * Schedule or unschedule the transcript cleanup cron job
1802 */
1803 public function schedule_transcript_cleanup($interval) {
1804 // Clear any existing scheduled event
1805 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1806 if ($timestamp) {
1807 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1808 }
1809
1810 // Schedule new event whenever retention is active. "active" is the canonical
1811 // value passed by sanitize_transcripts_options when either the dropdown != never
1812 // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1813 // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1814 if ($interval !== 'never') {
1815 // Schedule to run daily at 3 AM
1816 $next_run = strtotime('tomorrow 3:00 AM');
1817 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
1818 }
1819 }
1820
1821 /**
1822 * Self-heal guard: keep the cleanup cron in sync with the retention setting.
1823 *
1824 * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and
1825 * one wp_next_scheduled() (reads the cached cron option) — no writes. It only
1826 * schedules when retention is active but the event is missing, and only
1827 * unschedules when retention is off but the event survived. Both directions
1828 * reuse schedule_transcript_cleanup() so there is exactly one scheduling path.
1829 * Legacy dropdown values (1week/2weeks/1month) count as active, matching the
1830 * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling
1831 * writes the cron option regardless of how cron is executed, so a server-side
1832 * cron runner still picks the event up.
1833 */
1834 public function ensure_transcript_cleanup_scheduled() {
1835 $options = get_option('mxchat_transcripts_options', array());
1836 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1837 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1838 $active = ($interval !== 'never') || ($days > 0);
1839 $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts');
1840
1841 if ($active && !$scheduled) {
1842 $this->schedule_transcript_cleanup('active');
1843 } elseif (!$active && $scheduled) {
1844 $this->schedule_transcript_cleanup('never');
1845 }
1846 }
1847
1848 /**
1849 * Delete old transcripts based on the configured interval
1850 */
1851 public function cleanup_old_transcripts() {
1852 $options = get_option('mxchat_transcripts_options', array());
1853 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1854 $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1855
1856 // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
1857 $days = 0;
1858 if ($custom_days > 0) {
1859 $days = $custom_days;
1860 } else {
1861 switch ($interval) {
1862 case '1week': $days = 7; break;
1863 case '2weeks': $days = 14; break;
1864 case '1month': $days = 30; break;
1865 case 'never':
1866 default:
1867 $days = 0;
1868 }
1869 }
1870
1871 // Devs can override the final day count programmatically.
1872 $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
1873
1874 if ($days <= 0) {
1875 return; // Retention disabled — bail.
1876 }
1877
1878 global $wpdb;
1879 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
1880 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
1881 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
1882
1883 // Defensive: if the main table doesn't exist (fresh-ish install), bail.
1884 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
1885 return;
1886 }
1887
1888 $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
1889
1890 // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
1891 // the cron will pick up where it left off on the next tick (within hours).
1892 $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
1893
1894 $sessions_to_delete = $wpdb->get_col(
1895 $wpdb->prepare(
1896 "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
1897 $cutoff_date,
1898 $batch_cap
1899 )
1900 );
1901
1902 if (empty($sessions_to_delete)) {
1903 return;
1904 }
1905
1906 $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
1907
1908 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1909 // $placeholders is a server-built list of literal "%s" tokens.
1910 $deleted_transcripts = (int) $wpdb->query(
1911 $wpdb->prepare(
1912 "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
1913 $sessions_to_delete
1914 )
1915 );
1916
1917 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
1918 $wpdb->query(
1919 $wpdb->prepare(
1920 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
1921 $sessions_to_delete
1922 )
1923 );
1924 }
1925
1926 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
1927 $wpdb->query(
1928 $wpdb->prepare(
1929 "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
1930 $sessions_to_delete
1931 )
1932 );
1933 }
1934 // phpcs:enable
1935
1936 update_option('mxchat_retention_last_swept_at', time(), false);
1937 update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
1938 }
1939
1940 public function export_chat_transcripts() {
1941 if (!current_user_can('manage_options')) {
1942 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
1943 }
1944
1945 check_ajax_referer('mxchat_export_transcripts', 'security');
1946
1947 global $wpdb;
1948 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1949
1950 // Get all transcripts ordered by session and timestamp
1951 $results = $wpdb->get_results(
1952 "SELECT session_id, user_email, user_identifier, role, message, timestamp
1953 FROM {$table_name}
1954 ORDER BY session_id, timestamp ASC"
1955 );
1956
1957 if (empty($results)) {
1958 wp_send_json_error(array('message' => 'No transcripts found.'));
1959 wp_die();
1960 }
1961
1962 // Set headers for CSV download
1963 header('Content-Type: text/csv');
1964 header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"');
1965 header('Pragma: no-cache');
1966 header('Expires: 0');
1967
1968 // Create output stream
1969 $output = fopen('php://output', 'w');
1970
1971 // Add UTF-8 BOM for proper Excel encoding
1972 fputs($output, "\xEF\xBB\xBF");
1973
1974 // Add CSV headers
1975 fputcsv($output, array(
1976 'Session ID',
1977 'Email',
1978 'User Identifier',
1979 'Role',
1980 'Message',
1981 'Timestamp'
1982 ));
1983
1984 // Add data rows
1985 foreach ($results as $row) {
1986 fputcsv($output, array(
1987 $row->session_id,
1988 $row->user_email,
1989 $row->user_identifier,
1990 $row->role,
1991 $row->message,
1992 $row->timestamp
1993 ));
1994 }
1995
1996 fclose($output);
1997 wp_die();
1998 }
1999
2000 // ============================================================================
2001 // Leads tab (inside Transcripts)
2002 //
2003 // Leads are derived from existing data — no dedicated table. Primary source:
2004 // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
2005 // source: wp_options entries `mxchat_email_{session_id}` / `mxchat_name_{sid}`
2006 // for "orphan" leads who submitted the pre-chat form but never chatted.
2007 // ============================================================================
2008
2009 /**
2010 * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
2011 */
2012 public function mxchat_fetch_leads() {
2013 if (!current_user_can('manage_options')) {
2014 wp_send_json_error(['message' => 'Insufficient permissions']);
2015 wp_die();
2016 }
2017
2018 global $wpdb;
2019 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2020
2021 $page = isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1;
2022 $per_page = isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25;
2023 $offset = ($page - 1) * $per_page;
2024 $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
2025 $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
2026 $status = isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all';
2027 $page_filter = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
2028 $sort = isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen';
2029 $sort_dir = (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
2030
2031 $date_cutoff = self::mxchat_leads_date_cutoff($date_range);
2032 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2033
2034 // Base WHERE for transcripts leads.
2035 $where_clauses = ["user_email IS NOT NULL", "user_email != ''"];
2036 $where_params = [];
2037
2038 if ($date_cutoff) {
2039 $where_clauses[] = 'timestamp >= %s';
2040 $where_params[] = $date_cutoff;
2041 }
2042 if ($page_filter && $has_page_url_column) {
2043 $where_clauses[] = 'originating_page_url = %s';
2044 $where_params[] = $page_filter;
2045 }
2046 if ($search !== '') {
2047 $like = '%' . $wpdb->esc_like($search) . '%';
2048 $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
2049 $where_params[] = $like;
2050 $where_params[] = $like;
2051 }
2052 $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
2053
2054 // Aggregate query grouped by email.
2055 $select_sql = $has_page_url_column
2056 ? "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2057 COUNT(DISTINCT session_id) AS conversation_count"
2058 : "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2059 COUNT(DISTINCT session_id) AS conversation_count";
2060
2061 $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
2062 $group_order_limit = " GROUP BY user_email ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
2063
2064 $transcripts_sql = $wpdb->prepare(
2065 "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
2066 array_merge($where_params, [$per_page, $offset])
2067 );
2068 $transcript_rows = $wpdb->get_results($transcripts_sql);
2069
2070 // Count of unique transcript-based leads under the same filters.
2071 $count_sql = $wpdb->prepare(
2072 "SELECT COUNT(DISTINCT user_email) FROM {$table} {$where_sql}",
2073 $where_params
2074 );
2075 $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
2076
2077 // Hydrate each row: name, latest_session_id, top page.
2078 $leads = [];
2079 foreach ($transcript_rows as $row) {
2080 $detail = $has_page_url_column
2081 ? $wpdb->get_row($wpdb->prepare(
2082 "SELECT session_id, user_name, originating_page_url, originating_page_title
2083 FROM {$table} WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2084 $row->user_email
2085 ))
2086 : $wpdb->get_row($wpdb->prepare(
2087 "SELECT session_id, user_name FROM {$table}
2088 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2089 $row->user_email
2090 ));
2091
2092 $leads[] = [
2093 'email' => $row->user_email,
2094 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2095 'conversation_count' => (int) $row->conversation_count,
2096 'last_seen' => $row->last_seen,
2097 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
2098 'first_seen' => $row->first_seen,
2099 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
2100 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2101 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
2102 'is_orphan' => false,
2103 'status' => 'active',
2104 ];
2105 }
2106
2107 // Non-transcript lead sources. Built once here, then filtered/merged based on the
2108 // status filter below. Deduplication priority when the same email appears in multiple
2109 // sources: transcripts > chat_deleted > orphan.
2110 $transcripts_emails_seen = array_flip(array_map(
2111 function ($r) { return strtolower($r['email']); },
2112 $leads
2113 ));
2114
2115 // Chat-deleted leads: had a conversation that an admin removed. Preserved via
2116 // mxchat_lead_del_* options, with timestamps so they respect date filters.
2117 $chat_deleted_leads_all = [];
2118 if ($status === 'all' || $status === 'chat_deleted') {
2119 $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
2120 // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
2121 $chat_deleted_leads_all = array_values(array_filter(
2122 $chat_deleted_leads_all,
2123 function ($row) use ($transcripts_emails_seen) {
2124 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2125 }
2126 ));
2127 foreach ($chat_deleted_leads_all as $row) {
2128 $transcripts_emails_seen[strtolower($row['email'])] = true;
2129 }
2130 }
2131
2132 // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2133 // when a date filter is active.
2134 $orphan_leads_all = [];
2135 if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2136 $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2137 $orphan_leads_all = array_values(array_filter(
2138 $orphan_leads_all,
2139 function ($row) use ($transcripts_emails_seen) {
2140 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2141 }
2142 ));
2143 }
2144
2145 // Apply status filter to the transcripts-derived list.
2146 if ($status === 'orphan' || $status === 'chat_deleted') {
2147 $leads = [];
2148 $transcripts_lead_count = 0;
2149 }
2150
2151 // Stitch the current page from the three buckets in priority order.
2152 $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2153 $remaining_slots = $per_page - count($leads);
2154
2155 if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2156 $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2157 if ($start < count($chat_deleted_leads_all)) {
2158 $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2159 $remaining_slots = $per_page - count($leads);
2160 }
2161 }
2162
2163 if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2164 $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2165 $start = max(0, ($page - 1) * $per_page - $before);
2166 if ($start < count($orphan_leads_all)) {
2167 $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2168 }
2169 }
2170
2171 $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2172
2173 // Stats strip: always computed over full dataset, unaffected by filters.
2174 $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2175
2176 // Top pages: top 5 by distinct emails captured.
2177 $top_pages = [];
2178 if ($has_page_url_column) {
2179 $top_pages_rows = $wpdb->get_results(
2180 "SELECT originating_page_url AS url,
2181 MAX(originating_page_title) AS title,
2182 COUNT(DISTINCT user_email) AS lead_count
2183 FROM {$table}
2184 WHERE user_email IS NOT NULL AND user_email != ''
2185 AND originating_page_url IS NOT NULL AND originating_page_url != ''
2186 GROUP BY originating_page_url
2187 ORDER BY lead_count DESC, url ASC
2188 LIMIT 5"
2189 );
2190 foreach ($top_pages_rows as $p) {
2191 $top_pages[] = [
2192 'url' => $p->url,
2193 'title' => $p->title ?: $p->url,
2194 'lead_count' => (int) $p->lead_count,
2195 ];
2196 }
2197 }
2198
2199 wp_send_json([
2200 'success' => true,
2201 'leads' => $leads,
2202 'page' => $page,
2203 'per_page' => $per_page,
2204 'total_count' => $total_count,
2205 'total_pages' => $total_pages,
2206 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2207 'showing_end' => min($offset + $per_page, $total_count),
2208 'stats' => $stats,
2209 'top_pages' => $top_pages,
2210 ]);
2211 wp_die();
2212 }
2213
2214 /**
2215 * Delete one or more leads by email. Removes every transcripts row for that
2216 * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2217 * mxchat_history_{sid}) and any orphan option entries matching the email.
2218 */
2219 public function mxchat_delete_leads() {
2220 if (!current_user_can('manage_options')) {
2221 wp_send_json_error(['message' => 'Insufficient permissions']);
2222 wp_die();
2223 }
2224 check_ajax_referer('mxchat_delete_leads', 'security');
2225
2226 $emails_raw = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2227 $emails = [];
2228 foreach ($emails_raw as $e) {
2229 $clean = sanitize_email((string) $e);
2230 if ($clean) {
2231 $emails[] = $clean;
2232 }
2233 }
2234 if (empty($emails)) {
2235 wp_send_json_error(['message' => 'No emails provided']);
2236 wp_die();
2237 }
2238
2239 $summary = self::mxchat_wipe_leads_by_email($emails);
2240
2241 wp_send_json([
2242 'success' => true,
2243 'deleted_leads' => count($emails),
2244 'deleted_sessions' => $summary['deleted_sessions'],
2245 'deleted_rows' => $summary['deleted_rows'],
2246 ]);
2247 wp_die();
2248 }
2249
2250 /**
2251 * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2252 * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2253 *
2254 * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2255 * Input emails must already be sanitized with sanitize_email().
2256 */
2257 private static function mxchat_wipe_leads_by_email(array $emails) {
2258 global $wpdb;
2259 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2260 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2261 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2262
2263 $deleted_sessions = 0;
2264 $deleted_rows = 0;
2265 $emails_lc = array_map('strtolower', $emails);
2266
2267 foreach ($emails as $email) {
2268 $session_ids = $wpdb->get_col($wpdb->prepare(
2269 "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2270 $email
2271 ));
2272
2273 $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2274 if ($rows_removed !== false) {
2275 $deleted_rows += (int) $rows_removed;
2276 }
2277
2278 foreach ($session_ids as $sid) {
2279 $deleted_sessions++;
2280 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2281 delete_option('mxchat_history_' . $sid);
2282 delete_option('mxchat_email_' . $sid);
2283 delete_option('mxchat_name_' . $sid);
2284 delete_option('mxchat_agent_name_' . $sid);
2285 delete_option('mxchat_lead_del_email_' . $sid);
2286 delete_option('mxchat_lead_del_name_' . $sid);
2287 delete_option('mxchat_lead_del_ts_' . $sid);
2288 if (class_exists('MxChat_Session_Store')) {
2289 MxChat_Session_Store::delete_session($sid); // b64b77
2290 }
2291 if ($has_translations) {
2292 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2293 }
2294 }
2295 }
2296
2297 // Clean up any lingering option entries (orphan pre-chat captures + chat_deleted
2298 // preservations) whose stored value matches one of the emails being wiped.
2299 $lingering = $wpdb->get_results(
2300 "SELECT option_name, option_value FROM {$wpdb->options}
2301 WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2302 );
2303 foreach ($lingering as $opt) {
2304 if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2305 continue;
2306 }
2307 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2308 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2309 delete_option('mxchat_lead_del_email_' . $sid);
2310 delete_option('mxchat_lead_del_name_' . $sid);
2311 delete_option('mxchat_lead_del_ts_' . $sid);
2312 } else {
2313 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2314 delete_option('mxchat_email_' . $sid);
2315 delete_option('mxchat_name_' . $sid);
2316 }
2317 }
2318
2319 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2320
2321 return [
2322 'deleted_sessions' => $deleted_sessions,
2323 'deleted_rows' => $deleted_rows,
2324 ];
2325 }
2326
2327 /**
2328 * Stream a leads CSV. scope=all exports every lead under current filters is not
2329 * supported to keep semantics simple; caller either exports all leads or a
2330 * specific set of selected emails.
2331 */
2332 public function mxchat_export_leads() {
2333 if (!current_user_can('manage_options')) {
2334 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2335 }
2336 check_ajax_referer('mxchat_export_leads', 'security');
2337
2338 $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2339 $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2340 $emails_in = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2341
2342 $emails_in_clean = [];
2343 foreach ($emails_in as $e) {
2344 $clean = sanitize_email((string) $e);
2345 if ($clean) {
2346 $emails_in_clean[] = $clean;
2347 }
2348 }
2349
2350 global $wpdb;
2351 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2352 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2353
2354 // Collect leads from transcripts.
2355 $transcripts_sql = "SELECT user_email AS email,
2356 MAX(timestamp) AS last_seen,
2357 COUNT(DISTINCT session_id) AS conversation_count
2358 FROM {$table}
2359 WHERE user_email IS NOT NULL AND user_email != ''";
2360 $params = [];
2361 if ($scope === 'selected' && !empty($emails_in_clean)) {
2362 $placeholders = implode(',', array_fill(0, count($emails_in_clean), '%s'));
2363 $transcripts_sql .= " AND user_email IN ({$placeholders})";
2364 $params = $emails_in_clean;
2365 }
2366 $transcripts_sql .= " GROUP BY user_email ORDER BY last_seen DESC";
2367
2368 $rows = !empty($params)
2369 ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2370 : $wpdb->get_results($transcripts_sql);
2371
2372 // Hydrate each row with name + top page.
2373 $export_rows = [];
2374 foreach ($rows as $row) {
2375 $detail = $has_page_url_column
2376 ? $wpdb->get_row($wpdb->prepare(
2377 "SELECT user_name, originating_page_url FROM {$table}
2378 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2379 $row->email
2380 ))
2381 : $wpdb->get_row($wpdb->prepare(
2382 "SELECT user_name FROM {$table}
2383 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2384 $row->email
2385 ));
2386 $export_rows[] = [
2387 'email' => $row->email,
2388 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2389 'conversation_count' => (int) $row->conversation_count,
2390 'last_seen' => $row->last_seen,
2391 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2392 ];
2393 }
2394
2395 // Include orphan + chat_deleted leads when exporting all.
2396 if ($scope === 'all') {
2397 $transcripts_emails_lc = array_flip(array_map(
2398 function ($r) { return strtolower($r['email']); },
2399 $export_rows
2400 ));
2401 foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2402 if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2403 $transcripts_emails_lc[strtolower($cd['email'])] = true;
2404 $export_rows[] = [
2405 'email' => $cd['email'],
2406 'name' => $cd['name'],
2407 'conversation_count' => 0,
2408 'last_seen' => $cd['last_seen'],
2409 'top_page_url' => '',
2410 ];
2411 }
2412 foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2413 if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2414 $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2415 $export_rows[] = [
2416 'email' => $orphan['email'],
2417 'name' => $orphan['name'],
2418 'conversation_count' => 0,
2419 'last_seen' => '',
2420 'top_page_url' => '',
2421 ];
2422 }
2423 }
2424
2425 if (empty($export_rows)) {
2426 wp_send_json_error(['message' => 'No leads to export.']);
2427 wp_die();
2428 }
2429
2430 $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2431 header('Content-Type: text/csv');
2432 header('Content-Disposition: attachment; filename="' . $filename . '"');
2433 header('Pragma: no-cache');
2434 header('Expires: 0');
2435
2436 $output = fopen('php://output', 'w');
2437 fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2438
2439 if ($fields_mode === 'email_only') {
2440 fputcsv($output, ['Email']);
2441 foreach ($export_rows as $r) {
2442 fputcsv($output, [$r['email']]);
2443 }
2444 } else {
2445 fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page']);
2446 foreach ($export_rows as $r) {
2447 fputcsv($output, [
2448 $r['email'],
2449 $r['name'],
2450 $r['conversation_count'],
2451 $r['last_seen'],
2452 $r['top_page_url'],
2453 ]);
2454 }
2455 }
2456
2457 fclose($output);
2458 wp_die();
2459 }
2460
2461 /**
2462 * Stats strip payload (independent of filters).
2463 */
2464 private static function mxchat_leads_stats($table, $has_page_url_column) {
2465 global $wpdb;
2466
2467 $total_transcripts_emails = (int) $wpdb->get_var(
2468 "SELECT COUNT(DISTINCT user_email) FROM {$table}
2469 WHERE user_email IS NOT NULL AND user_email != ''"
2470 );
2471
2472 $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2473 "SELECT COUNT(*) FROM (
2474 SELECT user_email FROM {$table}
2475 WHERE user_email IS NOT NULL AND user_email != ''
2476 GROUP BY user_email
2477 HAVING MIN(timestamp) >= %s
2478 ) AS new_leads",
2479 gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2480 ));
2481
2482 $total_convos = (int) $wpdb->get_var(
2483 "SELECT COUNT(DISTINCT session_id) FROM {$table}
2484 WHERE user_email IS NOT NULL AND user_email != ''"
2485 );
2486
2487 $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2488 $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2489
2490 // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2491 // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2492 $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2493
2494 $avg = $total_transcripts_emails > 0
2495 ? round($total_convos / $total_transcripts_emails, 1)
2496 : 0;
2497
2498 // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2499 // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2500 // inflate this number.
2501 $orphan_pct = $total_leads > 0
2502 ? (int) round(($orphan_count / $total_leads) * 100)
2503 : 0;
2504
2505 return [
2506 'total_leads' => $total_leads,
2507 'new_this_week' => $new_this_week,
2508 'avg_convos' => $avg,
2509 'orphan_pct' => $orphan_pct,
2510 'orphan_count' => $orphan_count,
2511 'chat_deleted_count' => $chat_deleted_count,
2512 ];
2513 }
2514
2515 /**
2516 * Collect leads who had a conversation that an admin later deleted (preserved via
2517 * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2518 * original last-seen timestamp so they still sort and filter sensibly.
2519 *
2520 * @param string $search Optional email/name substring filter.
2521 * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2522 * @return array
2523 */
2524 private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2525 global $wpdb;
2526 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2527
2528 $rows = $wpdb->get_results(
2529 "SELECT option_name, option_value FROM {$wpdb->options}
2530 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2531 );
2532 if (empty($rows)) {
2533 return [];
2534 }
2535
2536 // Emails that currently have transcripts rows should not appear as chat_deleted —
2537 // they've come back and chatted, so they're active leads again.
2538 $emails_in_transcripts = array_map(
2539 'strtolower',
2540 (array) $wpdb->get_col(
2541 "SELECT DISTINCT user_email FROM {$table}
2542 WHERE user_email IS NOT NULL AND user_email != ''"
2543 )
2544 );
2545 $emails_in_transcripts = array_flip($emails_in_transcripts);
2546
2547 $needle = strtolower(trim((string) $search));
2548 $by_email = [];
2549
2550 foreach ($rows as $opt) {
2551 $email = sanitize_email(trim((string) $opt->option_value));
2552 if (!$email) {
2553 continue;
2554 }
2555 if (isset($emails_in_transcripts[strtolower($email)])) {
2556 continue;
2557 }
2558 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2559 if (!$sid) {
2560 continue;
2561 }
2562 $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2563 $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2564
2565 if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2566 continue;
2567 }
2568 if ($needle !== '') {
2569 $hay = strtolower($email . ' ' . $name);
2570 if (strpos($hay, $needle) === false) {
2571 continue;
2572 }
2573 }
2574
2575 $key = strtolower($email);
2576 if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2577 $by_email[$key] = [
2578 'email' => $email,
2579 'name' => $name,
2580 'conversation_count' => 0,
2581 'last_seen' => $ts,
2582 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2583 'first_seen' => $ts,
2584 'latest_session_id' => '',
2585 'top_page_url' => '',
2586 'top_page_title' => '',
2587 'is_orphan' => false,
2588 'status' => 'chat_deleted',
2589 ];
2590 }
2591 }
2592
2593 // Newest chat_deleted first.
2594 usort($by_email, function ($a, $b) {
2595 return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2596 });
2597 return array_values($by_email);
2598 }
2599
2600 /**
2601 * Count unique emails preserved as "chat deleted" (for the stats strip).
2602 */
2603 private static function mxchat_count_chat_deleted_leads() {
2604 global $wpdb;
2605 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2606
2607 $emails = $wpdb->get_col(
2608 "SELECT DISTINCT option_value FROM {$wpdb->options}
2609 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2610 );
2611 if (empty($emails)) {
2612 return 0;
2613 }
2614
2615 $transcripts_emails = array_map(
2616 'strtolower',
2617 (array) $wpdb->get_col(
2618 "SELECT DISTINCT user_email FROM {$table}
2619 WHERE user_email IS NOT NULL AND user_email != ''"
2620 )
2621 );
2622 $transcripts_emails = array_flip($transcripts_emails);
2623
2624 $count = 0;
2625 $seen = [];
2626 foreach ($emails as $raw) {
2627 $email = strtolower(trim((string) $raw));
2628 if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2629 continue;
2630 }
2631 $seen[$email] = true;
2632 $count++;
2633 }
2634 return $count;
2635 }
2636
2637 /**
2638 * Find leads who submitted the pre-chat form but never produced a transcripts row.
2639 * Returned rows have no conversation_count, no timestamp.
2640 *
2641 * @param string $search Optional email/name substring filter.
2642 * @return array
2643 */
2644 private static function mxchat_collect_orphan_leads($search = '') {
2645 global $wpdb;
2646 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2647
2648 $option_rows = $wpdb->get_results(
2649 "SELECT option_name, option_value FROM {$wpdb->options}
2650 WHERE option_name LIKE 'mxchat_email_%'"
2651 );
2652 if (empty($option_rows)) {
2653 return [];
2654 }
2655
2656 // Collect all session_ids that have real transcripts rows so we can exclude them.
2657 $session_ids_with_rows = $wpdb->get_col(
2658 "SELECT DISTINCT session_id FROM {$table}
2659 WHERE user_email IS NOT NULL AND user_email != ''"
2660 );
2661 $session_ids_with_rows = array_flip($session_ids_with_rows);
2662
2663 // Seen emails in transcripts (so orphans only include truly never-chatted leads).
2664 $emails_in_transcripts = array_map(
2665 'strtolower',
2666 (array) $wpdb->get_col(
2667 "SELECT DISTINCT user_email FROM {$table}
2668 WHERE user_email IS NOT NULL AND user_email != ''"
2669 )
2670 );
2671 $emails_in_transcripts = array_flip($emails_in_transcripts);
2672
2673 $orphans_by_email = [];
2674 $needle = strtolower(trim((string) $search));
2675
2676 foreach ($option_rows as $opt) {
2677 $email = sanitize_email(trim((string) $opt->option_value));
2678 if (!$email) {
2679 continue;
2680 }
2681 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2682 if (!$sid) {
2683 continue;
2684 }
2685 // Exclude leads who have any transcripts rows (they appear in the main list).
2686 if (isset($emails_in_transcripts[strtolower($email)])) {
2687 continue;
2688 }
2689 if (isset($session_ids_with_rows[$sid])) {
2690 continue;
2691 }
2692
2693 $name_option = get_option('mxchat_name_' . $sid, '');
2694 $name = is_string($name_option) ? trim($name_option) : '';
2695
2696 if ($needle !== '') {
2697 $hay = strtolower($email . ' ' . $name);
2698 if (strpos($hay, $needle) === false) {
2699 continue;
2700 }
2701 }
2702
2703 $key = strtolower($email);
2704 if (!isset($orphans_by_email[$key])) {
2705 $orphans_by_email[$key] = [
2706 'email' => $email,
2707 'name' => $name,
2708 'conversation_count' => 0,
2709 'last_seen' => '',
2710 'last_seen_display' => __('No conversation yet', 'mxchat'),
2711 'first_seen' => '',
2712 'latest_session_id' => '',
2713 'top_page_url' => '',
2714 'top_page_title' => '',
2715 'is_orphan' => true,
2716 'status' => 'orphan',
2717 ];
2718 }
2719 }
2720
2721 return array_values($orphans_by_email);
2722 }
2723
2724 /**
2725 * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
2726 */
2727 private static function mxchat_leads_date_cutoff($date_range) {
2728 switch ($date_range) {
2729 case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
2730 case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
2731 case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
2732 case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
2733 case 'all':
2734 default: return '';
2735 }
2736 }
2737
2738 /**
2739 * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
2740 */
2741 private static function mxchat_leads_format_relative($timestamp) {
2742 if (!$timestamp) {
2743 return '';
2744 }
2745 $ts = strtotime($timestamp . ' UTC');
2746 if (!$ts) {
2747 return '';
2748 }
2749 $diff = time() - $ts;
2750 if ($diff < 60) return __('just now', 'mxchat');
2751 if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
2752 if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
2753 if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
2754 return wp_date('M j', $ts);
2755 }
2756
2757 /**
2758 * Handle translation of chat messages via AJAX
2759 */
2760 public function mxchat_translate_messages() {
2761 if (!current_user_can('manage_options')) {
2762 wp_send_json_error(['error' => 'Insufficient permissions']);
2763 wp_die();
2764 }
2765
2766 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
2767 $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en';
2768 $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]';
2769 $messages = json_decode($messages_json, true);
2770
2771 if (empty($session_id)) {
2772 wp_send_json_error(['error' => 'No session ID provided']);
2773 wp_die();
2774 }
2775
2776 if (empty($messages) || !is_array($messages)) {
2777 wp_send_json_error(['error' => 'No messages to translate']);
2778 wp_die();
2779 }
2780
2781 // Language names for prompting
2782 $languages = [
2783 'en' => 'English',
2784 'es' => 'Spanish',
2785 'fr' => 'French',
2786 'de' => 'German',
2787 'it' => 'Italian',
2788 'pt' => 'Portuguese',
2789 'nl' => 'Dutch',
2790 'ru' => 'Russian',
2791 'zh' => 'Chinese',
2792 'ja' => 'Japanese',
2793 'ko' => 'Korean',
2794 'ar' => 'Arabic',
2795 'hi' => 'Hindi',
2796 'tr' => 'Turkish',
2797 'pl' => 'Polish',
2798 'vi' => 'Vietnamese',
2799 'th' => 'Thai',
2800 'id' => 'Indonesian',
2801 'sv' => 'Swedish',
2802 'da' => 'Danish'
2803 ];
2804
2805 $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English';
2806
2807 // Build combined text for translation (numbered for parsing)
2808 $numbered_messages = [];
2809 foreach ($messages as $i => $msg) {
2810 $content = isset($msg['content']) ? trim($msg['content']) : '';
2811 if (!empty($content)) {
2812 $numbered_messages[] = "[MSG" . $i . "]" . $content . "[/MSG" . $i . "]";
2813 }
2814 }
2815
2816 if (empty($numbered_messages)) {
2817 wp_send_json_error(['error' => 'No valid messages to translate']);
2818 wp_die();
2819 }
2820
2821 $combined_text = implode("\n\n", $numbered_messages);
2822
2823 // Prepare the translation prompt
2824 $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.";
2825
2826 // Get user's selected model and determine provider
2827 $options = get_option('mxchat_options', []);
2828 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
2829
2830 // Check if using OpenRouter
2831 if ($selected_model === 'openrouter') {
2832 $provider = 'openrouter';
2833 $selected_model = $options['openrouter_selected_model'] ?? '';
2834 $api_key = $options['openrouter_api_key'] ?? '';
2835
2836 if (empty($selected_model)) {
2837 wp_send_json_error(['error' => 'No OpenRouter model selected']);
2838 wp_die();
2839 }
2840 } else {
2841 // Determine provider from model name
2842 $model_parts = explode('-', $selected_model);
2843 $provider = strtolower($model_parts[0]);
2844
2845 // Get the appropriate API key based on provider
2846 $api_key = '';
2847 switch ($provider) {
2848 case 'gpt':
2849 case 'o1':
2850 $api_key = $options['api_key'] ?? '';
2851 break;
2852 case 'claude':
2853 $api_key = $options['claude_api_key'] ?? '';
2854 break;
2855 case 'grok':
2856 $api_key = $options['xai_api_key'] ?? '';
2857 break;
2858 case 'deepseek':
2859 $api_key = $options['deepseek_api_key'] ?? '';
2860 break;
2861 case 'gemini':
2862 $api_key = $options['gemini_api_key'] ?? '';
2863 break;
2864 default:
2865 // Default to OpenAI
2866 $api_key = $options['api_key'] ?? '';
2867 $provider = 'gpt';
2868 break;
2869 }
2870 }
2871
2872 if (empty($api_key)) {
2873 wp_send_json_error(['error' => 'No API key configured for ' . $provider]);
2874 wp_die();
2875 }
2876
2877 // Make API request based on provider
2878 $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text);
2879
2880 if (is_wp_error($response)) {
2881 wp_send_json_error(['error' => $response->get_error_message()]);
2882 wp_die();
2883 }
2884
2885 // Parse the response to extract translated messages
2886 $translations = [];
2887 foreach ($messages as $msg) {
2888 $index = $msg['index'];
2889 $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s';
2890 if (preg_match($pattern, $response, $matches)) {
2891 $translations[] = [
2892 'index' => $index,
2893 'translated' => trim($matches[1])
2894 ];
2895 }
2896 }
2897
2898 // Save translations to database
2899 if (!empty($translations)) {
2900 $this->save_transcript_translation($session_id, $target_lang, $translations);
2901 }
2902
2903 wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]);
2904 wp_die();
2905 }
2906
2907 /**
2908 * Save transcript translation to database
2909 */
2910 private function save_transcript_translation($session_id, $language_code, $translations) {
2911 global $wpdb;
2912 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
2913
2914 // Check if table exists, create if not
2915 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
2916 mxchat_create_translations_table();
2917 }
2918
2919 $now = current_time('mysql');
2920 $translations_json = wp_json_encode($translations);
2921
2922 // Use REPLACE to insert or update
2923 $wpdb->query($wpdb->prepare(
2924 "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at)
2925 VALUES (%s, %s, %s, %s, %s)",
2926 $session_id,
2927 $language_code,
2928 $translations_json,
2929 $now,
2930 $now
2931 ));
2932 }
2933
2934 /**
2935 * Get saved translation for a session
2936 */
2937 public function mxchat_get_transcript_translation() {
2938 if (!current_user_can('manage_options')) {
2939 wp_send_json_error(['error' => 'Insufficient permissions']);
2940 wp_die();
2941 }
2942
2943 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
2944
2945 if (empty($session_id)) {
2946 wp_send_json_error(['error' => 'No session ID provided']);
2947 wp_die();
2948 }
2949
2950 global $wpdb;
2951 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
2952
2953 // Check if table exists
2954 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
2955 wp_send_json(['success' => true, 'has_translation' => false]);
2956 wp_die();
2957 }
2958
2959 // Get the most recent translation for this session
2960 $result = $wpdb->get_row($wpdb->prepare(
2961 "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1",
2962 $session_id
2963 ));
2964
2965 if ($result) {
2966 $translations = json_decode($result->translations, true);
2967 wp_send_json([
2968 'success' => true,
2969 'has_translation' => true,
2970 'language' => $result->language_code,
2971 'translations' => $translations
2972 ]);
2973 } else {
2974 wp_send_json(['success' => true, 'has_translation' => false]);
2975 }
2976 wp_die();
2977 }
2978
2979 /**
2980 * Translate text using the user's selected provider and model
2981 */
2982 private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) {
2983 switch ($provider) {
2984 case 'claude':
2985 return $this->translate_with_claude($api_key, $model, $system_prompt, $text);
2986 case 'grok':
2987 return $this->translate_with_xai($api_key, $model, $system_prompt, $text);
2988 case 'deepseek':
2989 return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text);
2990 case 'gemini':
2991 return $this->translate_with_gemini($api_key, $model, $system_prompt, $text);
2992 case 'openrouter':
2993 return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text);
2994 case 'gpt':
2995 case 'o1':
2996 default:
2997 return $this->translate_with_openai($api_key, $model, $system_prompt, $text);
2998 }
2999 }
3000
3001 /**
3002 * Translate text using OpenAI API
3003 */
3004 private function translate_with_openai($api_key, $model, $system_prompt, $text) {
3005 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
3006 'timeout' => 60,
3007 'headers' => [
3008 'Authorization' => 'Bearer ' . $api_key,
3009 'Content-Type' => 'application/json'
3010 ],
3011 'body' => wp_json_encode([
3012 'model' => $model,
3013 'messages' => [
3014 ['role' => 'system', 'content' => $system_prompt],
3015 ['role' => 'user', 'content' => $text]
3016 ],
3017 'temperature' => 0.3
3018 ])
3019 ]);
3020
3021 if (is_wp_error($response)) {
3022 return $response;
3023 }
3024
3025 $body = json_decode(wp_remote_retrieve_body($response), true);
3026
3027 if (isset($body['error'])) {
3028 return new WP_Error('api_error', $body['error']['message']);
3029 }
3030
3031 if (isset($body['choices'][0]['message']['content'])) {
3032 return $body['choices'][0]['message']['content'];
3033 }
3034
3035 return new WP_Error('api_error', 'Invalid API response');
3036 }
3037
3038 /**
3039 * Translate text using Claude API
3040 */
3041 private function translate_with_claude($api_key, $model, $system_prompt, $text) {
3042 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3043 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3044 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3045 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3046 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
3047 'timeout' => 60,
3048 'headers' => [
3049 'x-api-key' => $api_key,
3050 'anthropic-version' => '2023-06-01',
3051 'Content-Type' => 'application/json'
3052 ],
3053 'body' => wp_json_encode([
3054 'model' => $model,
3055 'max_tokens' => 4096,
3056 'system' => $system_prompt,
3057 'messages' => [
3058 ['role' => 'user', 'content' => $text]
3059 ]
3060 ])
3061 ]);
3062
3063 if (is_wp_error($response)) {
3064 return $response;
3065 }
3066
3067 $body = json_decode(wp_remote_retrieve_body($response), true);
3068
3069 if (isset($body['error'])) {
3070 return new WP_Error('api_error', $body['error']['message']);
3071 }
3072
3073 if (isset($body['content'][0]['text'])) {
3074 return $body['content'][0]['text'];
3075 }
3076
3077 return new WP_Error('api_error', 'Invalid API response');
3078 }
3079
3080 /**
3081 * Translate text using xAI (Grok) API
3082 */
3083 private function translate_with_xai($api_key, $model, $system_prompt, $text) {
3084 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [
3085 'timeout' => 60,
3086 'headers' => [
3087 'Authorization' => 'Bearer ' . $api_key,
3088 'Content-Type' => 'application/json'
3089 ],
3090 'body' => wp_json_encode([
3091 'model' => $model,
3092 'messages' => [
3093 ['role' => 'system', 'content' => $system_prompt],
3094 ['role' => 'user', 'content' => $text]
3095 ],
3096 'temperature' => 0.3
3097 ])
3098 ]);
3099
3100 if (is_wp_error($response)) {
3101 return $response;
3102 }
3103
3104 $body = json_decode(wp_remote_retrieve_body($response), true);
3105
3106 if (isset($body['error'])) {
3107 return new WP_Error('api_error', $body['error']['message']);
3108 }
3109
3110 if (isset($body['choices'][0]['message']['content'])) {
3111 return $body['choices'][0]['message']['content'];
3112 }
3113
3114 return new WP_Error('api_error', 'Invalid API response');
3115 }
3116
3117 /**
3118 * Translate text using DeepSeek API
3119 */
3120 private function translate_with_deepseek($api_key, $model, $system_prompt, $text) {
3121 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [
3122 'timeout' => 60,
3123 'headers' => [
3124 'Authorization' => 'Bearer ' . $api_key,
3125 'Content-Type' => 'application/json'
3126 ],
3127 'body' => wp_json_encode([
3128 'model' => $model,
3129 'messages' => [
3130 ['role' => 'system', 'content' => $system_prompt],
3131 ['role' => 'user', 'content' => $text]
3132 ],
3133 'temperature' => 0.3,
3134 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3135 // slow responses); translation wants non-thinking.
3136 'thinking' => ['type' => 'disabled']
3137 ])
3138 ]);
3139
3140 if (is_wp_error($response)) {
3141 return $response;
3142 }
3143
3144 $body = json_decode(wp_remote_retrieve_body($response), true);
3145
3146 if (isset($body['error'])) {
3147 return new WP_Error('api_error', $body['error']['message']);
3148 }
3149
3150 if (isset($body['choices'][0]['message']['content'])) {
3151 return $body['choices'][0]['message']['content'];
3152 }
3153
3154 return new WP_Error('api_error', 'Invalid API response');
3155 }
3156
3157 /**
3158 * Translate text using Google Gemini API
3159 */
3160 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3161 if ($model === 'gemini-3-pro-preview') {
3162 $model = 'gemini-3.1-pro-preview';
3163 }
3164 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3165
3166 $response = wp_remote_post($url, [
3167 'timeout' => 60,
3168 'headers' => [
3169 'Content-Type' => 'application/json'
3170 ],
3171 'body' => wp_json_encode([
3172 'contents' => [
3173 [
3174 'parts' => [
3175 ['text' => $system_prompt . "\n\n" . $text]
3176 ]
3177 ]
3178 ],
3179 'generationConfig' => [
3180 'temperature' => 0.3
3181 ]
3182 ])
3183 ]);
3184
3185 if (is_wp_error($response)) {
3186 return $response;
3187 }
3188
3189 $body = json_decode(wp_remote_retrieve_body($response), true);
3190
3191 if (isset($body['error'])) {
3192 return new WP_Error('api_error', $body['error']['message']);
3193 }
3194
3195 if (isset($body['candidates'][0]['content']['parts'][0]['text'])) {
3196 return $body['candidates'][0]['content']['parts'][0]['text'];
3197 }
3198
3199 return new WP_Error('api_error', 'Invalid API response');
3200 }
3201
3202 /**
3203 * Translate text using OpenRouter API
3204 */
3205 private function translate_with_openrouter($api_key, $model, $system_prompt, $text) {
3206 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
3207 'timeout' => 60,
3208 'headers' => [
3209 'Authorization' => 'Bearer ' . $api_key,
3210 'Content-Type' => 'application/json',
3211 'HTTP-Referer' => home_url(),
3212 'X-Title' => 'MxChat Translation'
3213 ],
3214 'body' => wp_json_encode([
3215 'model' => $model,
3216 'messages' => [
3217 ['role' => 'system', 'content' => $system_prompt],
3218 ['role' => 'user', 'content' => $text]
3219 ],
3220 'temperature' => 0.3
3221 ])
3222 ]);
3223
3224 if (is_wp_error($response)) {
3225 return $response;
3226 }
3227
3228 $body = json_decode(wp_remote_retrieve_body($response), true);
3229
3230 if (isset($body['error'])) {
3231 return new WP_Error('api_error', $body['error']['message']);
3232 }
3233
3234 if (isset($body['choices'][0]['message']['content'])) {
3235 return $body['choices'][0]['message']['content'];
3236 }
3237
3238 return new WP_Error('api_error', 'Invalid API response');
3239 }
3240
3241 public function mxchat_fetch_chat_history() {
3242 global $wpdb;
3243 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3244 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3245
3246 if (!current_user_can('manage_options')) {
3247 wp_send_json_error(['message' => 'Insufficient permissions']);
3248 wp_die();
3249 }
3250
3251 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3252 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3253 $offset = ($page - 1) * $per_page;
3254 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3255 $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3256 $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3257 if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3258 $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3259 $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3260 $ratings_join = '';
3261 $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3262 if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3263 // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3264 $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3265 $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3266 }
3267
3268 // Build search condition
3269 $search_condition = '';
3270 $search_params = [];
3271 if (!empty($search)) {
3272 $search_condition = "WHERE (
3273 session_id LIKE %s
3274 OR user_email LIKE %s
3275 OR user_name LIKE %s
3276 OR user_identifier LIKE %s
3277 OR message LIKE %s
3278 )";
3279 $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%');
3280 }
3281
3282 // Get total count
3283 $count_query = !empty($search)
3284 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3285 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3286 $total_sessions = (int) $wpdb->get_var($count_query);
3287
3288 // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3289 // and order by rating value (NULLs last) with recency as tiebreaker.
3290 if ($ratings_join) {
3291 $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3292 } else {
3293 $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3294 }
3295 $session_query = !empty($search)
3296 ? $wpdb->prepare(
3297 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3298 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3299 array_merge($search_params, [$per_page, $offset])
3300 )
3301 : $wpdb->prepare(
3302 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3303 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3304 $per_page, $offset
3305 );
3306 $session_ids = $wpdb->get_col($session_query);
3307
3308 $total_pages = ceil($total_sessions / $per_page);
3309
3310 if (empty($session_ids)) {
3311 wp_send_json([
3312 'success' => true,
3313 'sessions' => [],
3314 'page' => $page,
3315 'total_pages' => 0,
3316 'total_sessions' => 0,
3317 'showing_start' => 0,
3318 'showing_end' => 0
3319 ]);
3320 wp_die();
3321 }
3322
3323 // Check for optional columns/tables
3324 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3325 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3326
3327 // Batch-fetch session ratings for this page (plan-a5b006).
3328 $ratings_map = array();
3329 if ($rating_table_exists && !empty($session_ids)) {
3330 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3331 $rating_rows = $wpdb->get_results($wpdb->prepare(
3332 "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3333 $session_ids
3334 ));
3335 foreach ($rating_rows as $row) {
3336 $ratings_map[$row->session_id] = array(
3337 'value' => (int) $row->rating_value,
3338 'feedback' => (string) $row->rating_feedback,
3339 );
3340 }
3341 }
3342
3343 // Build session list data
3344 $sessions = [];
3345 foreach ($session_ids as $session_id) {
3346 // Get session metadata
3347 $session_data = $originating_columns_exist
3348 ? $wpdb->get_row($wpdb->prepare(
3349 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3350 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3351 $session_id
3352 ))
3353 : $wpdb->get_row($wpdb->prepare(
3354 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3355 WHERE session_id = %s LIMIT 1",
3356 $session_id
3357 ));
3358
3359 // Get message count and latest timestamp
3360 $message_stats = $wpdb->get_row($wpdb->prepare(
3361 "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s",
3362 $session_id
3363 ));
3364
3365 // Get first user message as preview
3366 $first_user_msg = $wpdb->get_var($wpdb->prepare(
3367 "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1",
3368 $session_id
3369 ));
3370 $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages';
3371
3372 // Build display name
3373 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3374 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3375 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3376
3377 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3378 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3379
3380 // Format time - show relative for recent, date for older
3381 $timestamp = strtotime($message_stats->latest . ' UTC');
3382 $now = time();
3383 $diff = $now - $timestamp;
3384 if ($diff < 3600) {
3385 $time_display = floor($diff / 60) . 'm ago';
3386 } elseif ($diff < 86400) {
3387 $time_display = floor($diff / 3600) . 'h ago';
3388 } elseif ($diff < 604800) {
3389 $time_display = floor($diff / 86400) . 'd ago';
3390 } else {
3391 $time_display = wp_date('M j', $timestamp);
3392 }
3393
3394 // Get initials for avatar
3395 $initials = strtoupper(substr($display_name, 0, 2));
3396 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3397 $parts = explode(' ', $display_name);
3398 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3399 }
3400
3401 $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3402 $rating_value = $rating_entry ? $rating_entry['value'] : null;
3403 $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3404
3405 $sessions[] = [
3406 'session_id' => $session_id,
3407 'display_name' => $display_name,
3408 'display_sub' => $display_sub,
3409 'initials' => $initials,
3410 'preview' => $preview,
3411 'message_count' => (int) $message_stats->count,
3412 'time_display' => $time_display,
3413 'timestamp' => $message_stats->latest,
3414 'rating_value' => $rating_value,
3415 'rating_feedback' => $rating_feedback,
3416 ];
3417 }
3418
3419 wp_send_json([
3420 'success' => true,
3421 'sessions' => $sessions,
3422 'page' => $page,
3423 'total_pages' => $total_pages,
3424 'total_sessions' => $total_sessions,
3425 'showing_start' => $offset + 1,
3426 'showing_end' => min($offset + $per_page, $total_sessions)
3427 ]);
3428 wp_die();
3429 }
3430
3431 /**
3432 * Fetch single conversation details for split-panel view
3433 */
3434 public function mxchat_fetch_conversation() {
3435 global $wpdb;
3436 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3437 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3438
3439 if (!current_user_can('manage_options')) {
3440 wp_send_json_error(['message' => 'Insufficient permissions']);
3441 wp_die();
3442 }
3443
3444 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3445 if (empty($session_id)) {
3446 wp_send_json_error(['message' => 'No session ID provided']);
3447 wp_die();
3448 }
3449
3450 // Check for optional columns/tables
3451 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3452 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3453
3454 // Get session metadata
3455 $session_data = $originating_columns_exist
3456 ? $wpdb->get_row($wpdb->prepare(
3457 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3458 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3459 $session_id
3460 ))
3461 : $wpdb->get_row($wpdb->prepare(
3462 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3463 WHERE session_id = %s LIMIT 1",
3464 $session_id
3465 ));
3466
3467 if (!$session_data) {
3468 wp_send_json_error(['message' => 'Session not found']);
3469 wp_die();
3470 }
3471
3472 // Get clicked URLs
3473 $clicked_urls = [];
3474 if ($url_table_exists) {
3475 $url_clicks = $wpdb->get_results($wpdb->prepare(
3476 "SELECT DISTINCT clicked_url FROM {$url_clicks_table}
3477 WHERE session_id = %s ORDER BY click_timestamp ASC",
3478 $session_id
3479 ));
3480 foreach ($url_clicks as $click) {
3481 $clicked_urls[] = $click->clicked_url;
3482 }
3483 }
3484
3485 // Get all messages
3486 $messages = $wpdb->get_results($wpdb->prepare(
3487 "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC",
3488 $session_id
3489 ));
3490
3491 // Build user info
3492 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3493 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3494 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3495
3496 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3497 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3498
3499 // Get initials
3500 $initials = strtoupper(substr($display_name, 0, 2));
3501 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3502 $parts = explode(' ', $display_name);
3503 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3504 }
3505
3506 // Page info
3507 $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : '';
3508 $page_title = '';
3509 if ($page_url) {
3510 $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH);
3511 }
3512
3513 // Format messages for output
3514 $formatted_messages = [];
3515 foreach ($messages as $msg) {
3516 $is_user = ($msg->role === 'user');
3517 // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'.
3518 // Kept OUT of $is_bot so the RAG Sources link below stays bot-only.
3519 $is_agent = ($msg->role === 'agent');
3520 $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant');
3521
3522 $content = wp_kses(
3523 stripslashes($msg->message),
3524 [
3525 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3526 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3527 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []],
3528 'div' => ['class' => [], 'id' => [], 'data-nonce' => []],
3529 'img' => ['src' => [], 'alt' => [], 'class' => []],
3530 'h3' => ['class' => []],
3531 'h4' => ['class' => []],
3532 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []],
3533 'select' => ['class' => [], 'data-attribute' => []],
3534 'option' => ['value' => []],
3535 'span' => ['class' => []],
3536 'del' => [], 'ins' => [],
3537 ]
3538 );
3539 $formatted_content = $this->format_transcript_message($content);
3540
3541 $has_rag = $is_bot && !empty($msg->rag_context);
3542
3543 $formatted_messages[] = [
3544 'id' => $msg->id,
3545 'role' => $msg->role,
3546 'is_user' => $is_user,
3547 'is_agent' => $is_agent,
3548 'is_bot' => $is_bot,
3549 'content' => $formatted_content,
3550 'timestamp' => wp_date('g:i A', strtotime($msg->timestamp . ' UTC')),
3551 'full_timestamp' => wp_date('F j, Y g:i A', strtotime($msg->timestamp . ' UTC')),
3552 'has_rag' => $has_rag
3553 ];
3554 }
3555
3556 // First message timestamp for "started" display
3557 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
3558
3559 // Pull rating_feedback for this session (if any) so the details drawer can show it.
3560 $rating_feedback = '';
3561 $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
3562 if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
3563 $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
3564 "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
3565 $session_id
3566 ));
3567 }
3568
3569 wp_send_json([
3570 'success' => true,
3571 'session_id' => $session_id,
3572 'user' => [
3573 'name' => $display_name,
3574 'sub' => $display_sub,
3575 'initials' => $initials,
3576 'email' => $user_email,
3577 'identifier' => $user_identifier
3578 ],
3579 'page' => [
3580 'url' => $page_url,
3581 'title' => $page_title
3582 ],
3583 'clicked_urls' => $clicked_urls,
3584 'messages' => $formatted_messages,
3585 'message_count' => count($messages),
3586 'started' => $started,
3587 'rating_feedback' => $rating_feedback
3588 ]);
3589 wp_die();
3590 }
3591
3592
3593 /**
3594 * ALTERNATIVE: Simpler helper method using string replacement
3595 */
3596 private function highlight_clicked_links($message_content, $clicked_urls) {
3597 if (empty($clicked_urls)) {
3598 return wp_kses(
3599 $message_content,
3600 [
3601 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3602 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3603 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3604 ]
3605 );
3606 }
3607
3608 // First apply standard sanitization
3609 $message_content = wp_kses(
3610 $message_content,
3611 [
3612 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3613 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3614 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3615 ]
3616 );
3617
3618 // Process each clicked URL
3619 foreach ($clicked_urls as $clicked_url) {
3620 // Try multiple patterns to catch different link formats
3621 $patterns = [
3622 // Standard link format
3623 '/<a([^>]*href=["\']' . preg_quote($clicked_url, '/') . '["\'][^>]*)>/i',
3624 // Link with trailing slash
3625 '/<a([^>]*href=["\']' . preg_quote(rtrim($clicked_url, '/'), '/') . '\/?["\'][^>]*)>/i',
3626 // Encoded entities version
3627 '/<a([^>]*href=["\']' . preg_quote(htmlentities($clicked_url), '/') . '["\'][^>]*)>/i',
3628 ];
3629
3630 foreach ($patterns as $pattern) {
3631 if (preg_match($pattern, $message_content)) {
3632 $message_content = preg_replace_callback(
3633 $pattern,
3634 function($matches) {
3635 $full_match = $matches[0];
3636 $attributes = $matches[1];
3637
3638 // Check if it already has the class
3639 if (strpos($full_match, 'mxchat-clicked-link') !== false) {
3640 return $full_match;
3641 }
3642
3643 // Check if class attribute exists
3644 if (preg_match('/class=["\']([^"\']*)["\']/', $attributes, $class_matches)) {
3645 // Add to existing class
3646 $new_attributes = preg_replace(
3647 '/class=["\']([^"\']*)["\']/',
3648 'class="$1 mxchat-clicked-link"',
3649 $attributes
3650 );
3651 } else {
3652 // Add new class attribute
3653 $new_attributes = $attributes . ' class="mxchat-clicked-link"';
3654 }
3655
3656 // Add title if not present
3657 if (strpos($new_attributes, 'title=') === false) {
3658 $new_attributes .= ' title="User clicked this link"';
3659 }
3660
3661 return '<a' . $new_attributes . '>';
3662 },
3663 $message_content
3664 );
3665
3666 // If we found and replaced, break out of the patterns loop
3667 break;
3668 }
3669 }
3670 }
3671
3672 return $message_content;
3673 }
3674
3675 public function mxchat_create_prompts_page() {
3676 //error_log('=== DEBUG: mxchat_create_prompts_page started ===');
3677
3678 global $wpdb;
3679 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3680
3681 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
3682 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
3683
3684 // Display success message if all prompts were deleted
3685 if (isset($_GET['all_deleted']) && $_GET['all_deleted'] === 'true') {
3686 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>';
3687 }
3688
3689 // Set up pagination, search query, and content type filter
3690 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '';
3691 $search_query = (!empty($nonce) && wp_verify_nonce($nonce, 'mxchat_prompts_search_nonce') && isset($_GET['search'])) ? sanitize_text_field($_GET['search']) : '';
3692 $content_type_filter = isset($_GET['content_type']) ? sanitize_key($_GET['content_type']) : ''; // ADDED 2.5.6
3693 $current_page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
3694 $per_page = 25;
3695
3696 //error_log('DEBUG: Search query: ' . $search_query);
3697 //error_log('DEBUG: Content type filter: ' . $content_type_filter);
3698 //error_log('DEBUG: Current page: ' . $current_page);
3699 //error_log('DEBUG: Per page: ' . $per_page);
3700
3701 // ================================
3702 // MULTI-BOT CONFIGURATION
3703 // ================================
3704
3705 // Check for multi-bot and set up bot selection
3706 if (class_exists('MxChat_Multi_Bot_Manager')) {
3707 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
3708 $available_bots = $multi_bot_manager->get_available_bots();
3709
3710 // Get saved bot selection (user-specific first, then site-wide default)
3711 $user_id = get_current_user_id();
3712 $saved_bot_id = get_user_meta($user_id, 'mxchat_selected_knowledge_bot', true);
3713 if (empty($saved_bot_id)) {
3714 $saved_bot_id = get_option('mxchat_current_knowledge_bot', 'default');
3715 }
3716
3717 // Allow URL override but default to saved selection
3718 $current_bot_id = isset($_GET['bot_id']) ? sanitize_key($_GET['bot_id']) : $saved_bot_id;
3719 $multibot_active = true;
3720 } else {
3721 $current_bot_id = 'default';
3722 $multibot_active = false;
3723 }
3724
3725 // ================================
3726 // DATA SOURCE CONFIGURATION
3727 // ================================
3728
3729 // Get bot-specific Pinecone settings
3730 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($current_bot_id);
3731 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3732 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3733
3734 // Get OpenAI Vector Store settings
3735 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
3736 $use_vectorstore = ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1';
3737
3738 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3739 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Has API Key: ' . (!empty($pinecone_api_key) ? 'YES' : 'NO'));
3740 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Host: ' . ($pinecone_options['mxchat_pinecone_host'] ?? 'NOT SET'));
3741 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Namespace: ' . ($pinecone_options['mxchat_pinecone_namespace'] ?? 'NOT SET'));
3742
3743 if ($use_pinecone && !empty($pinecone_api_key)) {
3744 //error_log('DEBUG: Using PINECONE data source with bot-specific config');
3745 // PINECONE DATA SOURCE
3746 $data_source = 'pinecone';
3747
3748 // IMPORTANT: Pass the bot-specific $pinecone_options, not default options!
3749 // UPDATED 2.5.6: Added content_type_filter parameter
3750 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $current_page, $per_page, $current_bot_id, $content_type_filter);
3751
3752 // TEMPORARY DEBUG - Add this right after the fetch call
3753 //error_log('=== DEBUG: Bot switching issue ===');
3754 //error_log('Current bot ID: ' . $current_bot_id);
3755 //error_log('Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3756 //error_log('Records returned: ' . count($records['data'] ?? []));
3757 //error_log('Total records: ' . ($records['total'] ?? 0));
3758
3759 // Check first few records to see their bot_id
3760 if (!empty($records['data'])) {
3761 foreach (array_slice($records['data'], 0, 3) as $i => $record) {
3762 $record_bot_id = $record->bot_id ?? 'NOT_SET';
3763 //error_log('Record ' . ($i+1) . ' bot_id: ' . $record_bot_id . ', content preview: ' . substr($record->article_content ?? '', 0, 30) . '...');
3764 }
3765 }
3766 //error_log('=== END DEBUG ===');
3767
3768 $total_records = $records['total'] ?? 0;
3769 $prompts = $records['data'] ?? array();
3770 $total_in_database = $records['total_in_database'] ?? 0;
3771 $showing_recent_only = $records['showing_recent_only'] ?? false;
3772
3773 $total_pages = ceil($total_records / $per_page);
3774
3775 } else {
3776 //error_log('DEBUG: Using WORDPRESS DB data source');
3777 // WORDPRESS DB DATA SOURCE (your existing logic)
3778 $data_source = 'wordpress';
3779
3780 // Initialize these variables for WordPress DB
3781 $total_in_database = 0;
3782 $showing_recent_only = false;
3783
3784 $offset = ($current_page - 1) * $per_page;
3785
3786 // UPDATED 2.5.6: Build WHERE clause for search and content type filtering
3787 $where_clauses = array();
3788 $where_values = array();
3789
3790 if ($search_query) {
3791 $where_clauses[] = "article_content LIKE %s";
3792 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3793 }
3794
3795 if ($content_type_filter) {
3796 $where_clauses[] = "content_type = %s";
3797 $where_values[] = $content_type_filter;
3798 }
3799
3800 $sql_where = "";
3801 if (!empty($where_clauses)) {
3802 $sql_where = "WHERE " . implode(" AND ", $where_clauses);
3803 }
3804
3805 // UPDATED 2.6.3: Count unique entries (by source_url) instead of individual rows
3806 // This ensures pagination shows X entries per page, not X chunks
3807 // Entries with empty source_url are counted individually
3808 if (!empty($where_values)) {
3809 // Count unique source_urls + count of rows with empty source_url
3810 $count_query = $wpdb->prepare(
3811 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$sql_where} AND source_url != '') +
3812 (SELECT COUNT(*) FROM {$table_name} {$sql_where} AND (source_url = '' OR source_url IS NULL))",
3813 array_merge($where_values, $where_values)
3814 );
3815 } else {
3816 $count_query = "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '') +
3817 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL)";
3818 }
3819 $total_records = $wpdb->get_var($count_query);
3820 $total_pages = ceil($total_records / $per_page);
3821
3822 // UPDATED 2.6.3: Get unique source_urls for pagination, then fetch all their rows
3823 // Step 1: Get the source_urls for this page (distinct URLs ordered by latest timestamp)
3824 if (!empty($where_values)) {
3825 $urls_query = $wpdb->prepare(
3826 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name} {$sql_where}
3827 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3828 array_merge($where_values, array($per_page, $offset))
3829 );
3830 } else {
3831 $urls_query = $wpdb->prepare(
3832 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3833 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3834 $per_page, $offset
3835 );
3836 }
3837 $page_urls = $wpdb->get_results($urls_query);
3838
3839 // Step 2: Fetch all rows for these source_urls
3840 $prompts = array();
3841 if (!empty($page_urls)) {
3842 // Build URL order map to preserve newest-first ordering from step 1
3843 $url_order_map = array();
3844 $url_list = array();
3845 $has_empty_url = false;
3846 $order_index = 0;
3847 foreach ($page_urls as $url_row) {
3848 if (empty($url_row->source_url)) {
3849 $has_empty_url = true;
3850 $url_order_map['__empty__'] = $order_index++;
3851 } else {
3852 $url_list[] = $url_row->source_url;
3853 $url_order_map[$url_row->source_url] = $order_index++;
3854 }
3855 }
3856
3857 // Build query to fetch all rows for these URLs
3858 $url_conditions = array();
3859 $url_values = array();
3860
3861 if (!empty($url_list)) {
3862 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3863 $url_conditions[] = "source_url IN ($placeholders)";
3864 $url_values = array_merge($url_values, $url_list);
3865 }
3866
3867 if ($has_empty_url) {
3868 $url_conditions[] = "(source_url = '' OR source_url IS NULL)";
3869 }
3870
3871 if (!empty($url_conditions)) {
3872 $url_where = "WHERE (" . implode(" OR ", $url_conditions) . ")";
3873
3874 // Add original filters back
3875 if (!empty($where_clauses)) {
3876 $url_where .= " AND " . implode(" AND ", $where_clauses);
3877 $url_values = array_merge($url_values, $where_values);
3878 }
3879
3880 if (!empty($url_values)) {
3881 $prompts_query = $wpdb->prepare(
3882 "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC",
3883 $url_values
3884 );
3885 } else {
3886 $prompts_query = "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC";
3887 }
3888
3889 $prompts = $wpdb->get_results($prompts_query);
3890
3891 // Sort prompts by the original URL order (newest first), then by timestamp within each URL group
3892 usort($prompts, function($a, $b) use ($url_order_map) {
3893 $url_a = empty($a->source_url) ? '__empty__' : $a->source_url;
3894 $url_b = empty($b->source_url) ? '__empty__' : $b->source_url;
3895 $order_a = $url_order_map[$url_a] ?? PHP_INT_MAX;
3896 $order_b = $url_order_map[$url_b] ?? PHP_INT_MAX;
3897
3898 // First sort by URL order (newest URLs first)
3899 if ($order_a !== $order_b) {
3900 return $order_a - $order_b;
3901 }
3902
3903 // Within same URL, sort by timestamp DESC (newest chunks first)
3904 return strtotime($b->timestamp) - strtotime($a->timestamp);
3905 });
3906 }
3907 }
3908 }
3909
3910 // ================================
3911 // PAGINATION GENERATION
3912 // ================================
3913
3914 // Generate pagination links
3915 if ($total_pages > 1) {
3916 // Build a clean base URL with only necessary parameters
3917 $pagination_args = array('page' => 'mxchat-prompts');
3918
3919 // Preserve bot_id parameter if multi-bot is active
3920 if ($multibot_active && !empty($current_bot_id) && $current_bot_id !== 'default') {
3921 $pagination_args['bot_id'] = $current_bot_id;
3922 }
3923
3924 // Preserve search query and nonce if present
3925 if ($search_query) {
3926 $pagination_args['search'] = $search_query;
3927 if (!empty($nonce)) {
3928 $pagination_args['_wpnonce'] = $nonce;
3929 }
3930 }
3931
3932 // Preserve content type filter if present
3933 if ($content_type_filter) {
3934 $pagination_args['content_type'] = $content_type_filter;
3935 }
3936
3937 // Build clean base URL
3938 $base_url = add_query_arg($pagination_args, admin_url('admin.php'));
3939
3940 $page_links = paginate_links(array(
3941 'base' => $base_url . '%_%',
3942 'format' => '&paged=%#%',
3943 'prev_text' => __('&laquo; Previous', 'mxchat'),
3944 'next_text' => __('Next &raquo;', 'mxchat'),
3945 'total' => $total_pages,
3946 'current' => $current_page,
3947 'type' => 'plain',
3948 ));
3949 } else {
3950 $page_links = '';
3951 }
3952
3953 // ================================
3954 // PROCESSING STATUS RETRIEVAL
3955 // ================================
3956
3957 // Retrieve processing statuses using queue-based method
3958 $processing_statuses = $knowledge_manager->mxchat_get_processing_statuses();
3959 $pdf_status = $processing_statuses['pdf_status'];
3960 $sitemap_status = $processing_statuses['sitemap_status'];
3961 $is_processing = $processing_statuses['is_processing'];
3962
3963 //error_log('=== DEBUG: mxchat_create_prompts_page data preparation completed ===');
3964
3965 // ================================
3966 // RENDER PAGE WITH NEW SIDEBAR LAYOUT
3967 // ================================
3968
3969 // Include the new knowledge page template
3970 require_once plugin_dir_path(__FILE__) . 'admin-knowledge-page.php';
3971
3972 // Package all page data for the render function
3973 $page_data = array(
3974 'prompts' => $prompts,
3975 'total_records' => $total_records,
3976 'total_pages' => $total_pages,
3977 'current_page' => $current_page,
3978 'per_page' => $per_page,
3979 'page_links' => $page_links,
3980 'search_query' => $search_query,
3981 'content_type_filter' => $content_type_filter,
3982 'data_source' => $data_source,
3983 'use_pinecone' => $use_pinecone,
3984 'use_vectorstore' => $use_vectorstore,
3985 'multibot_active' => $multibot_active,
3986 'current_bot_id' => $current_bot_id,
3987 'pdf_status' => $pdf_status,
3988 'sitemap_status' => $sitemap_status,
3989 'is_processing' => $is_processing,
3990 'total_in_database' => $total_in_database ?? 0,
3991 'showing_recent_only' => $showing_recent_only ?? false,
3992 );
3993
3994 // Render the new sidebar-based page
3995 mxchat_render_knowledge_page($this, $knowledge_manager, $page_data);
3996 }
3997
3998 /**
3999 * Get bot-specific Pinecone configuration
4000 * Used in the knowledge retrieval functions
4001 */
4002 private function get_bot_pinecone_config($bot_id = 'default') {
4003 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4004
4005 // If default bot or multi-bot add-on not active, use default Pinecone config
4006 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4007 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4008 $addon_options = get_option('mxchat_pinecone_addon_options', array());
4009 $config = array(
4010 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4011 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4012 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4013 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4014 );
4015 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4016 return $config;
4017 }
4018
4019 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4020
4021 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4022 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4023
4024 if (!empty($bot_pinecone_config)) {
4025 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4026 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4027 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4028 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4029 } else {
4030 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4031 }
4032
4033 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4034 }
4035
4036 public function mxchat_delete_chat_history() {
4037 if (!current_user_can('manage_options')) {
4038 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4039 wp_die();
4040 }
4041 check_ajax_referer('mxchat_delete_chat_history', 'security');
4042
4043 if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
4044 echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
4045 wp_die();
4046 }
4047
4048 global $wpdb;
4049 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4050 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
4051 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
4052
4053 // When true, any lead attached to these sessions is fully wiped (all their sessions,
4054 // across the whole table). Default false: the chat rows go away but the lead is
4055 // preserved as a separate "chat deleted" lead in the Leads tab.
4056 $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
4057
4058 $deleted_count = 0;
4059 $preserved_as_deleted_leads = 0;
4060 $emails_to_fully_wipe = [];
4061
4062 foreach ((array) $_POST['delete_session_ids'] as $session_id) {
4063 $session_id_sanitized = MxChat_Utils::sanitize_session_id($session_id);
4064 if ($session_id_sanitized === '') {
4065 continue;
4066 }
4067
4068 // Capture the lead info attached to this session *before* we delete the rows.
4069 $lead_row = $wpdb->get_row($wpdb->prepare(
4070 "SELECT user_email, user_name, MAX(timestamp) AS last_ts
4071 FROM {$table_name}
4072 WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
4073 GROUP BY user_email, user_name
4074 ORDER BY last_ts DESC LIMIT 1",
4075 $session_id_sanitized
4076 ));
4077
4078 wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
4079 $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
4080
4081 if ($has_translations) {
4082 $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
4083 }
4084
4085 delete_option('mxchat_history_' . $session_id_sanitized);
4086 delete_option('mxchat_agent_name_' . $session_id_sanitized);
4087 if (class_exists('MxChat_Session_Store')) {
4088 MxChat_Session_Store::delete_session($session_id_sanitized); // b64b77
4089 }
4090
4091 if ($lead_row && !empty($lead_row->user_email)) {
4092 if ($also_delete_lead) {
4093 // Full-wipe requested — queue the email so that all their sessions and
4094 // related options get swept below. Also clear this session's pre-chat
4095 // capture options (they're no longer meaningful).
4096 $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
4097 delete_option('mxchat_email_' . $session_id_sanitized);
4098 delete_option('mxchat_name_' . $session_id_sanitized);
4099 } else {
4100 // Preserve the lead in a "Chat deleted" state via distinct option keys so
4101 // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
4102 update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
4103 if (!empty($lead_row->user_name)) {
4104 update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
4105 }
4106 if (!empty($lead_row->last_ts)) {
4107 update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
4108 }
4109 // Clean up pre-chat capture options for this session — chat_deleted supersedes.
4110 delete_option('mxchat_email_' . $session_id_sanitized);
4111 delete_option('mxchat_name_' . $session_id_sanitized);
4112 $preserved_as_deleted_leads++;
4113 }
4114 } else {
4115 // No lead attached — nothing to preserve. Clean up any orphan options anyway.
4116 delete_option('mxchat_email_' . $session_id_sanitized);
4117 delete_option('mxchat_name_' . $session_id_sanitized);
4118 }
4119
4120 $deleted_count++;
4121 }
4122
4123 // Opt-in full-lead wipe: sweep every remaining row + every option key (including
4124 // chat_deleted preservation) for each affected email. Reuses the same internal
4125 // helper as the Leads-tab Delete button for consistency.
4126 if (!empty($emails_to_fully_wipe)) {
4127 self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
4128 }
4129
4130 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
4131
4132 echo wp_json_encode([
4133 'success' => sprintf(
4134 esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
4135 $deleted_count
4136 ),
4137 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
4138 'leads_fully_wiped' => count($emails_to_fully_wipe),
4139 ]);
4140 wp_die();
4141 }
4142
4143 /**
4144 * Format transcript message content with markdown processing
4145 * Converts markdown links, bold, italic, code blocks, and plain URLs to HTML
4146 */
4147 private function format_transcript_message($text) {
4148 if (empty($text)) {
4149 return '';
4150 }
4151
4152 // Normalize line endings and clean up excessive whitespace
4153 $text = str_replace("\r\n", "\n", $text);
4154 $text = str_replace("\r", "\n", $text);
4155
4156 // Clean up existing <br> tags that may have been saved (legacy data)
4157 // Convert <br>, <br/>, <br /> back to newlines for consistent processing
4158 $text = preg_replace('/<br\s*\/?>\s*/i', "\n", $text);
4159
4160 // Collapse 3+ consecutive newlines to just 2 (paragraph break)
4161 $text = preg_replace('/\n{3,}/', "\n\n", $text);
4162
4163 // Process markdown headers (# Header)
4164 $text = preg_replace_callback('/^(#{1,6})\s+(.+)$/m', function($matches) {
4165 $level = strlen($matches[1]);
4166 $content = esc_html(trim($matches[2]));
4167 return "<h{$level}>{$content}</h{$level}>";
4168 }, $text);
4169
4170 // Process code blocks with triple backticks
4171 $text = preg_replace_callback('/```(\w+)?\n?([\s\S]*?)```/', function($matches) {
4172 $language = !empty($matches[1]) ? ' class="language-' . esc_attr($matches[1]) . '"' : '';
4173 $code = esc_html($matches[2]);
4174 return "<pre><code{$language}>{$code}</code></pre>";
4175 }, $text);
4176
4177 // Process inline code with single backticks
4178 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
4179
4180 // Process bold text **text** or __text__
4181 $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
4182 $text = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $text);
4183
4184 // Process italic text *text* or _text_ (but not if part of URL)
4185 $text = preg_replace('/(?<![*_\w])\*([^*]+)\*(?![*\w])/', '<em>$1</em>', $text);
4186 $text = preg_replace('/(?<![*_\w])_([^_]+)_(?![*\w])/', '<em>$1</em>', $text);
4187
4188 // Process markdown links [text](url)
4189 $text = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function($matches) {
4190 $link_text = esc_html($matches[1]);
4191 $url = esc_url($matches[2]);
4192 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$link_text}</a>";
4193 }, $text);
4194
4195 // Process citation-style brackets [URL]
4196 $text = preg_replace_callback('/\[(https?:\/\/[^\]]+)\]/', function($matches) {
4197 $url = esc_url($matches[1]);
4198 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$url}</a>";
4199 }, $text);
4200
4201 // Process standalone URLs (not already in links or img src)
4202 $text = preg_replace_callback(
4203 '/(?<!href="|src="|">)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/',
4204 function($matches) {
4205 $url = esc_url($matches[1]);
4206 // Truncate display URL if too long
4207 $display = strlen($matches[1]) > 50 ? substr($matches[1], 0, 47) . '...' : $matches[1];
4208 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$display}</a>";
4209 },
4210 $text
4211 );
4212
4213 // Process mailto links
4214 $text = preg_replace_callback('/\[([^\]]+)\]\((mailto:[^)]+)\)/', function($matches) {
4215 $link_text = esc_html($matches[1]);
4216 $mailto = esc_url($matches[2]);
4217 return "<a href=\"{$mailto}\">{$link_text}</a>";
4218 }, $text);
4219
4220 // Convert paragraphs: split by double newlines, wrap in <p> tags
4221 // This creates proper paragraph structure instead of excessive <br> tags
4222 $paragraphs = preg_split('/\n\n+/', $text);
4223
4224 // Filter out empty paragraphs but preserve content like "0"
4225 $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), function($p) {
4226 return $p !== '';
4227 }));
4228
4229 if (empty($paragraphs)) {
4230 // No content after filtering
4231 return '';
4232 } elseif (count($paragraphs) > 1) {
4233 // Multiple paragraphs - wrap each in <p> tags, convert single newlines to <br>
4234 $formatted_paragraphs = array_map(function($p) {
4235 return nl2br($p);
4236 }, $paragraphs);
4237 $text = '<p>' . implode('</p><p>', $formatted_paragraphs) . '</p>';
4238 } else {
4239 // Single paragraph - just convert newlines to <br>
4240 $text = nl2br($paragraphs[0]);
4241 }
4242
4243 return $text;
4244 }
4245
4246 /**
4247 * AJAX handler to fetch RAG context for a specific message
4248 * Used by the transcript viewer to show retrieved documents
4249 */
4250 public function mxchat_get_rag_context() {
4251 // Check permissions
4252 if (!current_user_can('manage_options')) {
4253 wp_send_json_error(['message' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4254 wp_die();
4255 }
4256
4257 // Validate message ID
4258 if (!isset($_POST['message_id']) || empty($_POST['message_id'])) {
4259 wp_send_json_error(['message' => esc_html__('Message ID is required.', 'mxchat')]);
4260 wp_die();
4261 }
4262
4263 $message_id = absint($_POST['message_id']);
4264
4265 global $wpdb;
4266 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4267
4268 // Fetch the RAG context for this message
4269 $result = $wpdb->get_row($wpdb->prepare(
4270 "SELECT rag_context FROM {$table_name} WHERE id = %d",
4271 $message_id
4272 ));
4273
4274 if (!$result || empty($result->rag_context)) {
4275 wp_send_json_error(['message' => esc_html__('No RAG context found for this message.', 'mxchat')]);
4276 wp_die();
4277 }
4278
4279 // Decode the JSON data
4280 $rag_context = json_decode($result->rag_context, true);
4281
4282 if (json_last_error() !== JSON_ERROR_NONE) {
4283 wp_send_json_error(['message' => esc_html__('Invalid RAG context data.', 'mxchat')]);
4284 wp_die();
4285 }
4286
4287 wp_send_json_success($rag_context);
4288 wp_die();
4289 }
4290
4291 public function display_admin_notices() {
4292 // Check if we're on a MXChat admin page
4293 $screen = get_current_screen();
4294 if (!$screen || strpos($screen->base, 'mxchat') === false) {
4295 return;
4296 }
4297
4298 $dismiss_button = '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button>';
4299
4300 // Check for error notices
4301 $error_notice = get_transient('mxchat_admin_notice_error');
4302 if ($error_notice) {
4303 echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post($error_notice) . '</p>' . $dismiss_button . '</div>';
4304 delete_transient('mxchat_admin_notice_error');
4305 }
4306
4307 // Check for success notices
4308 $success_notice = get_transient('mxchat_admin_notice_success');
4309 if ($success_notice) {
4310 echo '<div class="notice notice-success is-dismissible"><p>' . wp_kses_post($success_notice) . '</p>' . $dismiss_button . '</div>';
4311 delete_transient('mxchat_admin_notice_success');
4312 }
4313
4314 // Check for info notices
4315 $info_notice = get_transient('mxchat_admin_notice_info');
4316 if ($info_notice) {
4317 echo '<div class="notice notice-info is-dismissible"><p>' . wp_kses_post($info_notice) . '</p>' . $dismiss_button . '</div>';
4318 delete_transient('mxchat_admin_notice_info');
4319 }
4320 }
4321
4322
4323 public function mxchat_create_activation_page() {
4324 // Include the new Pro & Extensions page template
4325 require_once plugin_dir_path(__FILE__) . 'admin-pro-page.php';
4326
4327 // Get addons configuration from the MxChat_Addons class
4328 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
4329 $addons_instance = new MxChat_Addons();
4330 $addons_config = $addons_instance->get_addons_config();
4331
4332 // Render the consolidated Pro & Extensions page
4333 mxchat_render_pro_page($this, $addons_config);
4334 }
4335
4336 /**
4337 * Check if current activation is linked to a domain
4338 * This checks YOUR website's database, not the user's local database
4339 */
4340 public function is_current_activation_linked($domain) {
4341 $license_key = get_option('mxchat_activation_key');
4342 $email = get_option('mxchat_pro_email');
4343
4344 if (empty($license_key) || empty($email)) {
4345 return false;
4346 }
4347
4348 // Check with YOUR website's API
4349 $response = wp_remote_post('https://mxchat.ai/mxchat-api/check-domain', array(
4350 'body' => array(
4351 'license_key' => $license_key,
4352 'email' => $email,
4353 'domain' => $domain
4354 ),
4355 'timeout' => 10,
4356 'sslverify' => false
4357 ));
4358
4359 if (is_wp_error($response)) {
4360 return false;
4361 }
4362
4363 $body = json_decode(wp_remote_retrieve_body($response), true);
4364 return isset($body['success']) && $body['success'] && isset($body['data']['linked']) && $body['data']['linked'];
4365 }
4366
4367
4368 public function mxchat_actions_page_html() {
4369 if (!current_user_can('manage_options')) {
4370 return;
4371 }
4372
4373 global $wpdb;
4374 $table_name = $wpdb->prefix . 'mxchat_intents';
4375
4376 // Get stats for dashboard
4377 $total_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
4378 $enabled_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE enabled = 1");
4379 $disabled_actions = $total_actions - $enabled_actions;
4380
4381 // Get unique action types count
4382 $action_types_count = $wpdb->get_var("SELECT COUNT(DISTINCT callback_function) FROM $table_name");
4383
4384 // Get action type distribution
4385 $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");
4386 $available_callbacks = $this->mxchat_get_available_callbacks();
4387 $callback_groups = $this->mxchat_get_available_callbacks(true, true);
4388
4389 $action_type_distribution = array();
4390 foreach ($type_distribution_raw as $row) {
4391 $label = isset($available_callbacks[$row->callback_function]['label'])
4392 ? $available_callbacks[$row->callback_function]['label']
4393 : $row->callback_function;
4394 $action_type_distribution[$label] = $row->count;
4395 }
4396
4397 // Native function-calling (AI Tools) data — plan-mxchat-20260617-a41dee.
4398 // The AI Tools checklist reads from MxChat_Tool_Registry, the SAME single
4399 // source the chat-time function-calling loop reads, so the two never drift.
4400 if (!class_exists('MxChat_Tool_Registry')) {
4401 require_once plugin_dir_path(__FILE__) . 'class-mxchat-tool-registry.php';
4402 }
4403 if (!class_exists('MxChat_Model_Catalog')) {
4404 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
4405 }
4406 $fc_options = get_option('mxchat_options', array());
4407 $fc_current_model = isset($fc_options['model']) ? $fc_options['model'] : 'gpt-5.6-sol';
4408 $fc_model_capable = class_exists('MxChat_Model_Catalog')
4409 ? MxChat_Model_Catalog::supports_tools($fc_current_model) : true;
4410 $active_tab = (isset($_GET['tab']) && $_GET['tab'] === 'ai-tools') ? 'ai-tools' : 'dashboard';
4411
4412 // Enrich each AI Tool with the dashicon its callback already uses on the
4413 // Trigger Phrases "Choose what it does" grid, so the AI Tools cards/modal
4414 // share the same iconography (plan 8bbf98 part 4). View-layer only — the
4415 // registry's model-facing data is untouched. Default admin-generic.
4416 $fc_tools = MxChat_Tool_Registry::available_tools();
4417 foreach ($fc_tools as &$fc_tool_ref) {
4418 $fc_cb_ref = $fc_tool_ref['callback'];
4419 $fc_tool_ref['icon'] = isset($available_callbacks[$fc_cb_ref]['icon'])
4420 ? $available_callbacks[$fc_cb_ref]['icon']
4421 : 'admin-generic';
4422 }
4423 unset($fc_tool_ref);
4424
4425 // Count of ACTIVE tools — drives the AI Tools sidebar nav badge, mirroring
4426 // $total_actions for Trigger Phrases. A tool in the list = active (plan
4427 // d450a7), so the badge shows the same number the list pane shows (plan 5f7409).
4428 $total_tools = count(array_filter($fc_tools, function ($t) {
4429 return !empty($t['enabled']);
4430 }));
4431
4432 // Brave-key dependency check (plan 183856): Web Search + Image Search run on
4433 // the Brave Search API. If either is enabled as a tool but no brave_api_key is
4434 // configured, surface a graceful "key not set" notice so the admin isn't met
4435 // with a silently no-firing tool.
4436 $fc_brave_missing = false;
4437 $brave_key = isset($fc_options['brave_api_key']) ? trim($fc_options['brave_api_key']) : '';
4438 if ($brave_key === '') {
4439 foreach ($fc_tools as $fc_t) {
4440 if (!empty($fc_t['enabled']) && isset($fc_t['requires_key']) && $fc_t['requires_key'] === 'brave_api_key') {
4441 $fc_brave_missing = true;
4442 break;
4443 }
4444 }
4445 }
4446
4447 // Prepare page data
4448 $page_data = array(
4449 'total_actions' => $total_actions,
4450 'enabled_actions' => $enabled_actions,
4451 'disabled_actions' => $disabled_actions,
4452 'action_types_count' => $action_types_count,
4453 'action_type_distribution' => $action_type_distribution,
4454 'available_callbacks' => $available_callbacks,
4455 'callback_groups' => $callback_groups,
4456 // AI Tools section
4457 'active_tab' => $active_tab,
4458 'fc_enabled' => MxChat_Tool_Registry::is_enabled(),
4459 'total_tools' => $total_tools,
4460 'fc_tools' => $fc_tools,
4461 'fc_current_model' => $fc_current_model,
4462 'fc_model_capable' => $fc_model_capable,
4463 'fc_brave_missing' => $fc_brave_missing,
4464 'fc_saved' => isset($_GET['mxchat_fc_saved']),
4465 );
4466
4467 // Include and render the new template
4468 require_once plugin_dir_path(__FILE__) . 'admin-actions-page.php';
4469 mxchat_render_actions_page($this, $page_data);
4470 }
4471
4472 /**
4473 * LEGACY HTML - Preserved below for reference, to be removed in future update
4474 */
4475 function mxchat_actions_page_legacy_html() {
4476 // This function is deprecated and no longer used
4477 // The new template is in includes/admin-actions-page.php
4478 ?>
4479 <div class="wrap mxchat-wrapper">
4480 <!-- Hero Section -->
4481 <div class="mxchat-hero">
4482 <h1 class="mxchat-main-title">
4483 <span class="mxchat-gradient-text">Actions</span> Manager
4484 </h1>
4485 <p class="mxchat-hero-subtitle">
4486 <?php esc_html_e('Create and manage custom actions to enhance your chatbot\'s capabilities.', 'mxchat'); ?>
4487 </p>
4488 </div>
4489
4490 <!-- Actions Header with Search and Filter -->
4491 <div class="mxchat-actions-header">
4492 <div class="mxchat-actions-filters">
4493 <form method="get" class="mxchat-search-form">
4494 <input type="hidden" name="page" value="mxchat-actions">
4495 <div class="mxchat-search-group">
4496 <span class="dashicons dashicons-search"></span>
4497 <input type="text" name="s" class="mxchat-search-input"
4498 placeholder="<?php esc_attr_e('Search Actions', 'mxchat'); ?>"
4499 value="<?php echo esc_attr($search_term); ?>">
4500 </div>
4501 <select name="callback_filter" class="mxchat-action-filter">
4502 <option value=""><?php esc_html_e('All Action Types', 'mxchat'); ?></option>
4503 <?php foreach ($available_callbacks as $function => $callback_data) :
4504 $label = $callback_data['label']; ?>
4505 <option value="<?php echo esc_attr($function); ?>"
4506 <?php selected($callback_filter, $function); ?>>
4507 <?php echo esc_html($label); ?>
4508 </option>
4509 <?php endforeach; ?>
4510 </select>
4511 <button type="submit" class="mxchat-button-secondary">
4512 <?php esc_html_e('Filter', 'mxchat'); ?>
4513 </button>
4514 </form>
4515 </div>
4516 <div class="mxchat-actions-controls">
4517 <button type="button" id="mxchat-add-action-btn" class="mxchat-button-primary">
4518 <span class="dashicons dashicons-plus-alt"></span>
4519 <?php esc_html_e('Add New Action', 'mxchat'); ?>
4520 </button>
4521 </div>
4522 </div>
4523
4524 <!-- Actions Grid Layout - All actions in a single grid -->
4525 <div class="mxchat-actions-grid">
4526 <div class="mxchat-cards-container">
4527 <?php if (!empty($actions)) : ?>
4528 <?php foreach ($actions as $action) :
4529 $callback_function = $action->callback_function;
4530 $callback_label = isset($available_callbacks[$callback_function]['label'])
4531 ? $available_callbacks[$callback_function]['label']
4532 : $callback_function;
4533 $threshold_value = isset($action->similarity_threshold)
4534 ? round($action->similarity_threshold * 100)
4535 : 85;
4536
4537 // Check if this is a form action
4538 $is_form_action = strpos($action->intent_label, 'Form ') === 0;
4539
4540 // Get action status (enabled/disabled) - default to true if column doesn't exist
4541 $is_enabled = isset($action->enabled) ? (bool)$action->enabled : true;
4542
4543 // Get enabled bots for display
4544 $enabled_bots = [];
4545 if (isset($action->enabled_bots) && !empty($action->enabled_bots)) {
4546 $enabled_bots = json_decode($action->enabled_bots, true);
4547 if (!is_array($enabled_bots)) {
4548 $enabled_bots = ['default'];
4549 }
4550 } else {
4551 $enabled_bots = ['default']; // Backward compatibility
4552 }
4553 ?>
4554 <div class="mxchat-action-card <?php echo $is_form_action ? 'mxchat-form-action' : ''; ?>">
4555 <div class="mxchat-card-header">
4556 <div class="mxchat-card-title"><?php echo esc_html($action->intent_label); ?></div>
4557 <div class="mxchat-card-toggle">
4558 <label class="mxchat-switch">
4559 <input type="checkbox" class="mxchat-action-toggle"
4560 data-action-id="<?php echo esc_attr($action->id); ?>"
4561 <?php checked($is_enabled); ?>>
4562 <span class="mxchat-slider round"></span>
4563 </label>
4564 </div>
4565 </div>
4566
4567 <div class="mxchat-card-body">
4568 <div class="mxchat-card-description">
4569 <strong><?php esc_html_e('Type:', 'mxchat'); ?></strong>
4570 <?php echo esc_html($callback_label); ?>
4571 </div>
4572
4573 <div class="mxchat-card-phrases">
4574 <strong><?php esc_html_e('Trigger phrases:', 'mxchat'); ?></strong>
4575 <div class="mxchat-phrases-preview">
4576 <?php
4577 // Check if the helper function exists, otherwise use a simple substring
4578 if (method_exists($this, 'get_trimmed_phrases')) {
4579 echo esc_html($this->get_trimmed_phrases($action->phrases));
4580 } else {
4581 echo esc_html(strlen($action->phrases) > 100 ?
4582 substr($action->phrases, 0, 97) . '...' :
4583 $action->phrases);
4584 }
4585 ?>
4586 </div>
4587 </div>
4588
4589 <div class="mxchat-threshold-control">
4590 <div class="mxchat-threshold-label">
4591 <?php esc_html_e('Similarity Threshold:', 'mxchat'); ?>
4592 <span class="mxchat-threshold-value"><?php echo esc_html($threshold_value); ?>%</span>
4593 </div>
4594 </div>
4595
4596 <!-- Bot Availability Display (Read-only) -->
4597 <div class="mxchat-card-bots">
4598 <strong><?php esc_html_e('Assigned bots:', 'mxchat'); ?></strong>
4599 <div class="mxchat-bot-badges">
4600 <?php
4601 foreach ($enabled_bots as $bot_id) {
4602 if ($bot_id === 'default') {
4603 echo '<span class="mxchat-bot-badge">' . esc_html__('Default Bot', 'mxchat') . '</span>';
4604 } else {
4605 // Try to get bot name from multi-bot manager
4606 if (class_exists('MxChat_Multi_Bot_Core_Manager')) {
4607 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4608 $available_bots = $multi_bot_manager->get_available_bots();
4609 $bot_name = isset($available_bots[$bot_id]) ? $available_bots[$bot_id] : $bot_id;
4610 echo '<span class="mxchat-bot-badge">' . esc_html($bot_name) . '</span>';
4611 } else {
4612 echo '<span class="mxchat-bot-badge">' . esc_html($bot_id) . '</span>';
4613 }
4614 }
4615 }
4616 ?>
4617 </div>
4618 </div>
4619 </div>
4620
4621 <div class="mxchat-card-footer">
4622 <?php
4623 // Check if it's a form action
4624 $is_form_action = preg_match('/Form (\d+)/', $action->intent_label, $form_matches);
4625
4626 // Check if it's a recommendation flow action
4627 $is_flow_action = preg_match('/Recommendation Flow (\d+)/', $action->intent_label, $flow_matches);
4628
4629 if ($is_form_action) {
4630 $form_id = isset($form_matches[1]) ? $form_matches[1] : '';
4631 ?>
4632 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-forms&action=edit&form_id=' . $form_id)); ?>"
4633 class="mxchat-button-primary">
4634 <span class="dashicons dashicons-feedback"></span>
4635 <?php esc_html_e('Edit Form', 'mxchat'); ?>
4636 </a>
4637 <?php } elseif ($is_flow_action) {
4638 ?>
4639 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-smart-recommender')); ?>"
4640 class="mxchat-button-primary">
4641 <span class="dashicons dashicons-list-view"></span>
4642 <?php esc_html_e('Manage Flows', 'mxchat'); ?>
4643 </a>
4644 <?php } else { ?>
4645 <button type="button"
4646 class="mxchat-button-secondary mxchat-edit-button"
4647 data-action-id="<?php echo esc_attr($action->id); ?>"
4648 data-phrases="<?php echo esc_attr($action->phrases); ?>"
4649 data-label="<?php echo esc_attr($action->intent_label); ?>"
4650 data-threshold="<?php echo esc_attr(round($action->similarity_threshold * 100)); ?>"
4651 data-callback-function="<?php echo esc_attr($action->callback_function); ?>"
4652 data-enabled-bots="<?php echo esc_attr(json_encode($enabled_bots)); ?>">
4653 <span class="dashicons dashicons-edit"></span>
4654 <?php esc_html_e('Edit', 'mxchat'); ?>
4655 </button>
4656 <form method="post"
4657 action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
4658 class="mxchat-delete-form"
4659 onsubmit="return confirm('<?php esc_attr_e('Are you sure you want to delete this action?', 'mxchat'); ?>');">
4660 <?php wp_nonce_field('mxchat_delete_intent_nonce'); ?>
4661 <input type="hidden" name="action" value="mxchat_delete_intent">
4662 <input type="hidden" name="intent_id" value="<?php echo esc_attr($action->id); ?>">
4663 <button type="submit" class="mxchat-button-text mxchat-delete-button">
4664 <span class="dashicons dashicons-trash"></span>
4665 <?php esc_html_e('Delete', 'mxchat'); ?>
4666 </button>
4667 </form>
4668 <?php } ?>
4669 </div>
4670 </div>
4671 <?php endforeach; ?>
4672 <?php else : ?>
4673 <!-- If no actions found -->
4674 <div class="mxchat-no-actions">
4675 <div class="mxchat-empty-state">
4676 <span class="dashicons dashicons-format-chat"></span>
4677 <h2><?php esc_html_e('No actions found', 'mxchat'); ?></h2>
4678 <p><?php esc_html_e('Get started by creating your first action to enhance your chatbot.', 'mxchat'); ?></p>
4679 <button type="button" id="mxchat-create-first-action" class="mxchat-button-primary">
4680 <?php esc_html_e('Create Your First Action', 'mxchat'); ?>
4681 </button>
4682 </div>
4683 </div>
4684 <?php endif; ?>
4685 </div>
4686 </div>
4687
4688 <?php if ($total_pages > 1) : ?>
4689 <div class="mxchat-pagination">
4690 <?php
4691 echo paginate_links(array(
4692 'base' => add_query_arg('paged', '%#%'),
4693 'format' => '',
4694 'prev_text' => __('&laquo; Previous', 'mxchat'),
4695 'next_text' => __('Next &raquo;', 'mxchat'),
4696 'total' => $total_pages,
4697 'current' => $page
4698 ));
4699 ?>
4700 </div>
4701 <?php endif; ?>
4702
4703 <!-- Add/Edit Action Modal with Step-Based Approach -->
4704 <!-- Complete Modal HTML with Defined Groups Variable -->
4705 <div id="mxchat-action-modal" class="mxchat-modal" style="display: none;">
4706 <div class="mxchat-modal-content">
4707 <span class="mxchat-modal-close">&times;</span>
4708
4709 <form id="mxchat-action-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
4710 <!-- Dynamic nonce field -->
4711 <div id="action-nonce-container">
4712 <?php wp_nonce_field('mxchat_add_intent_nonce', 'add_intent_nonce'); ?>
4713 </div>
4714 <input type="hidden" name="action" id="form_action_type" value="mxchat_add_intent">
4715 <input type="hidden" name="intent_id" id="edit_action_id" value="">
4716 <input type="hidden" name="callback_function" id="callback_function" value="">
4717
4718 <!-- Step 1: Action Type Selection -->
4719 <div id="mxchat-action-step-1" class="mxchat-action-step active">
4720 <div class="mxchat-step-indicator">
4721 <div class="mxchat-step-number">1</div>
4722 <div class="mxchat-step-title"><?php esc_html_e('Select Action Type', 'mxchat'); ?></div>
4723 </div>
4724
4725 <div id="mxchat-action-type-selector" class="mxchat-action-type-selector">
4726 <div class="mxchat-action-type-search">
4727 <span class="dashicons dashicons-search"></span>
4728 <input type="text" id="action-type-search" placeholder="<?php esc_attr_e('Search action types...', 'mxchat'); ?>" class="mxchat-action-type-search-input">
4729 </div>
4730
4731 <?php
4732 // Get the callbacks - IMPORTANT: Define the $groups variable here
4733 $groups = $this->mxchat_get_available_callbacks(true, true);
4734 ?>
4735
4736 <div class="mxchat-action-type-categories">
4737 <button type="button" class="mxchat-category-button active" data-category="all"><?php esc_html_e('All', 'mxchat'); ?></button>
4738 <?php
4739 // Get unique categories from the defined groups
4740 foreach ($groups as $group_label => $group_callbacks) :
4741 $category_slug = sanitize_title($group_label);
4742 ?>
4743 <button type="button" class="mxchat-category-button" data-category="<?php echo esc_attr($category_slug); ?>"><?php echo esc_html($group_label); ?></button>
4744 <?php endforeach; ?>
4745 </div>
4746
4747 <div class="mxchat-action-types-grid">
4748 <?php
4749 // Generate action cards from available callbacks
4750 foreach ($groups as $group_label => $group_callbacks) :
4751 $category_slug = sanitize_title($group_label);
4752
4753 foreach ($group_callbacks as $function => $data) :
4754 $label = $data['label'];
4755 $icon = isset($data['icon']) ? $data['icon'] : 'admin-generic';
4756 $description = isset($data['description']) ? $data['description'] : '';
4757 $is_addon = isset($data['addon']) && $data['addon'] !== false;
4758 $addon_name = isset($data['addon_name']) ? $data['addon_name'] : '';
4759 $is_installed = isset($data['installed']) ? $data['installed'] : true;
4760 $is_promo = !empty($data['addon_promo']) && !$is_installed;
4761
4762 // Determine card status and styling
4763 $card_class = 'mxchat-action-type-card';
4764 $icon_class = 'mxchat-action-type-icon';
4765 $status_badge = '';
4766
4767 if ($is_promo) {
4768 // Promotional card — not selectable, just informational
4769 $card_class .= ' not-installed mxchat-promo-card';
4770 $status_badge = '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
4771 } elseif ($is_addon && !$is_installed) {
4772 // Add-on not installed
4773 $card_class .= ' not-installed';
4774 $status_badge .= '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
4775 }
4776
4777 // Default description if none provided
4778 if (empty($description)) {
4779 $description = sprintf(
4780 esc_html__('Use the %s action in your chatbot', 'mxchat'),
4781 $label
4782 );
4783 }
4784 ?>
4785 <div class="<?php echo esc_attr($card_class); ?>"
4786 data-category="<?php echo esc_attr($category_slug); ?>"
4787 <?php if (!$is_promo) : ?>
4788 data-value="<?php echo esc_attr($function); ?>"
4789 data-label="<?php echo esc_attr($label); ?>"
4790 <?php endif; ?>
4791 data-pro="false"
4792 data-addon="<?php echo esc_attr($is_addon ? $data['addon'] : ''); ?>"
4793 data-installed="<?php echo $is_installed ? 'true' : 'false'; ?>"
4794 <?php if ($is_promo) : ?>data-promo="true"<?php endif; ?>>
4795 <div class="<?php echo esc_attr($icon_class); ?>">
4796 <span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span>
4797 </div>
4798 <div class="mxchat-action-type-info">
4799 <h4><?php echo esc_html($label); ?></h4>
4800 <p><?php echo esc_html($description); ?></p>
4801 <?php if (!empty($status_badge)) : ?>
4802 <?php echo $status_badge; ?>
4803 <?php endif; ?>
4804
4805 <?php if ($is_promo || ($is_addon && !$is_installed)) : ?>
4806 <div class="mxchat-addon-info">
4807 <?php echo esc_html(sprintf(
4808 __('Requires %s', 'mxchat'),
4809 $addon_name
4810 )); ?>
4811 — <a href="https://mxchat.ai/" target="_blank"><?php esc_html_e('Get Add-on', 'mxchat'); ?></a>
4812 </div>
4813 <?php endif; ?>
4814 </div>
4815 </div>
4816 <?php endforeach;
4817 endforeach; ?>
4818 </div>
4819 </div>
4820
4821 <div class="mxchat-modal-actions">
4822 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
4823 <?php esc_html_e('Cancel', 'mxchat'); ?>
4824 </button>
4825 </div>
4826 </div>
4827
4828 <!-- Step 2: Action Configuration -->
4829 <div id="mxchat-action-step-2" class="mxchat-action-step">
4830 <div class="mxchat-step-indicator">
4831 <div class="mxchat-step-number">2</div>
4832 <div class="mxchat-step-title"><?php esc_html_e('Configure Action', 'mxchat'); ?></div>
4833 </div>
4834
4835 <div class="mxchat-selected-action">
4836 <button type="button" class="mxchat-back-button" id="mxchat-back-to-step-1">
4837 <span class="dashicons dashicons-arrow-left-alt"></span>
4838 <?php esc_html_e('Back to Action Types', 'mxchat'); ?>
4839 </button>
4840 <div class="mxchat-selected-action-info">
4841 <div id="selected-action-icon" class="mxchat-action-type-icon">
4842 <span class="dashicons dashicons-admin-generic"></span>
4843 </div>
4844 <div class="mxchat-selected-action-details">
4845 <h3 id="selected-action-title"><?php esc_html_e('Selected Action', 'mxchat'); ?></h3>
4846 <p id="selected-action-description"><?php esc_html_e('Configure this action for your chatbot', 'mxchat'); ?></p>
4847 </div>
4848 </div>
4849 </div>
4850
4851 <div class="mxchat-form-group">
4852 <label for="intent_label">
4853 <?php esc_html_e('Action Label (For your reference only)', 'mxchat'); ?>
4854 </label>
4855 <input name="intent_label" type="text" id="intent_label" required
4856 class="mxchat-intent-input"
4857 placeholder="<?php esc_attr_e('Example: Newsletter Signup', 'mxchat'); ?>">
4858 </div>
4859
4860 <div class="mxchat-form-group">
4861 <label for="phrases">
4862 <?php esc_html_e('Trigger Phrases (comma-separated)', 'mxchat'); ?>
4863 </label>
4864 <textarea name="phrases" id="action_phrases" rows="5" required
4865 class="mxchat-intent-textarea"
4866 placeholder="<?php esc_attr_e('Example: sign me up, subscribe me, I want to join, add me to the newsletter', 'mxchat'); ?>"></textarea>
4867 </div>
4868
4869 <div class="mxchat-form-group">
4870 <label for="similarity_threshold">
4871 <?php esc_html_e('Similarity Threshold', 'mxchat'); ?>
4872 <span class="mxchat-threshold-value-display">85%</span>
4873 </label>
4874 <div class="mxchat-slider-group modal-slider">
4875 <input type="range"
4876 name="similarity_threshold"
4877 id="similarity_threshold"
4878 min="10"
4879 max="95"
4880 value="85"
4881 class="mxchat-intent-slider"
4882 oninput="document.querySelector('.mxchat-threshold-value-display').textContent = this.value + '%'">
4883 </div>
4884 <div class="mxchat-threshold-hint">
4885 <?php esc_html_e('Lower values (10-30) make the action trigger more easily. Higher values (70-95) require more exact matches.', 'mxchat'); ?>
4886 </div>
4887 </div>
4888
4889 <!-- Bot Selection Section -->
4890 <div class="mxchat-form-group">
4891 <label for="enabled_bots">
4892 <?php esc_html_e('Which bots should we enable this action for?', 'mxchat'); ?>
4893 </label>
4894 <div class="mxchat-bot-selector">
4895 <div class="mxchat-bot-option">
4896 <label class="mxchat-checkbox-label">
4897 <input type="checkbox"
4898 name="enabled_bots[]"
4899 value="default"
4900 id="bot_default"
4901 checked="checked">
4902 <span class="mxchat-checkmark"></span>
4903 <?php esc_html_e('Default Bot', 'mxchat'); ?>
4904 </label>
4905 </div>
4906
4907 <?php if (class_exists('MxChat_Multi_Bot_Core_Manager')) :
4908 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4909 $available_bots = $multi_bot_manager->get_available_bots();
4910
4911 foreach ($available_bots as $bot_id => $bot_name) :
4912 if ($bot_id === 'default') continue; // Skip default, already shown above
4913 ?>
4914 <div class="mxchat-bot-option">
4915 <label class="mxchat-checkbox-label">
4916 <input type="checkbox"
4917 name="enabled_bots[]"
4918 value="<?php echo esc_attr($bot_id); ?>"
4919 id="bot_<?php echo esc_attr($bot_id); ?>">
4920 <span class="mxchat-checkmark"></span>
4921 <?php echo esc_html($bot_name); ?>
4922 </label>
4923 </div>
4924 <?php
4925 endforeach;
4926 endif; ?>
4927 </div>
4928 </div>
4929
4930 <div class="mxchat-modal-actions">
4931 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
4932 <?php esc_html_e('Cancel', 'mxchat'); ?>
4933 </button>
4934 <button type="submit" class="mxchat-button-primary" id="mxchat-save-action-btn">
4935 <?php esc_html_e('Save Action', 'mxchat'); ?>
4936 </button>
4937 </div>
4938 </div>
4939 </form>
4940 </div>
4941 </div>
4942
4943 <div id="mxchat-action-loading" class="mxchat-action-loading" style="display: none;">
4944 <div class="mxchat-action-loading-spinner"></div>
4945 <div class="mxchat-action-loading-text">
4946 <?php esc_html_e('Saving action, please wait...', 'mxchat'); ?>
4947 </div>
4948 </div>
4949 </div><!-- .mxchat-wrapper -->
4950 <?php
4951 }
4952 private function get_trimmed_phrases($phrases, $max_length = 100) {
4953 if (strlen($phrases) <= $max_length) {
4954 return $phrases;
4955 }
4956
4957 $trimmed = substr($phrases, 0, $max_length);
4958 $last_comma = strrpos($trimmed, ',');
4959
4960 if ($last_comma !== false) {
4961 $trimmed = substr($trimmed, 0, $last_comma);
4962 }
4963
4964 return $trimmed . '...';
4965 }
4966 public function mxchat_add_enabled_column_to_intents() {
4967 global $wpdb;
4968 $table_name = $wpdb->prefix . 'mxchat_intents';
4969
4970 // Check if the column already exists
4971 $columns = $wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'enabled'");
4972
4973 if (empty($columns)) {
4974 // Add the column with default value of 1 (enabled)
4975 $wpdb->query("ALTER TABLE $table_name ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1");
4976 }
4977 }
4978
4979 public function mxchat_handle_delete_intent() {
4980 if ( ! current_user_can( 'manage_options' ) ) {
4981 wp_die( esc_html__('Unauthorized user', 'mxchat') );
4982 }
4983
4984 check_admin_referer('mxchat_delete_intent_nonce');
4985
4986 if (isset($_POST['intent_id'])) {
4987 global $wpdb;
4988 $table_name = $wpdb->prefix . 'mxchat_intents';
4989 $intent_id = intval($_POST['intent_id']);
4990
4991 $wpdb->delete($table_name, ['id' => $intent_id], ['%d']);
4992 }
4993
4994 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
4995 exit;
4996 }
4997 public function mxchat_handle_edit_intent() {
4998 // Security checks (nonce and permissions)
4999 if (!current_user_can('manage_options')) {
5000 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5001 }
5002 check_admin_referer('mxchat_edit_intent');
5003
5004 // Get POST data
5005 $intent_id = isset($_POST['intent_id']) ? absint($_POST['intent_id']) : 0;
5006 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5007 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5008 $threshold_percentage = isset($_POST['similarity_threshold']) ? intval($_POST['similarity_threshold']) : 85;
5009 $similarity_threshold = min(95, max(10, $threshold_percentage)) / 100; // Convert to 0.10–0.95
5010
5011 // Handle enabled_bots
5012 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5013 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5014
5015 // Ensure default is always included for backward compatibility
5016 if (!in_array('default', $enabled_bots)) {
5017 $enabled_bots[] = 'default';
5018 }
5019
5020 $enabled_bots_json = json_encode($enabled_bots);
5021
5022 // Validate inputs
5023 if (!$intent_id || empty($intent_label) || empty($phrases_input)) {
5024 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5025 return;
5026 }
5027
5028 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5029 if (empty($phrases_array)) {
5030 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5031 return;
5032 }
5033
5034 // Generate embeddings with improved error handling
5035 $vectors = [];
5036 $failed_phrases = [];
5037
5038 foreach ($phrases_array as $phrase) {
5039 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5040 if (is_array($embedding_vector)) {
5041 $vectors[] = $embedding_vector;
5042 } else {
5043 $failed_phrases[] = $phrase;
5044 }
5045 }
5046
5047 if (!empty($failed_phrases)) {
5048 $this->handle_embedding_error(
5049 sprintf(
5050 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5051 implode(', ', $failed_phrases)
5052 )
5053 );
5054 return;
5055 }
5056
5057 if (empty($vectors)) {
5058 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5059 return;
5060 }
5061
5062 $combined_vector = $this->mxchat_average_vectors($vectors);
5063 $serialized_vector = maybe_serialize($combined_vector);
5064
5065 // Update the database
5066 global $wpdb;
5067 $table_name = $wpdb->prefix . 'mxchat_intents';
5068
5069 $result = $wpdb->update(
5070 $table_name,
5071 array(
5072 'intent_label' => $intent_label,
5073 'phrases' => implode(', ', $phrases_array),
5074 'embedding_vector' => $serialized_vector,
5075 'similarity_threshold' => $similarity_threshold,
5076 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
5077 ),
5078 array('id' => $intent_id),
5079 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
5080 array('%d') // Where format: integer
5081 );
5082
5083 if (false === $result) {
5084 $this->handle_embedding_error(__('Failed to update action in database.', 'mxchat'));
5085 return;
5086 }
5087
5088 // Set success message and redirect
5089 set_transient('mxchat_admin_notice_success', __('Intent updated successfully!', 'mxchat'), 60);
5090
5091 $redirect_url = add_query_arg(
5092 array(
5093 'page' => 'mxchat-actions'
5094 ),
5095 admin_url('admin.php')
5096 );
5097 wp_safe_redirect($redirect_url);
5098 exit;
5099 }
5100 public function mxchat_handle_add_intent() {
5101 if (!current_user_can('manage_options')) {
5102 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5103 }
5104 check_admin_referer('mxchat_add_intent_nonce');
5105 global $wpdb;
5106 $table_name = $wpdb->prefix . 'mxchat_intents';
5107
5108 // Sanitize and get form data
5109 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5110 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5111 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5112
5113 // Get similarity threshold from form (convert percentage to decimal)
5114 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5115
5116 // Handle enabled_bots
5117 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5118 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5119
5120 // Ensure default is always included for backward compatibility with existing actions
5121 if (!in_array('default', $enabled_bots)) {
5122 $enabled_bots[] = 'default';
5123 }
5124
5125 $enabled_bots_json = json_encode($enabled_bots);
5126
5127 // Validate required fields
5128 if (empty($intent_label) || empty($callback_function) || empty($phrases_input)) {
5129 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5130 return;
5131 }
5132
5133 // Validate callback function
5134 $available_callbacks = $this->mxchat_get_available_callbacks();
5135 if (!array_key_exists($callback_function, $available_callbacks)) {
5136 $this->handle_embedding_error(__('Invalid callback function selected.', 'mxchat'));
5137 return;
5138 }
5139
5140 // Check if this is an add-on promotional placeholder (not a real action)
5141 if (!empty($available_callbacks[$callback_function]['addon_promo'])) {
5142 $addon_name = isset($available_callbacks[$callback_function]['addon_name']) ? $available_callbacks[$callback_function]['addon_name'] : __('an add-on', 'mxchat');
5143 $this->handle_embedding_error(sprintf(
5144 __('This action requires the %s to be installed and activated.', 'mxchat'),
5145 $addon_name
5146 ));
5147 return;
5148 }
5149
5150 // Process phrases
5151 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5152 if (empty($phrases_array)) {
5153 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5154 return;
5155 }
5156
5157 // Generate embeddings with improved error handling
5158 $vectors = [];
5159 $failed_phrases = [];
5160 foreach ($phrases_array as $phrase) {
5161 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5162 if (is_array($embedding_vector)) {
5163 $vectors[] = $embedding_vector;
5164 } else {
5165 $failed_phrases[] = $phrase;
5166 }
5167 }
5168
5169 // Check for embedding failures
5170 if (!empty($failed_phrases)) {
5171 $this->handle_embedding_error(
5172 sprintf(
5173 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5174 implode(', ', $failed_phrases)
5175 )
5176 );
5177 return;
5178 }
5179
5180 if (empty($vectors)) {
5181 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5182 return;
5183 }
5184
5185 // Create combined vector and insert into database
5186 $combined_vector = $this->mxchat_average_vectors($vectors);
5187 $serialized_vector = maybe_serialize($combined_vector);
5188
5189 $result = $wpdb->insert($table_name, [
5190 'intent_label' => $intent_label,
5191 'phrases' => implode(', ', $phrases_array),
5192 'embedding_vector' => $serialized_vector,
5193 'callback_function' => $callback_function,
5194 'similarity_threshold' => $similarity_threshold,
5195 'enabled_bots' => $enabled_bots_json, // NEW field
5196 ]);
5197
5198 if ($result === false) {
5199 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
5200 return;
5201 }
5202
5203 // Set success message and redirect
5204 set_transient('mxchat_admin_notice_success', __('New intent added successfully!', 'mxchat'), 60);
5205 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5206 exit;
5207 }
5208
5209
5210
5211
5212 private function handle_embedding_error($message, $redirect = true) {
5213 // Store the error message in the existing transient
5214 set_transient('mxchat_admin_notice_error', $message, 60);
5215
5216 if ($redirect) {
5217 // Redirect back to the actions page
5218 $redirect_url = add_query_arg(
5219 array(
5220 'page' => 'mxchat-actions'
5221 ),
5222 admin_url('admin.php')
5223 );
5224 wp_safe_redirect($redirect_url);
5225 exit;
5226 }
5227 }
5228
5229 /**
5230 * AJAX handler to fetch actions list for the new split-panel UI
5231 */
5232 public function mxchat_fetch_actions_list() {
5233 check_ajax_referer('mxchat_actions_nonce', 'security');
5234
5235 if (!current_user_can('manage_options')) {
5236 wp_send_json_error(__('Unauthorized', 'mxchat'));
5237 }
5238
5239 global $wpdb;
5240 $table_name = $wpdb->prefix . 'mxchat_intents';
5241
5242 $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
5243 $per_page = isset($_POST['per_page']) ? min(100, max(1, intval($_POST['per_page']))) : 50;
5244 $offset = ($page - 1) * $per_page;
5245 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
5246 $callback_filter = isset($_POST['callback_filter']) ? sanitize_text_field($_POST['callback_filter']) : '';
5247 $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
5248
5249 // Build WHERE clause
5250 $where = '1=1';
5251 $params = array();
5252
5253 if ($search) {
5254 $search_like = '%' . $wpdb->esc_like($search) . '%';
5255 $where .= ' AND (intent_label LIKE %s OR phrases LIKE %s)';
5256 $params[] = $search_like;
5257 $params[] = $search_like;
5258 }
5259
5260 if ($callback_filter) {
5261 $where .= ' AND callback_function = %s';
5262 $params[] = $callback_filter;
5263 }
5264
5265 // Get total count
5266 $count_query = "SELECT COUNT(*) FROM $table_name WHERE $where";
5267 if (!empty($params)) {
5268 $count_query = $wpdb->prepare($count_query, $params);
5269 }
5270 $total_actions = $wpdb->get_var($count_query);
5271
5272 // Get actions
5273 $query = "SELECT * FROM $table_name WHERE $where ORDER BY id $sort_order LIMIT %d OFFSET %d";
5274 $all_params = array_merge($params, array($per_page, $offset));
5275 $actions = $wpdb->get_results($wpdb->prepare($query, $all_params));
5276
5277 // Get available callbacks for labels/icons
5278 $available_callbacks = $this->mxchat_get_available_callbacks();
5279
5280 // Prefetch individual phrase counts for all fetched actions
5281 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5282 $phrase_counts = array();
5283 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5284 $action_ids = wp_list_pluck($actions, 'id');
5285 if (!empty($action_ids)) {
5286 $id_placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5287 $count_results = $wpdb->get_results($wpdb->prepare(
5288 "SELECT intent_id, COUNT(*) as cnt FROM $phrases_table WHERE intent_id IN ($id_placeholders) GROUP BY intent_id",
5289 $action_ids
5290 ));
5291 foreach ($count_results as $row) {
5292 $phrase_counts[$row->intent_id] = intval($row->cnt);
5293 }
5294 }
5295 }
5296
5297 // Format actions for response
5298 $formatted_actions = array();
5299 foreach ($actions as $action) {
5300 $callback_data = isset($available_callbacks[$action->callback_function])
5301 ? $available_callbacks[$action->callback_function]
5302 : array('label' => $action->callback_function, 'icon' => 'admin-generic');
5303
5304 $enabled_bots = json_decode($action->enabled_bots, true);
5305 if (!is_array($enabled_bots)) {
5306 $enabled_bots = array('default');
5307 }
5308
5309 $formatted_actions[] = array(
5310 'id' => intval($action->id),
5311 'label' => $action->intent_label,
5312 'phrases' => $action->phrases,
5313 'callback_function' => $action->callback_function,
5314 'callback_label' => $callback_data['label'],
5315 'icon' => isset($callback_data['icon']) ? $callback_data['icon'] : 'admin-generic',
5316 'threshold' => round($action->similarity_threshold * 100),
5317 'enabled' => (bool) $action->enabled,
5318 'enabled_bots' => $enabled_bots,
5319 'has_legacy_vector' => !empty($action->embedding_vector),
5320 'individual_phrase_count' => isset($phrase_counts[$action->id]) ? $phrase_counts[$action->id] : 0,
5321 );
5322 }
5323
5324 $total_pages = ceil($total_actions / $per_page);
5325 $showing_start = $total_actions > 0 ? $offset + 1 : 0;
5326 $showing_end = min($offset + $per_page, $total_actions);
5327
5328 wp_send_json_success(array(
5329 'actions' => $formatted_actions,
5330 'page' => $page,
5331 'per_page' => $per_page,
5332 'total_actions' => intval($total_actions),
5333 'total_pages' => $total_pages,
5334 'showing_start' => $showing_start,
5335 'showing_end' => $showing_end,
5336 ));
5337 }
5338
5339 /**
5340 * AJAX handler to toggle action enabled status
5341 */
5342 public function mxchat_toggle_action_status() {
5343 check_ajax_referer('mxchat_actions_nonce', 'security');
5344
5345 if (!current_user_can('manage_options')) {
5346 wp_send_json_error(__('Unauthorized', 'mxchat'));
5347 }
5348
5349 $action_id = isset($_POST['action_id']) ? intval($_POST['action_id']) : 0;
5350 $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
5351
5352 if (!$action_id) {
5353 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5354 }
5355
5356 global $wpdb;
5357 $table_name = $wpdb->prefix . 'mxchat_intents';
5358
5359 $result = $wpdb->update(
5360 $table_name,
5361 array('enabled' => $enabled ? 1 : 0),
5362 array('id' => $action_id),
5363 array('%d'),
5364 array('%d')
5365 );
5366
5367 if ($result === false) {
5368 wp_send_json_error(__('Failed to update action status', 'mxchat'));
5369 }
5370
5371 wp_send_json_success(array('enabled' => (bool) $enabled));
5372 }
5373
5374 /**
5375 * AJAX handler to bulk delete actions
5376 */
5377 public function mxchat_bulk_delete_actions() {
5378 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5379
5380 if (!current_user_can('manage_options')) {
5381 wp_send_json_error(__('Unauthorized', 'mxchat'));
5382 }
5383
5384 $action_ids = isset($_POST['action_ids']) ? array_map('intval', (array) $_POST['action_ids']) : array();
5385
5386 if (empty($action_ids)) {
5387 wp_send_json_error(__('No actions selected', 'mxchat'));
5388 }
5389
5390 global $wpdb;
5391 $table_name = $wpdb->prefix . 'mxchat_intents';
5392
5393 $placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5394 $query = $wpdb->prepare("DELETE FROM $table_name WHERE id IN ($placeholders)", $action_ids);
5395 $result = $wpdb->query($query);
5396
5397 if ($result === false) {
5398 wp_send_json_error(__('Failed to delete actions', 'mxchat'));
5399 }
5400
5401 // Also delete individual phrases for these intents
5402 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5403 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5404 $wpdb->query($wpdb->prepare("DELETE FROM $phrases_table WHERE intent_id IN ($placeholders)", $action_ids));
5405 }
5406
5407 wp_send_json_success(array('deleted' => $result));
5408 }
5409
5410 /**
5411 * AJAX handler to add a new intent/action
5412 */
5413 public function mxchat_add_intent_ajax() {
5414 check_ajax_referer('mxchat_add_intent_nonce', 'security');
5415
5416 if (!current_user_can('manage_options')) {
5417 wp_send_json_error(__('Unauthorized', 'mxchat'));
5418 }
5419
5420 global $wpdb;
5421 $table_name = $wpdb->prefix . 'mxchat_intents';
5422
5423 // Sanitize input
5424 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5425 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5426 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5427 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5428 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5429
5430 // Validate
5431 // Check if using new individual phrases mode or legacy mode
5432 $individual_phrases = isset($_POST['individual_phrases']) ? array_filter(array_map('sanitize_text_field', (array) $_POST['individual_phrases'])) : array();
5433 $use_individual = !empty($individual_phrases);
5434
5435 // Validate required fields (phrases not required when using individual mode)
5436 if (empty($intent_label) || empty($callback_function)) {
5437 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5438 }
5439 if (!$use_individual && empty($phrases_input)) {
5440 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5441 }
5442
5443 // Ensure default bot is included
5444 if (!in_array('default', $enabled_bots)) {
5445 $enabled_bots[] = 'default';
5446 }
5447 $enabled_bots_json = json_encode($enabled_bots);
5448
5449 if ($use_individual) {
5450 // New mode: individual phrases each get their own vector
5451 // Insert the intent row with empty legacy fields
5452 $result = $wpdb->insert(
5453 $table_name,
5454 array(
5455 'intent_label' => $intent_label,
5456 'phrases' => '',
5457 'embedding_vector' => '',
5458 'similarity_threshold' => $similarity_threshold,
5459 'callback_function' => $callback_function,
5460 'enabled' => 1,
5461 'enabled_bots' => $enabled_bots_json,
5462 )
5463 );
5464
5465 if ($result === false) {
5466 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5467 }
5468
5469 $intent_id = $wpdb->insert_id;
5470
5471 // Insert each phrase individually with its own embedding
5472 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5473 $failed_phrases = array();
5474 foreach ($individual_phrases as $phrase) {
5475 $phrase = trim($phrase);
5476 if (empty($phrase)) continue;
5477
5478 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5479 if (is_wp_error($embedding_vector)) {
5480 $failed_phrases[] = $phrase;
5481 continue;
5482 }
5483
5484 $wpdb->insert(
5485 $phrases_table,
5486 array(
5487 'intent_id' => $intent_id,
5488 'phrase' => $phrase,
5489 'embedding_vector' => maybe_serialize($embedding_vector),
5490 )
5491 );
5492 }
5493
5494 $response = array('id' => $intent_id);
5495 if (!empty($failed_phrases)) {
5496 $response['failed_phrases'] = $failed_phrases;
5497 }
5498 wp_send_json_success($response);
5499
5500 } else {
5501 // Legacy mode: combine all phrases into one embedding (backwards compatible)
5502 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5503 if (empty($phrases_array)) {
5504 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5505 }
5506
5507 // Generate embedding (combine phrases into single string for embedding)
5508 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5509 if (is_wp_error($embedding_vector)) {
5510 // Fallback: store without embedding
5511 $serialized_vector = null;
5512 } else {
5513 $serialized_vector = maybe_serialize($embedding_vector);
5514 }
5515
5516 // Insert
5517 $result = $wpdb->insert(
5518 $table_name,
5519 array(
5520 'intent_label' => $intent_label,
5521 'phrases' => implode(', ', $phrases_array),
5522 'embedding_vector' => $serialized_vector,
5523 'similarity_threshold' => $similarity_threshold,
5524 'callback_function' => $callback_function,
5525 'enabled' => 1,
5526 'enabled_bots' => $enabled_bots_json,
5527 )
5528 );
5529
5530 if ($result === false) {
5531 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5532 }
5533
5534 wp_send_json_success(array('id' => $wpdb->insert_id));
5535 }
5536 }
5537
5538 /**
5539 * AJAX handler to edit an existing intent/action
5540 */
5541 public function mxchat_edit_intent_ajax() {
5542 check_ajax_referer('mxchat_edit_intent', 'security');
5543
5544 if (!current_user_can('manage_options')) {
5545 wp_send_json_error(__('Unauthorized', 'mxchat'));
5546 }
5547
5548 global $wpdb;
5549 $table_name = $wpdb->prefix . 'mxchat_intents';
5550
5551 // Sanitize input
5552 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5553 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5554 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5555 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5556 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5557 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5558
5559 // Validate
5560 if (!$intent_id || empty($intent_label)) {
5561 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5562 }
5563
5564 // Check if phrases are managed individually (empty phrases_input means individual mode)
5565 $uses_individual_phrases = empty($phrases_input);
5566
5567 if ($uses_individual_phrases) {
5568 // Individual phrase mode: only update non-phrase fields, skip embedding regeneration
5569 $update_data = array(
5570 'intent_label' => $intent_label,
5571 'similarity_threshold' => $similarity_threshold,
5572 'callback_function' => $callback_function,
5573 'enabled_bots' => json_encode($enabled_bots),
5574 );
5575 } else {
5576 // Legacy mode: process phrases and regenerate embedding (backwards compatible for add-ons)
5577 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5578 if (empty($phrases_array)) {
5579 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5580 }
5581
5582 // Generate new embedding (combine phrases into single string for embedding)
5583 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5584 if (is_wp_error($embedding_vector)) {
5585 // Keep existing embedding
5586 $update_data = array(
5587 'intent_label' => $intent_label,
5588 'phrases' => implode(', ', $phrases_array),
5589 'similarity_threshold' => $similarity_threshold,
5590 'callback_function' => $callback_function,
5591 'enabled_bots' => json_encode($enabled_bots),
5592 );
5593 } else {
5594 $serialized_vector = maybe_serialize($embedding_vector);
5595 $update_data = array(
5596 'intent_label' => $intent_label,
5597 'phrases' => implode(', ', $phrases_array),
5598 'embedding_vector' => $serialized_vector,
5599 'similarity_threshold' => $similarity_threshold,
5600 'callback_function' => $callback_function,
5601 'enabled_bots' => json_encode($enabled_bots),
5602 );
5603 }
5604 }
5605
5606 // Ensure default bot is included
5607 if (!in_array('default', $enabled_bots)) {
5608 $enabled_bots[] = 'default';
5609 }
5610 $update_data['enabled_bots'] = json_encode($enabled_bots);
5611
5612 $result = $wpdb->update(
5613 $table_name,
5614 $update_data,
5615 array('id' => $intent_id),
5616 null,
5617 array('%d')
5618 );
5619
5620 if ($result === false) {
5621 wp_send_json_error(__('Failed to update action.', 'mxchat'));
5622 }
5623
5624 wp_send_json_success(array('updated' => true));
5625 }
5626
5627 /**
5628 * AJAX handler to delete a single intent/action
5629 */
5630 public function mxchat_delete_intent_ajax() {
5631 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5632
5633 if (!current_user_can('manage_options')) {
5634 wp_send_json_error(__('Unauthorized', 'mxchat'));
5635 }
5636
5637 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5638
5639 if (!$intent_id) {
5640 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5641 }
5642
5643 global $wpdb;
5644 $table_name = $wpdb->prefix . 'mxchat_intents';
5645
5646 $result = $wpdb->delete($table_name, array('id' => $intent_id), array('%d'));
5647
5648 if ($result === false) {
5649 wp_send_json_error(__('Failed to delete action', 'mxchat'));
5650 }
5651
5652 // Also delete individual phrases for this intent
5653 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5654 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5655 $wpdb->delete($phrases_table, array('intent_id' => $intent_id), array('%d'));
5656 }
5657
5658 wp_send_json_success(array('deleted' => true));
5659 }
5660
5661 /**
5662 * AJAX handler to add a single phrase with its own embedding to an intent
5663 */
5664 public function mxchat_add_phrase_ajax() {
5665 check_ajax_referer('mxchat_add_phrase_nonce', 'security');
5666
5667 if (!current_user_can('manage_options')) {
5668 wp_send_json_error(__('Unauthorized', 'mxchat'));
5669 }
5670
5671 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5672 $phrase = isset($_POST['phrase']) ? sanitize_text_field($_POST['phrase']) : '';
5673
5674 if (!$intent_id || empty($phrase)) {
5675 wp_send_json_error(__('Please provide an intent ID and phrase.', 'mxchat'));
5676 }
5677
5678 // Verify the intent exists
5679 global $wpdb;
5680 $intents_table = $wpdb->prefix . 'mxchat_intents';
5681 $intent = $wpdb->get_row($wpdb->prepare("SELECT id FROM $intents_table WHERE id = %d", $intent_id));
5682 if (!$intent) {
5683 wp_send_json_error(__('Action not found.', 'mxchat'));
5684 }
5685
5686 // Generate embedding for this single phrase
5687 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5688 if (is_wp_error($embedding_vector)) {
5689 wp_send_json_error(__('Failed to generate embedding: ', 'mxchat') . $embedding_vector->get_error_message());
5690 }
5691
5692 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5693 $result = $wpdb->insert(
5694 $phrases_table,
5695 array(
5696 'intent_id' => $intent_id,
5697 'phrase' => $phrase,
5698 'embedding_vector' => maybe_serialize($embedding_vector),
5699 )
5700 );
5701
5702 if ($result === false) {
5703 wp_send_json_error(__('Failed to add phrase.', 'mxchat'));
5704 }
5705
5706 wp_send_json_success(array('id' => $wpdb->insert_id, 'phrase' => $phrase));
5707 }
5708
5709 /**
5710 * AJAX handler to delete a single phrase from wp_mxchat_intent_phrases
5711 */
5712 public function mxchat_delete_phrase_ajax() {
5713 check_ajax_referer('mxchat_delete_phrase_nonce', 'security');
5714
5715 if (!current_user_can('manage_options')) {
5716 wp_send_json_error(__('Unauthorized', 'mxchat'));
5717 }
5718
5719 $phrase_id = isset($_POST['phrase_id']) ? intval($_POST['phrase_id']) : 0;
5720 if (!$phrase_id) {
5721 wp_send_json_error(__('Invalid phrase ID.', 'mxchat'));
5722 }
5723
5724 global $wpdb;
5725 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5726 $result = $wpdb->delete($phrases_table, array('id' => $phrase_id), array('%d'));
5727
5728 if ($result === false) {
5729 wp_send_json_error(__('Failed to delete phrase.', 'mxchat'));
5730 }
5731
5732 wp_send_json_success(array('deleted' => true));
5733 }
5734
5735 /**
5736 * AJAX handler to fetch individual phrases for an intent
5737 */
5738 public function mxchat_get_phrases_ajax() {
5739 check_ajax_referer('mxchat_get_phrases_nonce', 'security');
5740
5741 if (!current_user_can('manage_options')) {
5742 wp_send_json_error(__('Unauthorized', 'mxchat'));
5743 }
5744
5745 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5746 if (!$intent_id) {
5747 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
5748 }
5749
5750 global $wpdb;
5751 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5752
5753 $phrases = array();
5754 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5755 $phrases = $wpdb->get_results($wpdb->prepare(
5756 "SELECT id, phrase, created_at FROM $phrases_table WHERE intent_id = %d ORDER BY created_at ASC",
5757 $intent_id
5758 ));
5759 }
5760
5761 wp_send_json_success(array('phrases' => $phrases));
5762 }
5763
5764 /**
5765 * AJAX handler to clear legacy phrases and embedding from the main intents table
5766 */
5767 public function mxchat_delete_legacy_phrases_ajax() {
5768 check_ajax_referer('mxchat_delete_legacy_nonce', 'security');
5769
5770 if (!current_user_can('manage_options')) {
5771 wp_send_json_error(__('Unauthorized', 'mxchat'));
5772 }
5773
5774 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5775 if (!$intent_id) {
5776 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
5777 }
5778
5779 global $wpdb;
5780 $table_name = $wpdb->prefix . 'mxchat_intents';
5781
5782 $result = $wpdb->update(
5783 $table_name,
5784 array('phrases' => '', 'embedding_vector' => ''),
5785 array('id' => $intent_id),
5786 array('%s', '%s'),
5787 array('%d')
5788 );
5789
5790 if ($result === false) {
5791 wp_send_json_error(__('Failed to clear legacy phrases.', 'mxchat'));
5792 }
5793
5794 wp_send_json_success(array('cleared' => true));
5795 }
5796
5797 /**
5798 * Enhanced get_available_callbacks function with form action exclusion
5799 *
5800 * @param bool $grouped Whether to return callbacks grouped by category
5801 * @param bool $include_all Whether to include all potential actions (even if add-on not installed)
5802 * @return array Callbacks data with icons, descriptions and availability status
5803 */
5804 private function mxchat_get_available_callbacks($grouped = false, $include_all = true) {
5805 // Load WordPress plugin functions if needed
5806 if (!function_exists('get_plugins')) {
5807 require_once ABSPATH . 'wp-admin/includes/plugin.php';
5808 }
5809
5810 // Get active plugins
5811 $active_plugins = get_option('active_plugins', array());
5812
5813 // Functions to exclude from the action selector only if Pro is activated
5814 // If user doesn't have Pro, show these so they can see what they're missing
5815 $excluded_when_pro_active_functions = array(
5816 'mxchat_handle_form_collection' // Forms add-on action
5817 );
5818
5819 // Always excluded functions (regardless of Pro status)
5820 $always_excluded_functions = array();
5821
5822 // Combine exclusion lists based on Pro activation status
5823 $excluded_functions = $always_excluded_functions;
5824 if ($this->is_activated) {
5825 // Only exclude add-on managed functions if Pro is active
5826 $excluded_functions = array_merge($excluded_functions, $excluded_when_pro_active_functions);
5827 }
5828
5829 // Define add-on plugin files and their corresponding action functions
5830 $addon_plugins = array(
5831 'mxchat-woo/mxchat-woo.php' => array(
5832 'functions' => array(
5833 'mxchat_handle_product_recommendations',
5834 'mxchat_handle_order_history',
5835 'mxchat_show_product_card',
5836 'mxchat_add_to_cart',
5837 'mxchat_checkout_redirect',
5838 'mxchat_handle_featured_products'
5839 ),
5840 'name' => __('WooCommerce Add-on', 'mxchat'),
5841 'pro_required' => true
5842 ),
5843 'mxchat-perplexity/mxchat-perplexity.php' => array(
5844 'functions' => array('mxchat_perplexity_research'),
5845 'name' => __('Perplexity Add-on', 'mxchat'),
5846 'pro_required' => true
5847 ),
5848 'mxchat-forms/mxchat-forms.php' => array(
5849 'functions' => array('mxchat_handle_form_collection'),
5850 'name' => __('Forms Add-on', 'mxchat'),
5851 'pro_required' => true
5852 ),
5853 // Add other add-ons and their functions here
5854 );
5855
5856 // Get the functions that are provided by active add-ons
5857 $addon_provided_functions = array();
5858 $addon_function_mapping = array(); // Maps functions to their add-on info
5859
5860 // Check which add-ons are active
5861 foreach ($addon_plugins as $plugin_file => $addon_info) {
5862 $is_active = in_array($plugin_file, $active_plugins);
5863
5864 // For each function in this addon
5865 foreach ($addon_info['functions'] as $function) {
5866 // Consider a function installed only if:
5867 // 1. The add-on is active AND
5868 // 2. Either it doesn't require Pro OR Pro is activated
5869 $is_installed = $is_active && (!$addon_info['pro_required'] || $this->is_activated);
5870
5871 // If the add-on is installed, mark this function as provided by an add-on
5872 if ($is_installed) {
5873 $addon_provided_functions[] = $function;
5874 }
5875
5876 // Store addon info for this function regardless of installation status
5877 $addon_function_mapping[$function] = array(
5878 'addon' => basename(dirname($plugin_file)),
5879 'addon_name' => $addon_info['name'],
5880 'pro_required' => $addon_info['pro_required'],
5881 'is_active' => $is_active,
5882 'is_installed' => $is_installed
5883 );
5884 }
5885 }
5886
5887 // Core callbacks - always available in the base plugin
5888 $core_callbacks = array(
5889 'mxchat_handle_email_capture' => array(
5890 'label' => __('Loops Email Capture', 'mxchat'),
5891 'pro_only' => false,
5892 'group' => __('Customer Engagement', 'mxchat'),
5893 'icon' => 'email-alt',
5894 'description' => __('Collect visitor emails for your mailing list in Loops', 'mxchat'),
5895 'addon' => false, // Not from an add-on
5896 'installed' => true // Always installed with base plugin
5897 ),
5898 'mxchat_handle_search_request' => array(
5899 'label' => __('Brave Web Search', 'mxchat'),
5900 'pro_only' => false,
5901 'group' => __('Search Features', 'mxchat'),
5902 'icon' => 'search',
5903 'description' => __('Let users search the web directly from the chat (requires a Brave Search API key)', 'mxchat'),
5904 'addon' => false,
5905 'installed' => true
5906 ),
5907 'mxchat_handle_image_search_request' => array(
5908 'label' => __('Brave Image Search', 'mxchat'),
5909 'pro_only' => false,
5910 'group' => __('Search Features', 'mxchat'),
5911 'icon' => 'format-image',
5912 'description' => __('Search and display images in the chat conversation (requires a Brave Search API key)', 'mxchat'),
5913 'addon' => false,
5914 'installed' => true
5915 ),
5916 // Pro core features - check is_activated property
5917 'mxchat_generate_image' => array(
5918 'label' => __('Generate Image (OpenAI)', 'mxchat'),
5919 'pro_only' => false,
5920 'group' => __('Other Features', 'mxchat'),
5921 'icon' => 'art',
5922 'description' => __('Create images with GPT Image from OpenAI (requires OpenAI API key)', 'mxchat'),
5923 'addon' => false,
5924 'installed' => true
5925 ),
5926 'mxchat_generate_gemini_image' => array(
5927 'label' => __('Generate Image (Gemini)', 'mxchat'),
5928 'pro_only' => false,
5929 'group' => __('Other Features', 'mxchat'),
5930 'icon' => 'art',
5931 'description' => __('Create images with Imagen from Google (requires Gemini API key)', 'mxchat'),
5932 'addon' => false,
5933 'installed' => true
5934 ),
5935 'mxchat_handle_pdf_discussion' => array(
5936 'label' => __('Chat with PDF', 'mxchat'),
5937 'pro_only' => false,
5938 'group' => __('Other Features', 'mxchat'),
5939 'icon' => 'media-document',
5940 'description' => __('Answer questions about uploaded PDF documents', 'mxchat'),
5941 'addon' => false,
5942 'installed' => true
5943 ),
5944 'mxchat_live_agent_handover' => array(
5945 'label' => __('Slack Live Agent', 'mxchat'),
5946 'pro_only' => false,
5947 'group' => __('Customer Engagement', 'mxchat'),
5948 'icon' => 'admin-users',
5949 'description' => __('Transfer conversation to a human support agent on Slack', 'mxchat'),
5950 'addon' => false,
5951 'installed' => true
5952 ),
5953 'mxchat_telegram_live_agent_handover' => array(
5954 'label' => __('Telegram Live Agent', 'mxchat'),
5955 'pro_only' => false,
5956 'group' => __('Customer Engagement', 'mxchat'),
5957 'icon' => 'format-chat',
5958 'description' => __('Transfer conversation to a human support agent on Telegram', 'mxchat'),
5959 'addon' => false,
5960 'installed' => true
5961 ),
5962 'mxchat_handle_switch_to_chatbot_intent' => array(
5963 'label' => __('Back to Chatbot', 'mxchat'),
5964 'pro_only' => false,
5965 'group' => __('Customer Engagement', 'mxchat'),
5966 'icon' => 'backup',
5967 'description' => __('Return from live agent mode to AI chatbot', 'mxchat'),
5968 'addon' => false,
5969 'installed' => true
5970 ),
5971 );
5972
5973 // Add-on callbacks with placeholders - only include if the add-on is NOT active
5974 // These are promotional/informational only — not selectable as real actions
5975 $addon_callbacks = array(
5976 // WooCommerce Add-on
5977 'mxchat_handle_product_recommendations' => array(
5978 'label' => __('Product Recommendations', 'mxchat'),
5979 'pro_only' => false,
5980 'addon_promo' => true,
5981 'group' => __('WooCommerce Features', 'mxchat'),
5982 'icon' => 'cart',
5983 'description' => __('Suggest products based on customer preferences', 'mxchat'),
5984 ),
5985 'mxchat_handle_order_history' => array(
5986 'label' => __('Order History', 'mxchat'),
5987 'pro_only' => false,
5988 'addon_promo' => true,
5989 'group' => __('WooCommerce Features', 'mxchat'),
5990 'icon' => 'clipboard',
5991 'description' => __('Allow customers to check their order status', 'mxchat'),
5992 ),
5993 'mxchat_show_product_card' => array(
5994 'label' => __('Show Product Card', 'mxchat'),
5995 'pro_only' => false,
5996 'addon_promo' => true,
5997 'group' => __('WooCommerce Features', 'mxchat'),
5998 'icon' => 'products',
5999 'description' => __('Display product information in the chat', 'mxchat'),
6000 ),
6001 'mxchat_add_to_cart' => array(
6002 'label' => __('Add to Cart', 'mxchat'),
6003 'pro_only' => false,
6004 'addon_promo' => true,
6005 'group' => __('WooCommerce Features', 'mxchat'),
6006 'icon' => 'plus-alt',
6007 'description' => __('Add products to cart directly from chat', 'mxchat'),
6008 ),
6009 'mxchat_checkout_redirect' => array(
6010 'label' => __('Proceed to Checkout', 'mxchat'),
6011 'pro_only' => false,
6012 'addon_promo' => true,
6013 'group' => __('WooCommerce Features', 'mxchat'),
6014 'icon' => 'arrow-right-alt',
6015 'description' => __('Redirect customer to checkout page', 'mxchat'),
6016 ),
6017 'mxchat_handle_featured_products' => array(
6018 'label' => __('Featured Products Showcase', 'mxchat'),
6019 'pro_only' => false,
6020 'addon_promo' => true,
6021 'group' => __('WooCommerce Features', 'mxchat'),
6022 'icon' => 'star-filled',
6023 'description' => __('Display a curated selection of products with an AI-generated message', 'mxchat'),
6024 ),
6025
6026 // Perplexity Add-on
6027 'mxchat_perplexity_research' => array(
6028 'label' => __('Perplexity Research', 'mxchat'),
6029 'pro_only' => false,
6030 'addon_promo' => true,
6031 'group' => __('Search Features', 'mxchat'),
6032 'icon' => 'book-alt',
6033 'description' => __('Allows the chatbot to search the web for accurate, up-to-date answers', 'mxchat'),
6034 ),
6035
6036 // Forms Add-on
6037 'mxchat_handle_form_collection' => array(
6038 'label' => __('Form Collection', 'mxchat'),
6039 'pro_only' => false,
6040 'addon_promo' => true,
6041 'group' => __('Form Features', 'mxchat'),
6042 'icon' => 'feedback',
6043 'description' => __('Collect user information through custom forms in chat', 'mxchat'),
6044 ),
6045 );
6046
6047 // Enhance add-on callbacks with installation status and addon info
6048 foreach ($addon_callbacks as $function => $data) {
6049 if (isset($addon_function_mapping[$function])) {
6050 $addon_info = $addon_function_mapping[$function];
6051
6052 $addon_callbacks[$function]['addon'] = $addon_info['addon'];
6053 $addon_callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6054 $addon_callbacks[$function]['installed'] = $addon_info['is_installed'];
6055
6056 // Set pro_only based on add-on configuration
6057 $addon_callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6058 } else {
6059 $addon_callbacks[$function]['addon'] = 'unknown';
6060 $addon_callbacks[$function]['addon_name'] = __('Unknown Add-on', 'mxchat');
6061 $addon_callbacks[$function]['installed'] = false;
6062 }
6063 }
6064
6065 // Initialize callbacks with core features
6066 $callbacks = $core_callbacks;
6067
6068 // Get callbacks from active add-ons
6069 $active_addon_callbacks = apply_filters('mxchat_available_callbacks', array());
6070
6071 // Add placeholder callbacks only for add-ons that aren't active
6072 if ($include_all) {
6073 foreach ($addon_callbacks as $function => $data) {
6074 // Skip placeholders for functions provided by active add-ons
6075 if (in_array($function, $addon_provided_functions)) {
6076 continue;
6077 }
6078
6079 // Skip excluded functions
6080 if (in_array($function, $excluded_functions)) {
6081 continue;
6082 }
6083
6084 // Add the placeholder
6085 $callbacks[$function] = $data;
6086 }
6087 }
6088
6089 // Add callbacks from active add-ons (will override placeholders)
6090 foreach ($active_addon_callbacks as $function => $data) {
6091 // Skip excluded functions
6092 if (in_array($function, $excluded_functions)) {
6093 continue;
6094 }
6095
6096 // Always include callbacks from add-ons
6097 $callbacks[$function] = $data;
6098
6099 // Ensure they have the proper add-on info
6100 if (isset($addon_function_mapping[$function])) {
6101 $addon_info = $addon_function_mapping[$function];
6102 $callbacks[$function]['addon'] = $addon_info['addon'];
6103 $callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6104 $callbacks[$function]['installed'] = $addon_info['is_installed'];
6105 $callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6106 }
6107 }
6108
6109 // Just before returning callbacks, sort them to prioritize free features
6110 if (!$grouped) {
6111 // Create temporary arrays for sorting
6112 $free_callbacks = array();
6113 $pro_callbacks = array();
6114
6115 // Split callbacks into free and pro
6116 foreach ($callbacks as $key => $data) {
6117 if (isset($data['pro_only']) && $data['pro_only']) {
6118 $pro_callbacks[$key] = $data;
6119 } else {
6120 $free_callbacks[$key] = $data;
6121 }
6122 }
6123
6124 // Merge with free callbacks first
6125 $callbacks = array_merge($free_callbacks, $pro_callbacks);
6126 }
6127
6128 // Return grouped structure if requested
6129 if ($grouped) {
6130 $grouped_callbacks = array();
6131 foreach ($callbacks as $key => $data) {
6132 $group_label = isset($data['group']) ? $data['group'] : __('Other Features', 'mxchat');
6133
6134 // Ensure we carry forward all the new fields in grouped mode
6135 $callback_data = array(
6136 'label' => $data['label'],
6137 'pro_only' => isset($data['pro_only']) ? $data['pro_only'] : false,
6138 'icon' => isset($data['icon']) ? $data['icon'] : 'admin-generic',
6139 'description' => isset($data['description']) ? $data['description'] : __('Custom action for your chatbot', 'mxchat'),
6140 'addon' => isset($data['addon']) ? $data['addon'] : false,
6141 'addon_name' => isset($data['addon_name']) ? $data['addon_name'] : '',
6142 'installed' => isset($data['installed']) ? $data['installed'] : true
6143 );
6144
6145 $grouped_callbacks[$group_label][$key] = $callback_data;
6146 }
6147
6148 // Sort within each group to prioritize free features
6149 foreach ($grouped_callbacks as $group => $items) {
6150 $free_items = array();
6151 $pro_items = array();
6152
6153 foreach ($items as $key => $data) {
6154 if (isset($data['pro_only']) && $data['pro_only']) {
6155 $pro_items[$key] = $data;
6156 } else {
6157 $free_items[$key] = $data;
6158 }
6159 }
6160
6161 $grouped_callbacks[$group] = array_merge($free_items, $pro_items);
6162 }
6163
6164 return $grouped_callbacks;
6165 }
6166
6167 return $callbacks;
6168 }
6169
6170 public function mxchat_page_init() {
6171 register_setting(
6172 'mxchat_option_group',
6173 'mxchat_options',
6174 array($this, 'mxchat_sanitize')
6175 );
6176
6177 register_setting(
6178 'mxchat_option_group',
6179 'mxchat_similarity_threshold',
6180 array(
6181 'type' => 'number',
6182 'sanitize_callback' => function($value) {
6183 $value = absint($value);
6184 return min(max($value, 20), 95);
6185 },
6186 'default' => 80,
6187 )
6188 );
6189
6190 // Chatbot Settings Section
6191 add_settings_section(
6192 'mxchat_chatbot_section',
6193 esc_html__('Chatbot Settings', 'mxchat'),
6194 null,
6195 'mxchat-chatbot'
6196 );
6197
6198 // API Keys Settings Section
6199 add_settings_section(
6200 'mxchat_api_keys_section',
6201 esc_html__('API Keys', 'mxchat'),
6202 array($this, 'mxchat_api_keys_section_callback'),
6203 'mxchat-api-keys'
6204 );
6205
6206 // OpenAI API Key
6207 add_settings_field(
6208 'api_key',
6209 esc_html__('OpenAI API Key', 'mxchat'),
6210 array($this, 'api_key_callback'),
6211 'mxchat-api-keys',
6212 'mxchat_api_keys_section'
6213 );
6214
6215 // X.AI API Key
6216 add_settings_field(
6217 'xai_api_key',
6218 esc_html__('X.AI API Key', 'mxchat'),
6219 array($this, 'xai_api_key_callback'),
6220 'mxchat-api-keys',
6221 'mxchat_api_keys_section'
6222 );
6223
6224 // Claude API Key
6225 add_settings_field(
6226 'claude_api_key',
6227 esc_html__('Claude API Key', 'mxchat'),
6228 array($this, 'claude_api_key_callback'),
6229 'mxchat-api-keys',
6230 'mxchat_api_keys_section'
6231 );
6232
6233 // DeepSeek API Key
6234 add_settings_field(
6235 'deepseek_api_key',
6236 esc_html__('DeepSeek API Key', 'mxchat'),
6237 array($this, 'deepseek_api_key_callback'),
6238 'mxchat-api-keys',
6239 'mxchat_api_keys_section'
6240 );
6241
6242 // Google Gemini API Key
6243 add_settings_field(
6244 'gemini_api_key',
6245 esc_html__('Google Gemini API Key', 'mxchat'),
6246 array($this, 'gemini_api_key_callback'),
6247 'mxchat-api-keys',
6248 'mxchat_api_keys_section'
6249 );
6250
6251 // Voyage AI API Key
6252 add_settings_field(
6253 'voyage_api_key',
6254 esc_html__('Voyage AI API Key', 'mxchat'),
6255 array($this, 'voyage_api_key_callback'),
6256 'mxchat-api-keys',
6257 'mxchat_api_keys_section'
6258 );
6259
6260 // OpenRouter API Key
6261 add_settings_field(
6262 'openrouter_api_key',
6263 esc_html__('OpenRouter API Key', 'mxchat'),
6264 array($this, 'openrouter_api_key_callback'),
6265 'mxchat-api-keys',
6266 'mxchat_api_keys_section'
6267 );
6268
6269 // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6270 add_settings_field(
6271 'custom_provider',
6272 esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6273 array($this, 'custom_provider_callback'),
6274 'mxchat-api-keys',
6275 'mxchat_api_keys_section'
6276 );
6277
6278 // Loops API Key
6279 add_settings_field(
6280 'loops_api_key',
6281 esc_html__('Loops API Key', 'mxchat'),
6282 array($this, 'mxchat_loops_api_key_callback'),
6283 'mxchat-api-keys',
6284 'mxchat_api_keys_section'
6285 );
6286
6287 // Brave Search API Key
6288 add_settings_field(
6289 'brave_api_key',
6290 __('Brave API Key', 'mxchat'),
6291 array($this, 'mxchat_brave_api_key_callback'),
6292 'mxchat-api-keys',
6293 'mxchat_api_keys_section'
6294 );
6295
6296 // Similarity Threshold Slider
6297 add_settings_field(
6298 'similarity_threshold', // Field ID
6299 esc_html__('Similarity Threshold', 'mxchat'), // Field title
6300 array($this, 'mxchat_similarity_threshold_callback'), // Callback function
6301 'mxchat-chatbot', // Page
6302 'mxchat_chatbot_section' // Section
6303 );
6304
6305 // RAG Sources Limit Slider
6306 add_settings_field(
6307 'rag_sources_limit', // Field ID
6308 esc_html__('RAG Sources Limit', 'mxchat'), // Field title
6309 array($this, 'mxchat_rag_sources_limit_callback'), // Callback function
6310 'mxchat-chatbot', // Page
6311 'mxchat_chatbot_section' // Section
6312 );
6313
6314 // RAG Chunks Limit Slider
6315 add_settings_field(
6316 'rag_chunks_limit', // Field ID
6317 esc_html__('RAG Chunks Limit', 'mxchat'), // Field title
6318 array($this, 'mxchat_rag_chunks_limit_callback'), // Callback function
6319 'mxchat-chatbot', // Page
6320 'mxchat_chatbot_section' // Section
6321 );
6322
6323 add_settings_field(
6324 'append_to_body',
6325 esc_html__('Auto-Display Chatbot', 'mxchat'),
6326 array($this, 'mxchat_append_to_body_callback'),
6327 'mxchat-chatbot',
6328 'mxchat_chatbot_section'
6329 );
6330
6331 add_settings_field(
6332 'contextual_awareness_toggle',
6333 esc_html__('Contextual Awareness', 'mxchat'),
6334 array($this, 'mxchat_contextual_awareness_callback'),
6335 'mxchat-chatbot',
6336 'mxchat_chatbot_section'
6337 );
6338
6339 add_settings_field(
6340 'citation_links_toggle',
6341 esc_html__('Citation Links', 'mxchat'),
6342 array($this, 'mxchat_citation_links_toggle_callback'),
6343 'mxchat-chatbot',
6344 'mxchat_chatbot_section'
6345 );
6346
6347 // Satisfaction rating toggle (plan-a5b006).
6348 add_settings_field(
6349 'satisfaction_rating_enabled',
6350 esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6351 array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6352 'mxchat-chatbot',
6353 'mxchat_chatbot_section'
6354 );
6355
6356 // Satisfaction rating customization (plan-141a12, plan-29caac):
6357 // the 5 customization fields are now inline-rendered inside
6358 // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6359
6360 add_settings_field(
6361 'enable_streaming_toggle',
6362 esc_html__('Enable Streaming', 'mxchat'),
6363 array($this, 'enable_streaming_toggle_callback'),
6364 'mxchat-chatbot',
6365 'mxchat_chatbot_section', // Same section as your working toggle
6366 array(
6367 'class' => 'mxchat-setting-row streaming-setting',
6368 'style' => 'display: none;' // Hidden by default, shown when OpenAI/Claude selected
6369 )
6370 );
6371
6372
6373 add_settings_field(
6374 'model',
6375 esc_html__('Chat Model', 'mxchat'),
6376 array($this, 'mxchat_model_callback'),
6377 'mxchat-chatbot',
6378 'mxchat_chatbot_section'
6379 );
6380
6381 add_settings_field(
6382 'embedding_model',
6383 esc_html__('Embedding Model', 'mxchat'),
6384 array($this, 'embedding_model_callback'),
6385 'mxchat-chatbot',
6386 'mxchat_chatbot_section'
6387 );
6388
6389 add_settings_field(
6390 'system_prompt_instructions',
6391 esc_html__('AI Instructions (Behavior)', 'mxchat'),
6392 array($this, 'system_prompt_instructions_callback'),
6393 'mxchat-chatbot',
6394 'mxchat_chatbot_section'
6395 );
6396
6397
6398 add_settings_field(
6399 'top_bar_title',
6400 esc_html__('Top Bar Title', 'mxchat'),
6401 array($this, 'mxchat_top_bar_title_callback'),
6402 'mxchat-chatbot',
6403 'mxchat_chatbot_section'
6404 );
6405
6406 add_settings_field(
6407 'ai_agent_text',
6408 esc_html__('AI Agent Text', 'mxchat'),
6409 array($this, 'mxchat_ai_agent_text_callback'),
6410 'mxchat-chatbot',
6411 'mxchat_chatbot_section'
6412 );
6413
6414 add_settings_field(
6415 'enable_email_block',
6416 esc_html__('Require Email To Chat', 'mxchat'),
6417 array($this, 'enable_email_block_callback'),
6418 'mxchat-chatbot',
6419 'mxchat_chatbot_section'
6420 );
6421
6422 add_settings_field(
6423 'email_blocker_header_content',
6424 esc_html__('Require Email Chat Content', 'mxchat'),
6425 array($this, 'email_blocker_header_content_callback'),
6426 'mxchat-chatbot',
6427 'mxchat_chatbot_section'
6428 );
6429
6430 add_settings_field(
6431 'email_blocker_button_text',
6432 esc_html__('Require Email Chat Button Text', 'mxchat'),
6433 [$this, 'email_blocker_button_text_callback'],
6434 'mxchat-chatbot',
6435 'mxchat_chatbot_section'
6436 );
6437
6438 add_settings_field(
6439 'enable_name_field',
6440 esc_html__('Require Name Field', 'mxchat'),
6441 array($this, 'enable_name_field_callback'),
6442 'mxchat-chatbot',
6443 'mxchat_chatbot_section'
6444 );
6445
6446 add_settings_field(
6447 'name_field_placeholder',
6448 esc_html__('Name Field Placeholder', 'mxchat'),
6449 array($this, 'name_field_placeholder_callback'),
6450 'mxchat-chatbot',
6451 'mxchat_chatbot_section'
6452 );
6453
6454 add_settings_field(
6455 'intro_message',
6456 esc_html__('Introductory Message', 'mxchat'),
6457 array($this, 'mxchat_intro_message_callback'),
6458 'mxchat-chatbot',
6459 'mxchat_chatbot_section'
6460 );
6461
6462 add_settings_field(
6463 'input_copy',
6464 esc_html__('Input Copy', 'mxchat'),
6465 array($this, 'mxchat_input_copy_callback'),
6466 'mxchat-chatbot',
6467 'mxchat_chatbot_section'
6468 );
6469
6470 add_settings_field(
6471 'pre_chat_message',
6472 esc_html__('Chat Teaser Pop-up', 'mxchat'),
6473 array($this, 'mxchat_pre_chat_message_callback'),
6474 'mxchat-chatbot',
6475 'mxchat_chatbot_section'
6476 );
6477
6478 add_settings_field(
6479 'privacy_toggle',
6480 esc_html__('Toggle Privacy Notice', 'mxchat'),
6481 array($this, 'mxchat_privacy_toggle_callback'),
6482 'mxchat-chatbot',
6483 'mxchat_chatbot_section'
6484 );
6485
6486 add_settings_field(
6487 'complianz_toggle',
6488 esc_html__('Enable Complianz', 'mxchat'),
6489 array($this, 'mxchat_complianz_toggle_callback'),
6490 'mxchat-chatbot',
6491 'mxchat_chatbot_section'
6492 );
6493
6494 add_settings_field(
6495 'link_target_toggle',
6496 esc_html__('Open Links in a New Tab', 'mxchat'),
6497 array($this, 'mxchat_link_target_toggle_callback'),
6498 'mxchat-chatbot',
6499 'mxchat_chatbot_section'
6500 );
6501
6502 add_settings_field(
6503 'chat_persistence_toggle',
6504 esc_html__('Enable Chat Persistence', 'mxchat'),
6505 array($this, 'mxchat_chat_persistence_toggle_callback'),
6506 'mxchat-chatbot',
6507 'mxchat_chatbot_section'
6508 );
6509
6510 add_settings_field(
6511 'print_button_enabled',
6512 esc_html__('Show Download Transcript Button', 'mxchat'),
6513 array($this, 'mxchat_print_button_toggle_callback'),
6514 'mxchat-chatbot',
6515 'mxchat_chatbot_section'
6516 );
6517
6518 add_settings_field(
6519 'reset_chat_enabled',
6520 esc_html__('Show Start-New-Chat Button', 'mxchat'),
6521 array($this, 'mxchat_reset_chat_toggle_callback'),
6522 'mxchat-chatbot',
6523 'mxchat_chatbot_section'
6524 );
6525
6526 add_settings_field(
6527 'reset_chat_label',
6528 esc_html__('Start-New-Chat Button Label', 'mxchat'),
6529 array($this, 'mxchat_reset_chat_label_callback'),
6530 'mxchat-chatbot',
6531 'mxchat_chatbot_section'
6532 );
6533
6534 add_settings_field(
6535 'popular_question_1',
6536 esc_html__('Quick Question 1', 'mxchat'),
6537 array($this, 'mxchat_popular_question_1_callback'),
6538 'mxchat-chatbot',
6539 'mxchat_chatbot_section'
6540 );
6541
6542 add_settings_field(
6543 'popular_question_2',
6544 esc_html__('Quick Question 2', 'mxchat'),
6545 array($this, 'mxchat_popular_question_2_callback'),
6546 'mxchat-chatbot',
6547 'mxchat_chatbot_section'
6548 );
6549
6550 add_settings_field(
6551 'popular_question_3',
6552 esc_html__('Quick Question 3', 'mxchat'),
6553 array($this, 'mxchat_popular_question_3_callback'),
6554 'mxchat-chatbot',
6555 'mxchat_chatbot_section'
6556 );
6557
6558 add_settings_field(
6559 'additional_popular_questions',
6560 esc_html__('Additional Quick Questions', 'mxchat'),
6561 array($this, 'mxchat_additional_popular_questions_callback'),
6562 'mxchat-chatbot',
6563 'mxchat_chatbot_section'
6564 );
6565
6566
6567 add_settings_field(
6568 'rate_limits',
6569 __('Rate Limits Settings', 'mxchat'),
6570 array($this, 'mxchat_rate_limits_callback'),
6571 'mxchat-chatbot',
6572 'mxchat_chatbot_section'
6573 );
6574
6575 // Loops Settings Section
6576 add_settings_section(
6577 'mxchat_loops_section',
6578 esc_html__('Loops Settings', 'mxchat'),
6579 null,
6580 'mxchat-embed'
6581 );
6582
6583 // Loops Settings Fields (API Key moved to API Keys tab)
6584 add_settings_field(
6585 'loops_mailing_list',
6586 esc_html__('Loops Mailing List', 'mxchat'),
6587 array($this, 'mxchat_loops_mailing_list_callback'),
6588 'mxchat-embed',
6589 'mxchat_loops_section'
6590 );
6591
6592 add_settings_field(
6593 'triggered_phrase_response',
6594 esc_html__('Triggered Phrase Response', 'mxchat'),
6595 array($this, 'mxchat_triggered_phrase_response_callback'),
6596 'mxchat-embed',
6597 'mxchat_loops_section'
6598 );
6599
6600 add_settings_field(
6601 'email_capture_response',
6602 esc_html__('Email Capture Response', 'mxchat'),
6603 array($this, 'mxchat_email_capture_response_callback'),
6604 'mxchat-embed',
6605 'mxchat_loops_section'
6606 );
6607
6608 // Brave Search Settings Fields
6609 add_settings_section(
6610 'mxchat_brave_section',
6611 __('Brave Search Settings', 'mxchat'),
6612 array($this, 'mxchat_brave_section_callback'),
6613 'mxchat-embed'
6614 );
6615
6616 // Brave API Key moved to API Keys tab
6617 add_settings_field(
6618 'brave_image_count',
6619 __('Number of Images to Return', 'mxchat'),
6620 array($this, 'mxchat_brave_image_count_callback'),
6621 'mxchat-embed',
6622 'mxchat_brave_section'
6623 );
6624
6625 add_settings_field(
6626 'brave_safe_search',
6627 __('Safe Search', 'mxchat'),
6628 array($this, 'mxchat_brave_safe_search_callback'),
6629 'mxchat-embed',
6630 'mxchat_brave_section'
6631 );
6632
6633 add_settings_field(
6634 'brave_news_count',
6635 __('Number of News Articles', 'mxchat'),
6636 array($this, 'mxchat_brave_news_count_callback'),
6637 'mxchat-embed',
6638 'mxchat_brave_section'
6639 );
6640
6641 add_settings_field(
6642 'brave_country',
6643 __('Country', 'mxchat'),
6644 array($this, 'mxchat_brave_country_callback'),
6645 'mxchat-embed',
6646 'mxchat_brave_section'
6647 );
6648
6649 add_settings_field(
6650 'brave_language',
6651 __('Language', 'mxchat'),
6652 array($this, 'mxchat_brave_language_callback'),
6653 'mxchat-embed',
6654 'mxchat_brave_section'
6655 );
6656
6657 // Chat with PDF Intent Settings Fields
6658 add_settings_section(
6659 'mxchat_pdf_intent_section',
6660 __('Toolbar Settings & Intents', 'mxchat'),
6661 array($this, 'mxchat_pdf_intent_section_callback'),
6662 'mxchat-embed'
6663 );
6664
6665 add_settings_field(
6666 'chat_toolbar_toggle',
6667 __('Show Chat Toolbar', 'mxchat'),
6668 array($this, 'mxchat_chat_toolbar_toggle_callback'),
6669 'mxchat-embed',
6670 'mxchat_pdf_intent_section'
6671 );
6672
6673 // PDF Upload Button Toggle
6674 add_settings_field(
6675 'show_pdf_upload_button',
6676 __('Show PDF Upload Button', 'mxchat'),
6677 array($this, 'mxchat_show_pdf_upload_button_callback'),
6678 'mxchat-embed',
6679 'mxchat_pdf_intent_section'
6680 );
6681
6682 // Word Upload Button Toggle
6683 add_settings_field(
6684 'show_word_upload_button',
6685 __('Show Word Upload Button', 'mxchat'),
6686 array($this, 'mxchat_show_word_upload_button_callback'),
6687 'mxchat-embed',
6688 'mxchat_pdf_intent_section'
6689 );
6690
6691 add_settings_field(
6692 'pdf_intent_trigger_text',
6693 __('Intent Trigger Text', 'mxchat'),
6694 array($this, 'mxchat_pdf_intent_trigger_text_callback'),
6695 'mxchat-embed',
6696 'mxchat_pdf_intent_section'
6697 );
6698
6699 add_settings_field(
6700 'pdf_intent_success_text',
6701 __('Success Text', 'mxchat'),
6702 array($this, 'mxchat_pdf_intent_success_text_callback'),
6703 'mxchat-embed',
6704 'mxchat_pdf_intent_section'
6705 );
6706
6707 add_settings_field(
6708 'pdf_intent_error_text',
6709 __('Error Text', 'mxchat'),
6710 array($this, 'mxchat_pdf_intent_error_text_callback'),
6711 'mxchat-embed',
6712 'mxchat_pdf_intent_section'
6713 );
6714
6715 // Add PDF Maximum Pages Field
6716 add_settings_field(
6717 'pdf_max_pages',
6718 __('Maximum Document Pages', 'mxchat'),
6719 array($this, 'mxchat_pdf_max_pages_callback'),
6720 'mxchat-embed',
6721 'mxchat_pdf_intent_section'
6722 );
6723
6724 // Live Agent Settings Fields
6725 add_settings_section(
6726 'mxchat_live_agent_section',
6727 __('Live Agent Settings', 'mxchat'),
6728 array($this, 'mxchat_live_agent_section_callback'),
6729 'mxchat-embed'
6730 );
6731
6732 // Live Agent Status Fields (add at top of live agent settings)
6733 add_settings_field(
6734 'live_agent_status',
6735 __('Live Agent Status', 'mxchat'),
6736 array($this, 'mxchat_live_agent_status_callback'),
6737 'mxchat-embed',
6738 'mxchat_live_agent_section'
6739 );
6740
6741 // Slack availability schedule (plans 8ccaa2 + 99d7a4). Each handoff channel
6742 // owns an independent schedule rendered under its own Integrations tab; this
6743 // one governs Slack only. Same callback as Telegram's, parameterized.
6744 add_settings_field(
6745 'live_agent_schedule_slack',
6746 __('Availability Schedule', 'mxchat'),
6747 array($this, 'mxchat_live_agent_schedule_callback'),
6748 'mxchat-embed',
6749 'mxchat_live_agent_section',
6750 array('channel' => 'slack')
6751 );
6752
6753 add_settings_field(
6754 'live_agent_notification_message',
6755 __('Notification Message', 'mxchat'),
6756 array($this, 'mxchat_live_agent_notification_message_callback'),
6757 'mxchat-embed',
6758 'mxchat_live_agent_section'
6759 );
6760
6761 add_settings_field(
6762 'live_agent_away_message',
6763 __('Away Message', 'mxchat'),
6764 array($this, 'mxchat_live_agent_away_message_callback'),
6765 'mxchat-embed',
6766 'mxchat_live_agent_section'
6767 );
6768
6769 add_settings_field(
6770 'live_agent_user_ids',
6771 __('Slack Agent User IDs', 'mxchat'),
6772 array($this, 'mxchat_live_agent_user_ids_callback'),
6773 'mxchat-embed',
6774 'mxchat_live_agent_section'
6775 );
6776
6777 // Shared handoff channel (plan 9f7756): route every handoff into one
6778 // pre-existing channel as threads instead of creating chat-* channels.
6779 add_settings_field(
6780 'live_agent_shared_channel',
6781 __('Shared Handoff Channel', 'mxchat'),
6782 array($this, 'mxchat_live_agent_shared_channel_callback'),
6783 'mxchat-embed',
6784 'mxchat_live_agent_section'
6785 );
6786
6787 // Auto-archive per-conversation chat- channels on !endchat (plan 7458a7).
6788 // Default OFF; never touches the shared handoff channel.
6789 add_settings_field(
6790 'live_agent_archive_on_end_toggle',
6791 __('Archive Channel When Chat Ends', 'mxchat'),
6792 array($this, 'mxchat_live_agent_archive_on_end_callback'),
6793 'mxchat-embed',
6794 'mxchat_live_agent_section'
6795 );
6796
6797 add_settings_field(
6798 'live_agent_webhook_url',
6799 __('Slack Webhook URL', 'mxchat'),
6800 array($this, 'mxchat_live_agent_webhook_url_callback'),
6801 'mxchat-embed',
6802 'mxchat_live_agent_section'
6803 );
6804
6805 add_settings_field(
6806 'live_agent_secret_key',
6807 __('Slack Secret Key', 'mxchat'),
6808 array($this, 'mxchat_live_agent_secret_key_callback'),
6809 'mxchat-embed',
6810 'mxchat_live_agent_section'
6811 );
6812
6813 // Live Agent Integration Fields
6814 add_settings_field(
6815 'live_agent_bot_token',
6816 __('Slack Bot OAuth Token', 'mxchat'),
6817 array($this, 'mxchat_live_agent_bot_token_callback'),
6818 'mxchat-embed',
6819 'mxchat_live_agent_section'
6820 );
6821
6822 // Telegram Integration Section
6823 add_settings_section(
6824 'mxchat_telegram_section',
6825 __('Telegram Settings', 'mxchat'),
6826 array($this, 'mxchat_telegram_section_callback'),
6827 'mxchat-embed'
6828 );
6829
6830 add_settings_field(
6831 'telegram_status',
6832 __('Live Agent Status', 'mxchat'),
6833 array($this, 'mxchat_telegram_status_callback'),
6834 'mxchat-embed',
6835 'mxchat_telegram_section'
6836 );
6837
6838 // Telegram availability schedule (plan 99d7a4) — independent of Slack's,
6839 // rendered right under the Telegram status toggle it extends.
6840 add_settings_field(
6841 'live_agent_schedule_telegram',
6842 __('Availability Schedule', 'mxchat'),
6843 array($this, 'mxchat_live_agent_schedule_callback'),
6844 'mxchat-embed',
6845 'mxchat_telegram_section',
6846 array('channel' => 'telegram')
6847 );
6848
6849 add_settings_field(
6850 'telegram_notification_message',
6851 __('Notification Message', 'mxchat'),
6852 array($this, 'mxchat_telegram_notification_message_callback'),
6853 'mxchat-embed',
6854 'mxchat_telegram_section'
6855 );
6856
6857 add_settings_field(
6858 'telegram_away_message',
6859 __('Away Message', 'mxchat'),
6860 array($this, 'mxchat_telegram_away_message_callback'),
6861 'mxchat-embed',
6862 'mxchat_telegram_section'
6863 );
6864
6865 add_settings_field(
6866 'telegram_bot_token',
6867 __('Telegram Bot Token', 'mxchat'),
6868 array($this, 'mxchat_telegram_bot_token_callback'),
6869 'mxchat-embed',
6870 'mxchat_telegram_section'
6871 );
6872
6873 add_settings_field(
6874 'telegram_group_id',
6875 __('Telegram Group ID', 'mxchat'),
6876 array($this, 'mxchat_telegram_group_id_callback'),
6877 'mxchat-embed',
6878 'mxchat_telegram_section'
6879 );
6880
6881 add_settings_field(
6882 'telegram_webhook_secret',
6883 __('Webhook Secret Token', 'mxchat'),
6884 array($this, 'mxchat_telegram_webhook_secret_callback'),
6885 'mxchat-embed',
6886 'mxchat_telegram_section'
6887 );
6888
6889 // General Settings Section
6890 add_settings_section(
6891 'mxchat_general_section',
6892 esc_html__('YouTube Tutorials', 'mxchat'),
6893 null,
6894 'mxchat-general'
6895 );
6896 }
6897
6898 public function mxchat_prompts_page_init() {
6899 register_setting(
6900 'mxchat_prompts_options',
6901 'mxchat_prompts_options',
6902 array(
6903 'type' => 'array',
6904 'description' => __('MXChat Knowledge Base Settings', 'mxchat'),
6905 'default' => array(
6906 'mxchat_auto_sync_posts' => 0,
6907 'mxchat_auto_sync_pages' => 0,
6908 'mxchat_use_pinecone' => 0,
6909 'mxchat_pinecone_api_key' => '',
6910 'mxchat_pinecone_environment' => '',
6911 'mxchat_pinecone_index' => '',
6912 'mxchat_pinecone_host' => '',
6913 ),
6914 'sanitize_callback' => array($this, 'sanitize_prompts_options'),
6915 )
6916 );
6917
6918 add_action('admin_notices', array($this, 'sync_settings_notice'));
6919 }
6920
6921 public function mxchat_transcripts_page_init() {
6922 register_setting(
6923 'mxchat_transcripts_options',
6924 'mxchat_transcripts_options',
6925 array(
6926 'type' => 'array',
6927 'description' => __('MXChat Transcripts Notification Settings', 'mxchat'),
6928 'default' => array(
6929 'mxchat_enable_notifications' => 0,
6930 'mxchat_notification_email' => get_option('admin_email'),
6931 'mxchat_auto_delete_transcripts' => 'never',
6932 ),
6933 'sanitize_callback' => array($this, 'sanitize_transcripts_options'),
6934 )
6935 );
6936
6937 add_settings_section(
6938 'mxchat_transcripts_notification_section',
6939 esc_html__('Chat Notification Settings', 'mxchat'),
6940 array($this, 'mxchat_transcripts_notification_section_callback'),
6941 'mxchat-transcripts'
6942 );
6943
6944 add_settings_field(
6945 'mxchat_enable_notifications',
6946 esc_html__('Enable Chat Notifications', 'mxchat'),
6947 array($this, 'mxchat_enable_notifications_callback'),
6948 'mxchat-transcripts',
6949 'mxchat_transcripts_notification_section'
6950 );
6951
6952 add_settings_field(
6953 'mxchat_notification_email',
6954 esc_html__('Notification Email Address', 'mxchat'),
6955 array($this, 'mxchat_notification_email_callback'),
6956 'mxchat-transcripts',
6957 'mxchat_transcripts_notification_section'
6958 );
6959
6960 add_settings_field(
6961 'mxchat_auto_delete_transcripts',
6962 esc_html__('Auto-Delete Old Transcripts', 'mxchat'),
6963 array($this, 'mxchat_auto_delete_transcripts_callback'),
6964 'mxchat-transcripts',
6965 'mxchat_transcripts_notification_section'
6966 );
6967
6968 add_settings_field(
6969 'mxchat_retention_days',
6970 esc_html__('Custom Retention (Days)', 'mxchat'),
6971 array($this, 'mxchat_retention_days_callback'),
6972 'mxchat-transcripts',
6973 'mxchat_transcripts_notification_section'
6974 );
6975
6976 add_settings_field(
6977 'mxchat_auto_email_transcript',
6978 esc_html__('Auto-Email Full Transcript', 'mxchat'),
6979 array($this, 'mxchat_auto_email_transcript_callback'),
6980 'mxchat-transcripts',
6981 'mxchat_transcripts_notification_section'
6982 );
6983 }
6984
6985
6986 /**
6987 * Sanitize all prompts options
6988 *
6989 * @param array $input The unsanitized options array
6990 * @return array The sanitized options array
6991 */
6992 public function sanitize_prompts_options($input) {
6993 // Log the incoming input.
6994 //error_log('Sanitizing inputs: ' . print_r($input, true));
6995
6996 $sanitized = array();
6997
6998 // Boolean options
6999 $sanitized['mxchat_auto_sync_posts'] = isset($input['mxchat_auto_sync_posts']) ? 1 : 0;
7000 $sanitized['mxchat_auto_sync_pages'] = isset($input['mxchat_auto_sync_pages']) ? 1 : 0;
7001 $sanitized['mxchat_use_pinecone'] = !empty($input['mxchat_use_pinecone']) ? 1 : 0;
7002
7003 // API Key: if less than 32 characters, flag as invalid.
7004 $api_key = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
7005 if (!empty($api_key) && strlen($api_key) < 32) {
7006 add_settings_error(
7007 'mxchat_prompts_options',
7008 'invalid_api_key',
7009 __('The Pinecone API key appears to be invalid. Please check your API key.', 'mxchat')
7010 );
7011 $existing_options = get_option('mxchat_prompts_options', array());
7012 $sanitized['mxchat_pinecone_api_key'] = $existing_options['mxchat_pinecone_api_key'] ?? '';
7013 } else {
7014 $sanitized['mxchat_pinecone_api_key'] = $api_key;
7015 }
7016
7017 // Environment and Index Name
7018 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
7019 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
7020
7021 // Host: Remove protocol and validate format.
7022 $host = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
7023 $host = preg_replace('#^https?://#', '', $host);
7024 //error_log('Host after removing protocol: ' . $host);
7025 if (!empty($host)) {
7026 if (!preg_match('/^[\w-]+\.svc\.[\w-]+\.pinecone\.io$/', $host)) {
7027 add_settings_error(
7028 'mxchat_prompts_options',
7029 'invalid_host',
7030 __('The Pinecone host appears to be invalid. It should look like "mxchat-vectors-zrmsquq.svc.aped-4627-b74a.pinecone.io"', 'mxchat')
7031 );
7032 $existing_options = get_option('mxchat_prompts_options', array());
7033 $sanitized['mxchat_pinecone_host'] = $existing_options['mxchat_pinecone_host'] ?? '';
7034 } else {
7035 $sanitized['mxchat_pinecone_host'] = $host;
7036 }
7037 } else {
7038 $sanitized['mxchat_pinecone_host'] = '';
7039 }
7040
7041 //error_log('Final sanitized array: ' . print_r($sanitized, true));
7042
7043 return $sanitized;
7044 }
7045
7046 public function sync_settings_notice() {
7047 // Only show notice on our plugin page
7048 if (!isset($_GET['page']) || $_GET['page'] !== 'mxchat-prompts') {
7049 return;
7050 }
7051
7052 // Check if settings were updated
7053 if (isset($_GET['settings-updated'])) {
7054
7055 ?>
7056 <div class="notice notice-success is-dismissible">
7057 <p><?php esc_html_e('Sync settings updated successfully.', 'mxchat'); ?></p>
7058 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php esc_html_e('Dismiss this notice.', 'mxchat'); ?></span></button>
7059 </div>
7060 <?php
7061
7062 }
7063 }
7064 // Add this sanitization function to your class
7065 public function sanitize_sync_setting($input) {
7066 return (bool)$input ? __('1', 'mxchat') : __('', 'mxchat');
7067 }
7068
7069 public function mxchat_rate_limits_callback() {
7070 $all_options = get_option('mxchat_options', []);
7071
7072 // Define available rate limits
7073 $rate_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
7074
7075 // Define available timeframes
7076 $timeframes = array(
7077 'hourly' => __('Per Hour', 'mxchat'),
7078 'daily' => __('Per Day', 'mxchat'),
7079 'weekly' => __('Per Week', 'mxchat'),
7080 'monthly' => __('Per Month', 'mxchat')
7081 );
7082
7083 // Get all roles plus a "logged_out" pseudo-role
7084 $roles = wp_roles()->get_names();
7085 $roles['logged_out'] = __('Logged Out Users', 'mxchat');
7086
7087 // Start the wrapper
7088 echo '<div class="pro-feature-wrapper active">';
7089 echo '<div class="mxchat-rate-limits-container">';
7090
7091 echo '<p class="description" style="margin-bottom: 20px;">' .
7092 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') .
7093 '</p>';
7094
7095 // Add markdown link documentation
7096 echo '<div class="notice notice-info inline" style="margin-bottom: 20px; padding: 10px;">';
7097 echo '<p><strong>' . esc_html__('Markdown Links Supported:', 'mxchat') . '</strong></p>';
7098 echo '<p>' . esc_html__('You can include clickable links in your custom messages using markdown syntax:', 'mxchat') . '</p>';
7099 echo '<ul style="margin-left: 20px;">';
7100 echo '<li><code>[Link text](https://example.com)</code> - Creates a clickable link</li>';
7101 echo '<li><code>[Visit our pricing](https://example.com/pricing)</code> - Link with custom text</li>';
7102 echo '<li><code>Plain URLs like https://example.com will also become clickable</code></li>';
7103 echo '</ul>';
7104 echo '</div>';
7105
7106 // Output the controls for each role
7107 foreach ($roles as $role_id => $role_name) {
7108 // Get saved options or defaults
7109 $default_limit = ($role_id === 'logged_out') ? '10' : '100';
7110 $default_timeframe = 'daily';
7111 $default_message = __('Rate limit exceeded. Please try again later.', 'mxchat');
7112
7113 $selected_limit = isset($all_options['rate_limits'][$role_id]['limit'])
7114 ? $all_options['rate_limits'][$role_id]['limit']
7115 : $default_limit;
7116
7117 $selected_timeframe = isset($all_options['rate_limits'][$role_id]['timeframe'])
7118 ? $all_options['rate_limits'][$role_id]['timeframe']
7119 : $default_timeframe;
7120
7121 $custom_message = isset($all_options['rate_limits'][$role_id]['message'])
7122 ? $all_options['rate_limits'][$role_id]['message']
7123 : $default_message;
7124
7125 // Output the row
7126 echo '<div class="mxchat-rate-limit-row mxchat-autosave-section">';
7127
7128 // Role label
7129 echo '<div class="mxchat-rate-limit-role">' . esc_html($role_name) . '</div>';
7130
7131 // Controls section
7132 echo '<div class="mxchat-rate-limit-controls-wrapper">';
7133
7134 // Rate limit and timeframe controls
7135 echo '<div class="mxchat-rate-limit-controls">';
7136
7137 // Limit dropdown
7138 echo '<div>';
7139 echo '<label for="rate_limits_' . esc_attr($role_id) . '_limit">' . esc_html__('Limit:', 'mxchat') . '</label>';
7140 echo '<select
7141 id="rate_limits_' . esc_attr($role_id) . '_limit"
7142 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][limit]"
7143 class="mxchat-autosave-field">';
7144 foreach ($rate_limits as $limit) {
7145 echo '<option value="' . esc_attr($limit) . '" ' . selected($selected_limit, $limit, false) . '>' . esc_html($limit) . '</option>';
7146 }
7147 echo '</select>';
7148 echo '</div>';
7149
7150 // Timeframe dropdown
7151 echo '<div>';
7152 echo '<label for="rate_limits_' . esc_attr($role_id) . '_timeframe">' . esc_html__('Timeframe:', 'mxchat') . '</label>';
7153 echo '<select
7154 id="rate_limits_' . esc_attr($role_id) . '_timeframe"
7155 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][timeframe]"
7156 class="mxchat-autosave-field">';
7157 foreach ($timeframes as $value => $label) {
7158 echo '<option value="' . esc_attr($value) . '" ' . selected($selected_timeframe, $value, false) . '>' . esc_html($label) . '</option>';
7159 }
7160 echo '</select>';
7161 echo '</div>';
7162
7163 echo '</div>'; // End controls
7164
7165 // Custom message textarea
7166 echo '<div class="mxchat-rate-limit-message">';
7167 echo '<label for="rate_limits_' . esc_attr($role_id) . '_message">' . esc_html__('Custom Message:', 'mxchat') . '</label>';
7168 echo '<textarea
7169 id="rate_limits_' . esc_attr($role_id) . '_message"
7170 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][message]"
7171 class="mxchat-autosave-field"
7172 placeholder="' . esc_attr__('Enter custom message when rate limit is exceeded', 'mxchat') . '">' .
7173 esc_textarea($custom_message) .
7174 '</textarea>';
7175 echo '<p class="description">' .
7176 esc_html__('Example: Rate limit reached! [Visit our pricing page](https://example.com/pricing) to upgrade.', 'mxchat') .
7177 '</p>';
7178 echo '</div>'; // End message
7179
7180 echo '</div>'; // End controls wrapper
7181
7182 echo '</div>'; // End row
7183 }
7184
7185 echo '</div>'; // End container
7186
7187 echo '</div>'; // End pro-feature-wrapper
7188 }
7189
7190 private function mxchat_add_option_field($id, $title, $callback = '') {
7191 add_settings_field(
7192 $id,
7193 __($title, 'mxchat'),
7194 $callback ? array($this, $callback) : array($this, $id . '_callback'),
7195 'mxchat-max',
7196 'mxchat_setting_section_id',
7197 $id === 'model' ? ['label_for' => 'model'] : []
7198 );
7199 }
7200
7201 // API Keys Section Callback
7202 public function mxchat_api_keys_section_callback() {
7203 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>';
7204 }
7205
7206 // OpenAI API Key
7207 public function api_key_callback() {
7208 $apiKey = isset($this->options['api_key']) ? esc_attr($this->options['api_key']) : '';
7209 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7210
7211 echo '<div class="api-key-wrapper">';
7212 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 . '" />';
7213 echo '<button type="button" id="toggleApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7214 echo '<p class="description">' . esc_html__('Required for OpenAI GPT models and OpenAI embeddings. Get your API key from OpenAI Platform.', 'mxchat') . '</p>';
7215 echo $this->mxchat_provider_key_test_button('openai', 'api_key');
7216 echo '</div>';
7217 }
7218
7219 // X.AI API Key
7220 public function xai_api_key_callback() {
7221 $xaiApiKey = isset($this->options['xai_api_key']) ? esc_attr($this->options['xai_api_key']) : '';
7222 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7223
7224 echo '<div class="api-key-wrapper">';
7225 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 . '" />';
7226 echo '<button type="button" id="toggleXaiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7227 echo '<p class="description">' . esc_html__('Required for X.AI Grok models. Get your API key from X.AI Console.', 'mxchat') . '</p>';
7228 echo $this->mxchat_provider_key_test_button('xai', 'xai_api_key');
7229 echo '</div>';
7230 }
7231 // Claude API Key
7232 public function claude_api_key_callback() {
7233 $claudeApiKey = isset($this->options['claude_api_key']) ? esc_attr($this->options['claude_api_key']) : '';
7234 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7235
7236 echo '<div class="api-key-wrapper">';
7237 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 . '" />';
7238 echo '<button type="button" id="toggleClaudeApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7239 echo '<p class="description">' . esc_html__('Required for Anthropic Claude models. Get your API key from Anthropic Console.', 'mxchat') . '</p>';
7240 echo $this->mxchat_provider_key_test_button('claude', 'claude_api_key');
7241 echo '</div>';
7242 }
7243
7244 // DeepSeek API Key
7245 public function deepseek_api_key_callback() {
7246 $apiKey = isset($this->options['deepseek_api_key']) ? esc_attr($this->options['deepseek_api_key']) : '';
7247 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7248
7249 echo '<div class="api-key-wrapper">';
7250 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 . '" />';
7251 echo '<button type="button" id="toggleDeepSeekApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7252 echo '<p class="description">' . esc_html__('Required for DeepSeek models. Get your API key from DeepSeek Platform.', 'mxchat') . '</p>';
7253 echo $this->mxchat_provider_key_test_button('deepseek', 'deepseek_api_key');
7254 echo '</div>';
7255 }
7256
7257 // Gemini API Key
7258 public function gemini_api_key_callback() {
7259 $geminiApiKey = isset($this->options['gemini_api_key']) ? esc_attr($this->options['gemini_api_key']) : '';
7260 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7261
7262 echo '<div class="api-key-wrapper">';
7263 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 . '" />';
7264 echo '<button type="button" id="toggleGeminiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7265 echo '<p class="description">' . esc_html__('Required for Google Gemini models and embeddings. Get your API key from Google AI Studio.', 'mxchat') . '</p>';
7266 echo $this->mxchat_provider_key_test_button('gemini', 'gemini_api_key');
7267 echo '</div>';
7268 }
7269
7270
7271 // OpenRouter API Key
7272 public function openrouter_api_key_callback() {
7273 $openrouterApiKey = isset($this->options['openrouter_api_key']) ? esc_attr($this->options['openrouter_api_key']) : '';
7274 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7275
7276 echo '<div class="api-key-wrapper">';
7277 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 . '" />';
7278 echo '<button type="button" id="toggleOpenRouterApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7279 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7280 echo $this->mxchat_provider_key_test_button('openrouter', 'openrouter_api_key');
7281 echo '</div>';
7282 }
7283
7284 /**
7285 * Renders a "Test key" button + result target next to a built-in provider key
7286 * field, and emits the shared delegated click handler ONCE (static guard). The
7287 * button carries data-target = the key field id so the owner can test the value
7288 * they just typed (test-before-save); the AJAX handler falls back to the saved
7289 * key when the field is empty. Styled with the WP `button` class to match the
7290 * adjacent Custom-provider "Test Connection" button on this same page.
7291 * plan-mxchat-20260623-c41f74.
7292 */
7293 public function mxchat_provider_key_test_button($provider, $target_id) {
7294 static $script_emitted = false;
7295 $nonce = wp_create_nonce('mxchat_test_provider_key');
7296 $html = '<div class="mxchat-key-test" style="margin-top:8px;">'
7297 . '<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>'
7298 . '<span class="mxchat-test-provider-key-result" style="margin-left:10px;font-size:13px;vertical-align:middle;"></span>'
7299 . '</div>';
7300 if (!$script_emitted) {
7301 $script_emitted = true;
7302 $html .= $this->mxchat_provider_key_test_script();
7303 }
7304 return $html;
7305 }
7306
7307 /**
7308 * One-time delegated click handler shared by every .mxchat-test-provider-key
7309 * button. Posts the typed key value + provider to mxchat_test_provider_key and
7310 * renders the success/error message inline. Mirrors the Custom-provider test.
7311 */
7312 private function mxchat_provider_key_test_script() {
7313 $t_testing = esc_js(__('Testing...', 'mxchat'));
7314 $t_valid = esc_js(__('Key is valid.', 'mxchat'));
7315 $t_failed = esc_js(__('Failed', 'mxchat'));
7316 $t_req = esc_js(__('Request failed', 'mxchat'));
7317 return '<script>(function(){'
7318 . 'if (window.__mxchatProviderKeyTestWired) { return; }'
7319 . 'window.__mxchatProviderKeyTestWired = true;'
7320 . 'document.addEventListener("click", function(e){'
7321 . 'var btn = e.target && e.target.closest ? e.target.closest(".mxchat-test-provider-key") : null;'
7322 . 'if (!btn) { return; }'
7323 . 'e.preventDefault();'
7324 . 'var field = btn.getAttribute("data-target") ? document.getElementById(btn.getAttribute("data-target")) : null;'
7325 . 'var out = btn.parentNode ? btn.parentNode.querySelector(".mxchat-test-provider-key-result") : null;'
7326 . 'if (out) { out.textContent = "' . $t_testing . '"; out.style.color = "#646970"; }'
7327 . 'btn.disabled = true;'
7328 . 'var fd = new FormData();'
7329 . 'fd.append("action", "mxchat_test_provider_key");'
7330 . 'fd.append("_wpnonce", btn.getAttribute("data-nonce"));'
7331 . 'fd.append("provider", btn.getAttribute("data-provider") || "");'
7332 . 'fd.append("key", field ? field.value : "");'
7333 . 'fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })'
7334 . '.then(function(r){ return r.json(); })'
7335 . '.then(function(j){'
7336 . 'if (out) {'
7337 . 'if (j && j.success) { out.textContent = "✓ " + ((j.data && j.data.message) ? j.data.message : "' . $t_valid . '"); out.style.color = "#00a32a"; }'
7338 . 'else { out.textContent = "⚠ " + ((j && j.data && j.data.message) ? j.data.message : "' . $t_failed . '"); out.style.color = "#d63638"; }'
7339 . '}'
7340 . 'btn.disabled = false;'
7341 . '})'
7342 . '.catch(function(){ if (out) { out.textContent = "⚠ ' . $t_req . '"; out.style.color = "#d63638"; } btn.disabled = false; });'
7343 . '});'
7344 . '})();</script>';
7345 }
7346
7347 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7348 public function custom_provider_callback() {
7349 $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7350 $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7351 $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7352 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7353 $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7354 $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7355 $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7356 $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7357 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7358 $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7359
7360 echo '<style>
7361 .mxchat-cp { max-width: 680px; }
7362 .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7363 .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7364 .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7365 .mxchat-cp .mxchat-cp-row > input[type="text"],
7366 .mxchat-cp .mxchat-cp-row > input[type="password"],
7367 .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7368 .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7369 .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7370 .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7371 .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; }
7372 .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; }
7373 .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7374 .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7375 </style>';
7376
7377 echo '<div class="api-key-wrapper mxchat-cp">';
7378 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>';
7379
7380 // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7381 // so admins can configure Azure without piecing it together from each field's hint.
7382 echo '<div class="mxchat-cp-azure">';
7383 echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7384 echo '<ol>';
7385 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>';
7386 echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7387 echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7388 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>';
7389 echo '</ol>';
7390 echo '</div>';
7391
7392 echo '<div class="mxchat-cp-row">';
7393 echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7394 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 . '" />';
7395 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>';
7396 echo '</div>';
7397
7398 echo '<div class="mxchat-cp-row">';
7399 echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7400 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 . '" />';
7401 echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7402 echo '</div>';
7403
7404 echo '<div class="mxchat-cp-row">';
7405 echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
7406 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 . '" />';
7407 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>';
7408 echo '</div>';
7409
7410 echo '<div class="mxchat-cp-row">';
7411 echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
7412 echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7413 echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
7414 echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
7415 echo '</select>';
7416 echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
7417 echo '</div>';
7418
7419 echo '<div class="mxchat-cp-row">';
7420 echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
7421 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 . '" />';
7422 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>';
7423 echo '</div>';
7424
7425 // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
7426 echo '<div class="mxchat-cp-row">';
7427 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>';
7428 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>';
7429 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>';
7430 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>';
7431 echo '</div>';
7432
7433 echo '<div class="mxchat-cp-row">';
7434 echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
7435 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 . '" />';
7436 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>';
7437 echo '</div>';
7438
7439 echo '<div class="mxchat-cp-test">';
7440 echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
7441 echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
7442 echo '</div>';
7443
7444 echo '<script>(function(){
7445 var btn = document.getElementById("mxchat-test-custom-provider");
7446 if (!btn || btn._wired) { return; } btn._wired = true;
7447 btn.addEventListener("click", function(){
7448 var out = document.getElementById("mxchat-test-custom-provider-result");
7449 out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
7450 out.style.color = "#646970";
7451 var fd = new FormData();
7452 fd.append("action", "mxchat_test_custom_provider");
7453 fd.append("_wpnonce", btn.getAttribute("data-nonce"));
7454 fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
7455 .then(function(r){ return r.json(); })
7456 .then(function(j){
7457 if (j && j.success) {
7458 out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
7459 out.style.color = "#00a32a";
7460 } else {
7461 out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
7462 out.style.color = "#d63638";
7463 }
7464 })
7465 .catch(function(){
7466 out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
7467 out.style.color = "#d63638";
7468 });
7469 });
7470 })();</script>';
7471
7472 echo '</div>';
7473 }
7474
7475 // Voyage API Key
7476 public function voyage_api_key_callback() {
7477 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
7478 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7479
7480 echo '<div class="api-key-wrapper">';
7481 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 . '" />';
7482 echo '<button type="button" id="toggleVoyageAPIKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7483 echo '<p class="description">' . esc_html__('Required for Voyage AI embedding models. Get your API key from Voyage AI.', 'mxchat') . '</p>';
7484 echo '</div>';
7485 }
7486
7487 public function mxchat_loops_api_key_callback() {
7488 $loops_api_key = isset($this->options['loops_api_key']) ? esc_attr($this->options['loops_api_key']) : '';
7489 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7490
7491 echo '<div class="api-key-wrapper">';
7492 echo sprintf(
7493 '<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" />',
7494 $loops_api_key,
7495 $nonce
7496 );
7497 echo '<button type="button" id="toggleLoopsApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7498 echo '</div>';
7499 // Cross-reference back to where the list is chosen, so the two screens point
7500 // at each other (plan-mxchat-20260802-907a63).
7501 echo '<p class="description">' . wp_kses(
7502 sprintf(
7503 /* translators: %s: link to the Loops integration tab. */
7504 __('Required for Loops email integration. Get your API key from Loops.so, then choose your mailing list under %s.', 'mxchat'),
7505 '<a href="#integrations-loops">' . esc_html__('Integrations, Loops', 'mxchat') . '</a>'
7506 ),
7507 self::mxchat_loops_pointer_allowed_html()
7508 ) . '</p>';
7509 }
7510 public function mxchat_loops_mailing_list_callback() {
7511 // Add error handling and type checking
7512 $loops_api_key = '';
7513 $selected_list = '';
7514 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7515
7516 // Safely get the API key
7517 if (isset($this->options['loops_api_key']) && is_string($this->options['loops_api_key'])) {
7518 $loops_api_key = $this->options['loops_api_key'];
7519 }
7520
7521 // Safely get the selected list
7522 if (isset($this->options['loops_mailing_list']) && is_string($this->options['loops_mailing_list'])) {
7523 $selected_list = $this->options['loops_mailing_list'];
7524 }
7525
7526 if (!empty($loops_api_key)) {
7527 $lists = $this->mxchat_fetch_loops_mailing_lists($loops_api_key);
7528 if (is_array($lists) && !empty($lists)) {
7529 echo '<div class="mxchat-field-wrapper">';
7530 echo '<select id="loops_mailing_list" name="loops_mailing_list" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7531
7532 // Add a default "Select a list" option
7533 echo '<option value="" ' . selected($selected_list, '', false) . '>' . esc_html__('Select a list', 'mxchat') . '</option>';
7534
7535 foreach ($lists as $list) {
7536 if (is_array($list) && isset($list['id']) && isset($list['name'])) {
7537 echo sprintf(
7538 '<option value="%s" %s>%s</option>',
7539 esc_attr($list['id']),
7540 selected($selected_list, $list['id'], false),
7541 esc_html($list['name'])
7542 );
7543 }
7544 }
7545 echo '</select>';
7546 echo '</div>';
7547 echo '<p class="description">' . esc_html__('Please select a mailing list to use with Loops.', 'mxchat') . '</p>';
7548 } else {
7549 echo '<p class="description">' . wp_kses(
7550 self::mxchat_loops_api_key_pointer(
7551 /* translators: %s: link to the API Keys tab. */
7552 __('No lists found. Please check your Loops API key under %s.', 'mxchat')
7553 ),
7554 self::mxchat_loops_pointer_allowed_html()
7555 ) . '</p>';
7556 }
7557 } else {
7558 echo '<p class="description">' . wp_kses(
7559 self::mxchat_loops_api_key_pointer(
7560 /* translators: %s: link to the API Keys tab. */
7561 __('Enter your Loops API key under %s to load mailing lists.', 'mxchat')
7562 ),
7563 self::mxchat_loops_pointer_allowed_html()
7564 ) . '</p>';
7565 }
7566 }
7567
7568 /**
7569 * Build the "API Keys" pointer used by the Loops mailing-list field.
7570 *
7571 * plan-mxchat-20260802-907a63. The Loops API key field was moved to the API
7572 * Keys tab, but this field's copy still read as though the input were beside
7573 * it, so users had nowhere to go. Both live on the SAME screen
7574 * (admin.php?page=mxchat-settings) — the key is under the API Keys tab, this
7575 * dropdown under Integrations > Loops — so the pointer is an in-page tab
7576 * switch, not a cross-page link.
7577 *
7578 * A plain href="#api-keys" is all it takes: the shared admin shell
7579 * (js/admin-sidebar.js) honours section-naming hashes on load AND on
7580 * hashchange (plan 4ede16), so ordinary anchors switch tabs — the old
7581 * data-target workaround on these pointer anchors is gone.
7582 *
7583 * @param string $template Translatable string containing one %s placeholder.
7584 * @return string
7585 */
7586 private static function mxchat_loops_api_key_pointer($template) {
7587 return sprintf(
7588 $template,
7589 '<a href="#api-keys">' . esc_html__('API Keys', 'mxchat') . '</a>'
7590 );
7591 }
7592
7593 /**
7594 * Allowed HTML for the Loops pointer — anchor plus the shell's tab-switch hook.
7595 */
7596 private static function mxchat_loops_pointer_allowed_html() {
7597 return array(
7598 'a' => array(
7599 'href' => array(),
7600 'data-target' => array(),
7601 ),
7602 );
7603 }
7604 public function mxchat_triggered_phrase_response_callback() {
7605 $default_response = __('Would you like to join our mailing list? Please provide your email below.', 'mxchat');
7606 $triggered_response = isset($this->options['triggered_phrase_response'])
7607 ? $this->options['triggered_phrase_response']
7608 : $default_response;
7609 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7610
7611 echo '<div class="mxchat-field-wrapper">';
7612 echo sprintf(
7613 '<textarea id="triggered_phrase_response" name="triggered_phrase_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7614 $nonce,
7615 esc_textarea($triggered_response)
7616 );
7617 echo '</div>';
7618 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>';
7619 }
7620
7621 public function mxchat_email_capture_response_callback() {
7622 $default_response = __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
7623 $email_capture_response = isset($this->options['email_capture_response'])
7624 ? $this->options['email_capture_response']
7625 : $default_response;
7626 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7627
7628 echo '<div class="mxchat-field-wrapper">';
7629 echo sprintf(
7630 '<textarea id="email_capture_response" name="email_capture_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7631 $nonce,
7632 esc_textarea($email_capture_response)
7633 );
7634 echo '</div>';
7635 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>';
7636 }
7637 public function mxchat_pre_chat_message_callback() {
7638 // Load the entire 'mxchat_options' array
7639 $all_options = get_option('mxchat_options', []);
7640
7641 // Retrieve the saved message or use the default value
7642 $default_message = __('Hey there! Ask me anything!', 'mxchat');
7643 $pre_chat_message = isset($all_options['pre_chat_message']) ? $all_options['pre_chat_message'] : $default_message;
7644
7645 // Output the textarea
7646 printf(
7647 '<textarea id="pre_chat_message" name="pre_chat_message" rows="5" cols="50">%s</textarea>',
7648 esc_textarea($pre_chat_message)
7649 );
7650 }
7651
7652 // Callback for AI Instructions textarea
7653 public function system_prompt_instructions_callback() {
7654 // Retrieve the current value of the system prompt instructions
7655 $instructions = isset($this->options['system_prompt_instructions']) ? esc_textarea($this->options['system_prompt_instructions']) : '';
7656 // Render the textarea field
7657 printf(
7658 '<textarea id="system_prompt_instructions" name="system_prompt_instructions" rows="5" cols="50">%s</textarea>',
7659 $instructions
7660 );
7661 // Personalization hint
7662 echo '<p class="description" style="margin-top: 8px;">';
7663 echo esc_html__('Use {visitor_name} to personalize AI responses when lead capture is enabled.', 'mxchat') . '<br>';
7664 echo '<code style="font-size: 12px;">' . esc_html__('Example: The visitor\'s name is {visitor_name}. Address them by name.', 'mxchat') . '</code>';
7665 echo '</p>';
7666 // Sample instructions button
7667 echo '<div class="mxchat-instructions-container">';
7668 echo '<button type="button" class="mxchat-instructions-btn" id="mxchatViewSampleBtn">';
7669 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">';
7670 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
7671 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
7672 echo '</svg>';
7673 echo esc_html__('View Sample Instructions', 'mxchat');
7674 echo '</button>';
7675 echo '</div>';
7676
7677 // Add modal to WordPress admin footer instead of inline
7678 add_action('admin_footer', array($this, 'render_sample_instructions_modal'));
7679 }
7680
7681 // New method to render modal in admin footer
7682 public function render_sample_instructions_modal() {
7683 static $modal_rendered = false;
7684 if ($modal_rendered) return; // Prevent duplicate modals
7685 $modal_rendered = true;
7686
7687 echo '<div class="mxchat-instructions-modal-overlay" id="mxchatSampleModal">';
7688 echo '<div class="mxchat-instructions-modal-content">';
7689 echo '<div class="mxchat-instructions-modal-header">';
7690 echo '<h3 class="mxchat-instructions-modal-title">';
7691 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">';
7692 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
7693 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
7694 echo '</svg>';
7695 echo esc_html__('Sample AI Instructions', 'mxchat');
7696 echo '</h3>';
7697 echo '<button type="button" class="mxchat-instructions-modal-close" id="mxchatModalClose">';
7698 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">';
7699 echo '<line x1="18" y1="6" x2="6" y2="18"/>';
7700 echo '<line x1="6" y1="6" x2="18" y2="18"/>';
7701 echo '</svg>';
7702 echo '</button>';
7703 echo '</div>';
7704 echo '<div class="mxchat-instructions-modal-body">';
7705 echo '<div class="mxchat-instructions-content">';
7706 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:
7707
7708 # Response Style - CRITICALLY IMPORTANT
7709 - MAXIMUM LENGTH: 1-3 short sentences per response
7710 - Ultra-concise: Get straight to the answer with no filler
7711 - No introductions like "Sure!" or "I\'d be happy to help"
7712 - No phrases like "based on my knowledge" or "according to information"
7713 - No explanatory text before giving the answer
7714 - No summaries or repetition
7715 - Hyperlink all URLs
7716 - Respond in user\'s language
7717 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
7718
7719 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
7720 - ONLY answer using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
7721 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
7722 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
7723 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
7724 - Better to admit insufficient information than provide inaccurate answers');
7725 echo '</div>';
7726 echo '<button type="button" class="mxchat-instructions-copy-btn" id="mxchatCopyBtn">';
7727 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">';
7728 echo '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>';
7729 echo '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
7730 echo '</svg>';
7731 echo esc_html__('Copy Instructions', 'mxchat');
7732 echo '</button>';
7733 echo '</div>';
7734 echo '<div class="mxchat-instructions-modal-footer">';
7735 echo '<button type="button" class="mxchat-instructions-btn-secondary" id="mxchatCloseBtn">' . esc_html__('Close', 'mxchat') . '</button>';
7736 echo '</div>';
7737 echo '</div>';
7738 echo '</div>';
7739 }
7740
7741
7742 public function mxchat_model_callback() {
7743 // Catalog refactor (plan-d14e89): single source of truth lives in
7744 // includes/class-mxchat-model-catalog.php. Dropdown groups are the
7745 // provider labels; each group maps model_id => "Label" strings.
7746 if (!class_exists('MxChat_Model_Catalog')) {
7747 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7748 }
7749 // Retrieve the currently selected model from saved options
7750 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.6-sol';
7751
7752 // Pass the saved model so a provider-retired id keeps rendering as the
7753 // current selection instead of a blank select (plan e46b8f).
7754 $models = MxChat_Model_Catalog::settings_dropdown_groups($selected_model);
7755
7756 // Begin the select dropdown
7757 echo '<select id="model" name="model">';
7758
7759 // Iterate over groups of models
7760 foreach ($models as $group_label => $group_models) {
7761 echo '<optgroup label="' . esc_attr($group_label) . '">';
7762
7763 foreach ($group_models as $model_value => $model_label) {
7764 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
7765 }
7766
7767 echo '</optgroup>';
7768 }
7769
7770 echo '</select>';
7771
7772 // Add a note for OpenRouter
7773 echo '<p class="description" id="openrouter-model-note" style="display:none; color: #d63638; font-weight: 500;">';
7774 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle;"></span> ';
7775 echo esc_html__('After entering your OpenRouter API key above, click the button below to load available models.', 'mxchat');
7776 echo '</p>';
7777
7778 // API Key Status Messages (hidden by default, shown by JS based on selected model)
7779 $has_openai_key = !empty($this->options['api_key']);
7780 $has_claude_key = !empty($this->options['claude_api_key']);
7781 $has_xai_key = !empty($this->options['xai_api_key']);
7782 $has_deepseek_key = !empty($this->options['deepseek_api_key']);
7783 $has_gemini_key = !empty($this->options['gemini_api_key']);
7784 $has_openrouter_key = !empty($this->options['openrouter_api_key']);
7785
7786 // OpenAI/GPT models
7787 echo '<p class="mxchat-api-status" data-provider="openai" style="display:none;">';
7788 if ($has_openai_key) {
7789 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
7790 } else {
7791 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7792 }
7793 echo '</p>';
7794
7795 // Claude models
7796 echo '<p class="mxchat-api-status" data-provider="claude" style="display:none;">';
7797 if ($has_claude_key) {
7798 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Anthropic (Claude) detected', 'mxchat') . '</span>';
7799 } else {
7800 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Anthropic (Claude) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7801 }
7802 echo '</p>';
7803
7804 // X.AI models
7805 echo '<p class="mxchat-api-status" data-provider="xai" style="display:none;">';
7806 if ($has_xai_key) {
7807 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for X.AI (Grok) detected', 'mxchat') . '</span>';
7808 } else {
7809 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>';
7810 }
7811 echo '</p>';
7812
7813 // DeepSeek models
7814 echo '<p class="mxchat-api-status" data-provider="deepseek" style="display:none;">';
7815 if ($has_deepseek_key) {
7816 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for DeepSeek detected', 'mxchat') . '</span>';
7817 } else {
7818 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for DeepSeek detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7819 }
7820 echo '</p>';
7821
7822 // Gemini models
7823 echo '<p class="mxchat-api-status" data-provider="gemini" style="display:none;">';
7824 if ($has_gemini_key) {
7825 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
7826 } else {
7827 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7828 }
7829 echo '</p>';
7830
7831 // OpenRouter models
7832 echo '<p class="mxchat-api-status" data-provider="openrouter" style="display:none;">';
7833 if ($has_openrouter_key) {
7834 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenRouter detected', 'mxchat') . '</span>';
7835 } else {
7836 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenRouter detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7837 }
7838 echo '</p>';
7839
7840 // ADD THESE HIDDEN FIELDS RIGHT HERE:
7841 $openrouter_model = isset($this->options['openrouter_selected_model']) ? esc_attr($this->options['openrouter_selected_model']) : '';
7842 $openrouter_model_name = isset($this->options['openrouter_selected_model_name']) ? esc_attr($this->options['openrouter_selected_model_name']) : '';
7843
7844 echo '<input type="hidden" id="openrouter_selected_model" name="openrouter_selected_model" value="' . $openrouter_model . '" />';
7845 echo '<input type="hidden" id="openrouter_selected_model_name" name="openrouter_selected_model_name" value="' . $openrouter_model_name . '" />';
7846 }
7847
7848 // Update your existing callback method
7849 public function enable_streaming_toggle_callback() {
7850 // Get value from options array, default to 'on'
7851 $enabled = isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on';
7852 $checked = ($enabled === 'on') ? 'checked' : '';
7853
7854 echo '<label class="toggle-switch">';
7855 echo sprintf(
7856 '<input type="checkbox" id="enable_streaming_toggle" name="enable_streaming_toggle" value="on" %s />',
7857 esc_attr($checked)
7858 );
7859 echo '<span class="slider"></span>';
7860 echo '</label>';
7861
7862 // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
7863 echo '<div class="mxch-streaming-test-row">';
7864 echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
7865 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>';
7866 echo esc_html__('Test Streaming Compatibility', 'mxchat');
7867 echo '</button>';
7868 echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
7869 echo '</div>';
7870 }
7871
7872 // Web Search toggle callback
7873 public function enable_web_search_toggle_callback() {
7874 // Get value from options array, default to 'off'
7875 $enabled = isset($this->options['enable_web_search']) ? $this->options['enable_web_search'] : 'off';
7876 $checked = ($enabled === 'on') ? 'checked' : '';
7877
7878 // Get current model to determine if we should show/enable the toggle
7879 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
7880
7881 // Models that DON'T support web search — OpenAI-docs-driven exception list.
7882 // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
7883 // future OpenAI model that lacks Responses-API web_search support is added here.
7884 $unsupported_models = array('gpt-4.1-nano');
7885
7886 // Web-search-capable chat-model allowlists, derived from the central model catalog
7887 // (class-mxchat-model-catalog.php). When a new OpenAI/Gemini chat model is added there,
7888 // the Web Search toggle picks it up automatically — no edit here.
7889 // OpenAI grounds via the Responses-API web_search tool; Gemini grounds natively via the
7890 // Google Search tool (plan 46b9ea wired the Gemini dispatch — every shipped Gemini chat
7891 // model is 2.x/3.x and grounds, matching that path's empty opt-out list, so all catalog
7892 // Gemini models are supported here).
7893 if (!class_exists('MxChat_Model_Catalog')) {
7894 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7895 }
7896 $chat_catalog = MxChat_Model_Catalog::chat_models();
7897 $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
7898 ? array_keys($chat_catalog['openai']['models'])
7899 : array();
7900 $gemini_models = (isset($chat_catalog['gemini']['models']) && is_array($chat_catalog['gemini']['models']))
7901 ? array_keys($chat_catalog['gemini']['models'])
7902 : array();
7903
7904 $is_capable = in_array($current_model, $openai_models) || in_array($current_model, $gemini_models);
7905 $is_supported = $is_capable && !in_array($current_model, $unsupported_models);
7906
7907 // Wrapper div with data attributes for JS to show/hide. data-openai-models is kept for
7908 // back-compat; data-gemini-models is the added second provider the JS now also honors.
7909 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;"' : '') . '>';
7910
7911 echo '<label class="toggle-switch">';
7912 echo sprintf(
7913 '<input type="checkbox" id="enable_web_search" name="enable_web_search" value="on" %s />',
7914 esc_attr($checked)
7915 );
7916 echo '<span class="slider"></span>';
7917 echo '</label>';
7918
7919 echo '</div>';
7920
7921 // Message shown when a model that can't ground (Claude, Grok, DeepSeek, OpenRouter, etc.) is selected
7922 echo '<p id="web-search-unavailable-message" class="description" style="color: #666;' . ($is_supported ? ' display:none;' : '') . '">';
7923 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle; margin-right: 4px;"></span>';
7924 echo esc_html__('Web search is only available for OpenAI and Gemini models.', 'mxchat');
7925 echo '</p>';
7926 }
7927
7928 // AJAX handler to fetch OpenRouter models
7929 public function fetch_openrouter_models() {
7930 check_ajax_referer('mxchat_fetch_openrouter_models', 'nonce');
7931
7932 // plan-mxchat-20260731-c63fb6 — nonce is not authorization.
7933 if (!current_user_can('manage_options')) {
7934 wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403);
7935 }
7936
7937 $api_key = isset($_POST['api_key']) ? sanitize_text_field($_POST['api_key']) : '';
7938
7939 if (empty($api_key)) {
7940 wp_send_json_error(array('message' => 'API key is required'));
7941 }
7942
7943 $response = wp_remote_get('https://openrouter.ai/api/v1/models', array(
7944 'headers' => array(
7945 'Authorization' => 'Bearer ' . $api_key,
7946 'Content-Type' => 'application/json',
7947 ),
7948 'timeout' => 15,
7949 ));
7950
7951 if (is_wp_error($response)) {
7952 wp_send_json_error(array('message' => $response->get_error_message()));
7953 }
7954
7955 $body = wp_remote_retrieve_body($response);
7956 $data = json_decode($body, true);
7957
7958 if (isset($data['data']) && is_array($data['data'])) {
7959 // Format the models for the frontend
7960 $models = array_map(function($model) {
7961 return array(
7962 'id' => $model['id'],
7963 'name' => $model['name'] ?? $model['id'],
7964 'description' => $model['description'] ?? '',
7965 'context_length' => $model['context_length'] ?? 0,
7966 'pricing' => array(
7967 'prompt' => $model['pricing']['prompt'] ?? 0,
7968 'completion' => $model['pricing']['completion'] ?? 0,
7969 ),
7970 );
7971 }, $data['data']);
7972
7973 wp_send_json_success(array('models' => $models));
7974 } else {
7975 wp_send_json_error(array('message' => 'Invalid response from OpenRouter'));
7976 }
7977 }
7978
7979 // Callback function for embedding model selection
7980 public function embedding_model_callback() {
7981 $models = array(
7982 esc_html__('OpenAI Embeddings', 'mxchat') => array(
7983 'text-embedding-3-small' => esc_html__('TE3 Small (1536, Efficient)', 'mxchat'),
7984 'text-embedding-ada-002' => esc_html__('Ada 2 (1536, Recommended)', 'mxchat'),
7985 'text-embedding-3-large' => esc_html__('TE3 Large (3072, Powerful)', 'mxchat'),
7986 ),
7987 esc_html__('Voyage AI Embeddings', 'mxchat') => array(
7988 'voyage-3-large' => esc_html__('Voyage-3 Large (2048, Most Capable)', 'mxchat'),
7989 ),
7990 esc_html__('Google Gemini Embeddings', 'mxchat') => array(
7991 'gemini-embedding-001' => esc_html__('Gemini Embedding (1536, Stable)', 'mxchat'),
7992 )
7993 );
7994 $selected_model = isset($this->options['embedding_model']) ? esc_attr($this->options['embedding_model']) : 'text-embedding-ada-002';
7995 // With custom-provider embeddings on, this picker is inert — the effective
7996 // model comes from the Custom Embedding Model field (plan ae02cb). Autosave
7997 // fires only on user change events, so the disabled attribute cannot cause
7998 // a missing-key save; JS keeps the state in sync with the toggle live.
7999 $custom_embeddings_on = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
8000 echo '<select id="embedding_model" name="embedding_model"' . ($custom_embeddings_on ? ' disabled' : '') . '>';
8001 foreach ($models as $group_label => $group_models) {
8002 echo '<optgroup label="' . esc_attr($group_label) . '">';
8003 foreach ($group_models as $model_value => $model_label) {
8004 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8005 }
8006 echo '</optgroup>';
8007 }
8008 echo '</select>';
8009 echo '<p class="mxch-field-description mxchat-embedding-custom-note" id="mxchat_embedding_custom_note"' . ($custom_embeddings_on ? '' : ' style="display:none;"') . '>';
8010 echo esc_html__('Custom provider embeddings are in use — the model is set in the Custom Embedding Model field under Custom Provider.', 'mxchat');
8011 echo '</p>';
8012
8013 // API Key Status Messages for Embedding Models
8014 $has_openai_key = !empty($this->options['api_key']);
8015 $has_voyage_key = !empty($this->options['voyage_api_key']);
8016 $has_gemini_key = !empty($this->options['gemini_api_key']);
8017
8018 // OpenAI Embeddings
8019 echo '<p class="mxchat-embedding-api-status" data-provider="openai" style="display:none;">';
8020 if ($has_openai_key) {
8021 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8022 } else {
8023 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8024 }
8025 echo '</p>';
8026
8027 // Voyage AI Embeddings
8028 echo '<p class="mxchat-embedding-api-status" data-provider="voyage" style="display:none;">';
8029 if ($has_voyage_key) {
8030 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Voyage AI detected', 'mxchat') . '</span>';
8031 } else {
8032 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Voyage AI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8033 }
8034 echo '</p>';
8035
8036 // Gemini Embeddings
8037 echo '<p class="mxchat-embedding-api-status" data-provider="gemini" style="display:none;">';
8038 if ($has_gemini_key) {
8039 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8040 } else {
8041 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8042 }
8043 echo '</p>';
8044
8045 }
8046
8047
8048 public function mxchat_top_bar_title_callback() {
8049 // Retrieve the current value of the top bar title from saved options
8050 $top_bar_title = isset($this->options['top_bar_title']) ? esc_attr($this->options['top_bar_title']) : '';
8051
8052 // Render the input field
8053 echo '<input type="text" id="top_bar_title" name="top_bar_title" value="' . $top_bar_title . '" />';
8054 }
8055 public function mxchat_ai_agent_text_callback() {
8056 // Retrieve the current value of the AI agent text from saved options
8057 $ai_agent_text = isset($this->options['ai_agent_text']) ? esc_attr($this->options['ai_agent_text']) : '';
8058 // Render the input field
8059 echo '<input type="text" id="ai_agent_text" name="ai_agent_text" value="' . $ai_agent_text . '" />';
8060 }
8061
8062
8063 public function enable_email_block_callback() {
8064 // Load full plugin options array
8065 $all_options = get_option('mxchat_options', []);
8066
8067 // Get the value, default to 'off'
8068 $enable_email_block = isset($all_options['enable_email_block']) ? $all_options['enable_email_block'] : 'off';
8069
8070 // Check if it's 'on'
8071 $checked = ($enable_email_block === 'on') ? 'checked' : '';
8072
8073 echo '<label class="toggle-switch">';
8074 echo sprintf(
8075 '<input type="checkbox" id="enable_email_block" name="enable_email_block" value="on" %s />',
8076 esc_attr($checked)
8077 );
8078 echo '<span class="slider"></span>';
8079 echo '</label>';
8080 }
8081
8082 public function email_blocker_header_content_callback() {
8083 // Load the entire 'mxchat_options' array
8084 $all_options = get_option('mxchat_options', []);
8085
8086 // Retrieve the saved content or default to empty
8087 $content = isset($all_options['email_blocker_header_content'])
8088 ? $all_options['email_blocker_header_content']
8089 : '';
8090
8091 // Render the textarea - IMPORTANT: name should be just "email_blocker_header_content"
8092 echo '<textarea
8093 id="email_blocker_header_content"
8094 name="email_blocker_header_content"
8095 rows="5"
8096 cols="70"
8097 data-setting="email_blocker_header_content"
8098 >' . esc_textarea($content) . '</textarea>';
8099 }
8100
8101 public function email_blocker_button_text_callback() {
8102 // Load the entire 'mxchat_options' array
8103 $all_options = get_option('mxchat_options', []);
8104
8105 // Retrieve the saved button text or default to empty
8106 $button_text = isset($all_options['email_blocker_button_text'])
8107 ? $all_options['email_blocker_button_text']
8108 : '';
8109
8110 // Use esc_attr to safely render the existing text
8111 echo '<input type="text" id="email_blocker_button_text" name="email_blocker_button_text" value="' . esc_attr($button_text) . '" style="width: 300px;" />';
8112 }
8113
8114 //Enable name field callback
8115 public function enable_name_field_callback() {
8116 // Load full plugin options array
8117 $all_options = get_option('mxchat_options', []);
8118 // Get the value, default to 'off'
8119 $enable_name_field = isset($all_options['enable_name_field']) ? $all_options['enable_name_field'] : 'off';
8120 // Check if it's 'on'
8121 $checked = ($enable_name_field === 'on') ? 'checked' : '';
8122 echo '<label class="toggle-switch">';
8123 echo sprintf(
8124 '<input type="checkbox" id="enable_name_field" name="enable_name_field" value="on" %s />',
8125 esc_attr($checked)
8126 );
8127 echo '<span class="slider"></span>';
8128 echo '</label>';
8129 }
8130 //Name field placeholder callback
8131 public function name_field_placeholder_callback() {
8132 $all_options = get_option('mxchat_options', []);
8133 $placeholder = isset($all_options['name_field_placeholder'])
8134 ? $all_options['name_field_placeholder']
8135 : esc_html__('Enter your name', 'mxchat');
8136
8137 echo '<input type="text" id="name_field_placeholder" name="name_field_placeholder" value="' . esc_attr($placeholder) . '" style="width: 300px;" />';
8138 }
8139
8140
8141
8142 public function mxchat_intro_message_callback() {
8143 // Load the entire 'mxchat_options' array
8144 $all_options = get_option('mxchat_options', []);
8145 // Retrieve the saved intro message or use the default
8146 $default_message = __('Hello! How can I assist you today?', 'mxchat');
8147 $saved_message = isset($all_options['intro_message']) ? $all_options['intro_message'] : $default_message;
8148 // Escape on output (esc_textarea) — neutralizes any payload already stored before the
8149 // Wordfence Stored-XSS fix (CWE-79, plan-3f8158) and prevents </textarea> context-breakout.
8150 ?>
8151 <textarea id="intro_message" name="intro_message" rows="5" cols="50"><?php echo esc_textarea( $saved_message ); ?></textarea>
8152 <p class="description" style="margin-top: 8px;">
8153 <?php esc_html_e('Use {visitor_name} to personalize greetings when lead capture is enabled.', 'mxchat'); ?><br>
8154 <code style="font-size: 12px;"><?php esc_html_e('Example: Hello {visitor_name}! How can I help you today?', 'mxchat'); ?></code>
8155 </p>
8156 <?php
8157 }
8158
8159 public function mxchat_input_copy_callback() {
8160 // Load the entire 'mxchat_options' array
8161 $all_options = get_option('mxchat_options', []);
8162
8163 // Retrieve the saved input copy or use the default value
8164 $default_copy = __('How can I assist?', 'mxchat');
8165 $input_copy = isset($all_options['input_copy']) ? $all_options['input_copy'] : $default_copy;
8166
8167 // Output the input field with the saved value
8168 printf(
8169 '<input type="text" id="input_copy" name="input_copy" value="%s" placeholder="%s" />',
8170 esc_attr($input_copy),
8171 esc_attr__('How can I assist?', 'mxchat')
8172 );
8173 }
8174
8175
8176 public function mxchat_append_to_body_callback() {
8177 // Fetch fresh options to ensure we have the latest saved values
8178 $options = get_option('mxchat_options', array());
8179
8180 // Get value from options array, default to 'off'
8181 $append_to_body = isset($options['append_to_body']) ? $options['append_to_body'] : 'off';
8182 $checked = ($append_to_body === 'on') ? 'checked' : '';
8183
8184 // Get post type visibility settings
8185 $visibility_mode = isset($options['post_type_visibility_mode']) ? $options['post_type_visibility_mode'] : 'all';
8186 $visibility_list = isset($options['post_type_visibility_list']) ? $options['post_type_visibility_list'] : array();
8187 if (!is_array($visibility_list)) {
8188 $visibility_list = array();
8189 }
8190
8191 echo '<div class="mxchat-autosave-section">';
8192
8193 // Main toggle
8194 echo '<label class="toggle-switch">';
8195 echo sprintf(
8196 '<input type="checkbox" id="append_to_body" name="append_to_body" value="on" %s />',
8197 esc_attr($checked)
8198 );
8199 echo '<span class="slider"></span>';
8200 echo '</label>';
8201
8202 // Post Type Visibility Options (only visible when auto-display is ON)
8203 $display_style = ($append_to_body === 'on') ? '' : 'display: none;';
8204 echo '<div id="post-type-visibility-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8205
8206 echo '<div class="mxchat-post-type-visibility-header">';
8207 echo '<h4>' . esc_html__('Post Type Visibility', 'mxchat') . '</h4>';
8208 echo '</div>';
8209
8210 // Mode selector (radio buttons)
8211 echo '<div class="mxchat-visibility-mode">';
8212
8213 echo '<label class="mxchat-radio-label">';
8214 echo '<input type="radio" name="post_type_visibility_mode" value="all" ' . checked($visibility_mode, 'all', false) . ' />';
8215 echo '<span>' . esc_html__('Show on all post types', 'mxchat') . '</span>';
8216 echo '</label>';
8217
8218 echo '<label class="mxchat-radio-label">';
8219 echo '<input type="radio" name="post_type_visibility_mode" value="include" ' . checked($visibility_mode, 'include', false) . ' />';
8220 echo '<span>' . esc_html__('Only show on selected post types', 'mxchat') . '</span>';
8221 echo '</label>';
8222
8223 echo '<label class="mxchat-radio-label">';
8224 echo '<input type="radio" name="post_type_visibility_mode" value="exclude" ' . checked($visibility_mode, 'exclude', false) . ' />';
8225 echo '<span>' . esc_html__('Hide on selected post types', 'mxchat') . '</span>';
8226 echo '</label>';
8227
8228 echo '</div>';
8229
8230 // Post type checkboxes (only visible when mode is include or exclude)
8231 $list_display = ($visibility_mode !== 'all') ? '' : 'display: none;';
8232 echo '<div id="post-type-list" class="mxchat-post-type-list" style="' . esc_attr($list_display) . '">';
8233
8234 // Get all public post types
8235 $post_types = get_post_types(array('public' => true), 'objects');
8236
8237 foreach ($post_types as $post_type) {
8238 // Skip attachments
8239 if ($post_type->name === 'attachment') {
8240 continue;
8241 }
8242
8243 $is_checked = in_array($post_type->name, $visibility_list) ? 'checked' : '';
8244
8245 echo '<label class="mxchat-checkbox-label">';
8246 echo '<input type="checkbox" name="post_type_visibility_list[]" value="' . esc_attr($post_type->name) . '" ' . $is_checked . ' />';
8247 echo '<span>' . esc_html($post_type->label) . '</span>';
8248 echo '</label>';
8249 }
8250
8251 echo '</div>'; // End post-type-list
8252 echo '</div>'; // End post-type-visibility-options
8253 echo '</div>'; // End mxchat-autosave-section
8254 }
8255
8256 public function mxchat_contextual_awareness_callback() {
8257 // Get value from options array, default to 'off'
8258 $contextual_awareness = isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off';
8259 $checked = ($contextual_awareness === 'on') ? 'checked' : '';
8260 echo '<label class="toggle-switch">';
8261 echo sprintf(
8262 '<input type="checkbox" id="contextual_awareness_toggle" name="contextual_awareness_toggle" value="on" %s />',
8263 esc_attr($checked)
8264 );
8265 echo '<span class="slider"></span>';
8266 echo '</label>';
8267 }
8268
8269 public function mxchat_citation_links_toggle_callback() {
8270 // Get value from options array, default to 'on' (enabled by default)
8271 $citation_links = isset($this->options['citation_links_toggle']) ? $this->options['citation_links_toggle'] : 'on';
8272 $checked = ($citation_links === 'on') ? 'checked' : '';
8273 echo '<label class="toggle-switch">';
8274 echo sprintf(
8275 '<input type="checkbox" id="citation_links_toggle" name="citation_links_toggle" value="on" %s />',
8276 esc_attr($checked)
8277 );
8278 echo '<span class="slider"></span>';
8279 echo '</label>';
8280 }
8281
8282 /**
8283 * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
8284 * Default ON. The widget reads this through the localized object; the
8285 * mxchat_satisfaction_rating_enabled filter still lets developers force
8286 * the value site-wide.
8287 *
8288 * The 5 customization fields (idle/question/thanks/placeholder/saved) are
8289 * rendered inline here inside a single wrapper div whose initial display
8290 * is set server-side from the toggle value (plan-29caac). Mirrors the
8291 * auto-display chatbot pattern at mxchat_append_to_body_callback — no
8292 * DOMContentLoaded race because rows exist as direct children of this
8293 * callback's output, and the wrapper's display: none is inline at render
8294 * time so refresh shows the correct state with no flash.
8295 */
8296 public function mxchat_satisfaction_rating_toggle_callback() {
8297 $options = $this->options;
8298 $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
8299 $checked = ($value === 'on') ? 'checked' : '';
8300
8301 $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
8302 $idle = max(5, min(600, $idle));
8303 $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
8304 $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
8305 $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
8306 $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
8307
8308 echo '<div class="mxchat-autosave-section">';
8309
8310 echo '<label class="toggle-switch">';
8311 echo sprintf(
8312 '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
8313 esc_attr($checked)
8314 );
8315 echo '<span class="slider"></span>';
8316 echo '</label>';
8317
8318 ?>
8319 <style>
8320 .mxchat-sub-options-field { margin: 12px 0; }
8321 .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
8322 #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
8323 </style>
8324 <?php
8325
8326 $display_style = ($value === 'on') ? '' : 'display: none;';
8327 echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8328
8329 echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
8330
8331 echo '<div class="mxchat-sub-options-field">';
8332 echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
8333 printf(
8334 '<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>',
8335 (int) $idle,
8336 esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
8337 );
8338 echo '</div>';
8339
8340 echo '<div class="mxchat-sub-options-field">';
8341 echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
8342 printf(
8343 '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8344 esc_attr($question),
8345 esc_attr__('Was this helpful?', 'mxchat')
8346 );
8347 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
8348 echo '</div>';
8349
8350 echo '<div class="mxchat-sub-options-field">';
8351 echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
8352 printf(
8353 '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
8354 esc_attr($thanks),
8355 esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
8356 );
8357 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
8358 echo '</div>';
8359
8360 echo '<div class="mxchat-sub-options-field">';
8361 echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
8362 printf(
8363 '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8364 esc_attr($placeholder),
8365 esc_attr__('Tell us what could be better…', 'mxchat')
8366 );
8367 echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
8368 echo '</div>';
8369
8370 echo '<div class="mxchat-sub-options-field">';
8371 echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
8372 printf(
8373 '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8374 esc_attr($saved),
8375 esc_attr__('Thanks for the feedback.', 'mxchat')
8376 );
8377 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
8378 echo '</div>';
8379
8380 echo '</div>'; // #satisfaction-rating-sub-options
8381 echo '</div>'; // .mxchat-autosave-section
8382
8383 ?>
8384 <script>
8385 (function() {
8386 document.addEventListener('DOMContentLoaded', function() {
8387 var toggle = document.getElementById('satisfaction_rating_enabled');
8388 var subOptions = document.getElementById('satisfaction-rating-sub-options');
8389 if (!toggle || !subOptions) return;
8390 toggle.addEventListener('change', function() {
8391 subOptions.style.display = toggle.checked ? '' : 'none';
8392 });
8393 });
8394 })();
8395 </script>
8396 <?php
8397 }
8398
8399 public function mxchat_privacy_toggle_callback() {
8400 // Load from mxchat_options array
8401 $options = get_option('mxchat_options', []);
8402
8403 // Get privacy toggle value with fallback
8404 $privacy_toggle = isset($options['privacy_toggle']) ? $options['privacy_toggle'] : 'off';
8405 $checked = ($privacy_toggle === 'on') ? 'checked' : '';
8406
8407 // Get privacy text with fallback
8408 $privacy_text = isset($options['privacy_text'])
8409 ? $options['privacy_text']
8410 : __('By chatting, you agree to our <a href="https://example.com/privacy-policy" target="_blank">privacy policy</a>.', 'mxchat');
8411
8412 // Output the toggle switch
8413 echo '<label class="toggle-switch">';
8414 echo sprintf(
8415 '<input type="checkbox" id="privacy_toggle" name="privacy_toggle" value="on" %s />',
8416 esc_attr($checked)
8417 );
8418 echo '<span class="slider"></span>';
8419 echo '</label>';
8420
8421 // Output the custom text input field
8422 echo sprintf(
8423 '<textarea id="privacy_text" name="privacy_text" rows="5" cols="50" class="regular-text">%s</textarea>',
8424 esc_textarea($privacy_text)
8425 );
8426 }
8427
8428
8429 public function mxchat_complianz_toggle_callback() {
8430 // Load from mxchat_options array
8431 $options = get_option('mxchat_options', []);
8432
8433 // Get complianz toggle value with fallback
8434 $complianz_toggle = isset($options['complianz_toggle']) ? $options['complianz_toggle'] : 'off';
8435 $checked = ($complianz_toggle === 'on') ? 'checked' : '';
8436
8437 // Output the toggle switch
8438 echo '<label class="toggle-switch">';
8439 echo sprintf(
8440 '<input type="checkbox" id="complianz_toggle" name="complianz_toggle" value="on" %s />',
8441 esc_attr($checked)
8442 );
8443 echo '<span class="slider"></span>';
8444 echo '</label>';
8445 }
8446
8447 public function mxchat_link_target_toggle_callback() {
8448 // Load from mxchat_options array
8449 $options = get_option('mxchat_options', []);
8450
8451 // Get link target toggle value with fallback
8452 $link_target_toggle = isset($options['link_target_toggle']) ? $options['link_target_toggle'] : 'off';
8453 $checked = ($link_target_toggle === 'on') ? 'checked' : '';
8454
8455 // Output the toggle switch
8456 echo '<label class="toggle-switch">';
8457 echo sprintf(
8458 '<input type="checkbox" id="link_target_toggle" name="link_target_toggle" value="on" %s />',
8459 esc_attr($checked)
8460 );
8461 echo '<span class="slider"></span>';
8462 echo '</label>';
8463 }
8464
8465 public function mxchat_chat_persistence_toggle_callback() {
8466 // Load from mxchat_options array
8467 $options = get_option('mxchat_options', []);
8468
8469 // Get chat persistence toggle value with fallback
8470 $chat_persistence_toggle = isset($options['chat_persistence_toggle']) ? $options['chat_persistence_toggle'] : 'off';
8471 $checked = ($chat_persistence_toggle === 'on') ? 'checked' : '';
8472
8473 // Output the toggle switch
8474 echo '<label class="toggle-switch">';
8475 echo sprintf(
8476 '<input type="checkbox" id="chat_persistence_toggle" name="chat_persistence_toggle" value="on" %s />',
8477 esc_attr($checked)
8478 );
8479 echo '<span class="slider"></span>';
8480 echo '</label>';
8481 }
8482
8483 public function mxchat_print_button_toggle_callback() {
8484 // Load from mxchat_options array
8485 $options = get_option('mxchat_options', []);
8486
8487 // Default ON — the option was previously unexposed and the button always showed.
8488 $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
8489 $checked = ($print_button_enabled === 'on') ? 'checked' : '';
8490
8491 // Output the toggle switch
8492 echo '<label class="toggle-switch">';
8493 echo sprintf(
8494 '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
8495 esc_attr($checked)
8496 );
8497 echo '<span class="slider"></span>';
8498 echo '</label>';
8499 }
8500
8501 /**
8502 * Editor Assistant toggle (plan-8cb0cb). Standalone option, NOT in mxchat_options
8503 * (bypasses the mxchat_sanitize strip-trap + autosave normalization). Default OFF.
8504 * Saved by the mxchat_editor_assistant_enabled case in class-ajax-handler.php.
8505 * Renders on Content → Settings (moved there from Settings → Behavior, plan-f7df40).
8506 */
8507 public function mxchat_editor_assistant_toggle_callback() {
8508 $enabled = get_option('mxchat_editor_assistant_enabled', 'off');
8509 $checked = ($enabled === 'on') ? 'checked' : '';
8510
8511 echo '<label class="toggle-switch">';
8512 echo sprintf(
8513 '<input type="checkbox" id="mxchat_editor_assistant_enabled" name="mxchat_editor_assistant_enabled" value="on" %s />',
8514 esc_attr($checked)
8515 );
8516 echo '<span class="slider"></span>';
8517 echo '</label>';
8518 }
8519
8520 /**
8521 * Hybrid keyword boost toggle (plan-38ffa1; rebuilt on the house toggle pattern
8522 * in plan-64d34f — the hand-rolled .mxch-toggle markup matched neither selector
8523 * the autosave JS uses to place its save confirmation, so the control looked
8524 * dead). Standalone option, NOT in mxchat_options. Default OFF. Saved by the
8525 * mxchat_hybrid_keyword_toggle case in class-ajax-handler.php, which also runs
8526 * capability detection on enable.
8527 */
8528 public function mxchat_hybrid_keyword_toggle_callback() {
8529 $enabled = get_option('mxchat_hybrid_keyword_toggle', 'off');
8530 $checked = ($enabled === 'on') ? 'checked' : '';
8531
8532 echo '<label class="toggle-switch">';
8533 echo sprintf(
8534 '<input type="checkbox" id="mxchat_hybrid_keyword_toggle" name="mxchat_hybrid_keyword_toggle" value="on" %s />',
8535 esc_attr($checked)
8536 );
8537 echo '<span class="slider"></span>';
8538 echo '</label>';
8539 }
8540
8541 /**
8542 * Smart asset loading toggle (plan-915355; rebuilt on the house toggle pattern
8543 * in plan-64d34f, same dead-save-feedback defect as the hybrid keyword toggle).
8544 * Standalone option, NOT in mxchat_options. Default OFF. Saved by the
8545 * mxchat_smart_asset_loading case in class-ajax-handler.php.
8546 */
8547 public function mxchat_smart_asset_loading_toggle_callback() {
8548 $enabled = get_option('mxchat_smart_asset_loading', 'off');
8549 $checked = ($enabled === 'on') ? 'checked' : '';
8550
8551 echo '<label class="toggle-switch">';
8552 echo sprintf(
8553 '<input type="checkbox" id="mxchat_smart_asset_loading" name="mxchat_smart_asset_loading" value="on" %s />',
8554 esc_attr($checked)
8555 );
8556 echo '<span class="slider"></span>';
8557 echo '</label>';
8558 }
8559
8560 public function mxchat_reset_chat_toggle_callback() {
8561 // Load from mxchat_options array. plan ac2e81 — default OFF (new, opt-in).
8562 $options = get_option('mxchat_options', []);
8563 $reset_chat_enabled = isset($options['reset_chat_enabled']) ? $options['reset_chat_enabled'] : 'off';
8564 $checked = ($reset_chat_enabled === 'on') ? 'checked' : '';
8565
8566 echo '<label class="toggle-switch">';
8567 echo sprintf(
8568 '<input type="checkbox" id="reset_chat_enabled" name="reset_chat_enabled" value="on" %s />',
8569 esc_attr($checked)
8570 );
8571 echo '<span class="slider"></span>';
8572 echo '</label>';
8573 }
8574
8575 public function mxchat_reset_chat_label_callback() {
8576 // Editable label for the "Start new chat" menu item. plan ac2e81.
8577 $options = get_option('mxchat_options', []);
8578 $reset_chat_label = isset($options['reset_chat_label']) ? $options['reset_chat_label'] : '';
8579
8580 printf(
8581 '<input type="text" id="reset_chat_label" name="reset_chat_label" value="%s" placeholder="%s" class="regular-text" />',
8582 esc_attr($reset_chat_label),
8583 esc_attr__('Start new chat', 'mxchat')
8584 );
8585 }
8586
8587 public function mxchat_popular_question_1_callback() {
8588 // Load the full plugin options array
8589 $all_options = get_option('mxchat_options', []);
8590
8591 // Retrieve the specific option for popular_question_1
8592 $popular_question_1 = isset($all_options['popular_question_1']) ? $all_options['popular_question_1'] : '';
8593
8594 // Render the input field
8595 printf(
8596 '<input type="text" id="popular_question_1" name="popular_question_1" value="%s" placeholder="%s" class="regular-text" />',
8597 esc_attr($popular_question_1),
8598 esc_attr__('Enter Quick Question 1', 'mxchat')
8599 );
8600 }
8601
8602
8603 public function mxchat_popular_question_2_callback() {
8604 // Load the full plugin options array
8605 $all_options = get_option('mxchat_options', []);
8606
8607 // Retrieve the specific option for popular_question_2
8608 $popular_question_2 = isset($all_options['popular_question_2']) ? $all_options['popular_question_2'] : '';
8609
8610 // Render the input field
8611 printf(
8612 '<input type="text" id="popular_question_2" name="popular_question_2" value="%s" placeholder="%s" class="regular-text" />',
8613 esc_attr($popular_question_2),
8614 esc_attr__('Enter Quick Question 2', 'mxchat')
8615 );
8616 }
8617
8618
8619 public function mxchat_popular_question_3_callback() {
8620 // Load the full plugin options array
8621 $all_options = get_option('mxchat_options', []);
8622
8623 // Retrieve the specific option for popular_question_3
8624 $popular_question_3 = isset($all_options['popular_question_3']) ? $all_options['popular_question_3'] : '';
8625
8626 // Render the input field
8627 printf(
8628 '<input type="text" id="popular_question_3" name="popular_question_3" value="%s" placeholder="%s" class="regular-text" />',
8629 esc_attr($popular_question_3),
8630 esc_attr(__('Enter Quick Question 3', 'mxchat'))
8631 );
8632 }
8633
8634 public function mxchat_additional_popular_questions_callback() {
8635 $options = get_option('mxchat_options', []);
8636 $additional_questions = isset($options['additional_popular_questions'])
8637 ? $options['additional_popular_questions']
8638 : get_option('additional_popular_questions', array());
8639
8640 echo '<div id="mxchat-additional-questions-container">';
8641 if (!empty($additional_questions)) {
8642 foreach ($additional_questions as $index => $question) {
8643 printf(
8644 '<div class="mxchat-question-row">
8645 <input type="text" name="additional_popular_questions[]"
8646 value="%s"
8647 placeholder="%s"
8648 class="regular-text mxchat-question-input"
8649 data-question-index="%d" />
8650 <button type="button" class="button mxchat-remove-question"
8651 aria-label="%s">%s</button>
8652 </div>',
8653 esc_attr($question),
8654 esc_attr(sprintf(__('Enter Additional Quick Question %d', 'mxchat'), $index + 4)),
8655 $index,
8656 esc_attr(__('Remove question', 'mxchat')),
8657 esc_html__('Remove', 'mxchat')
8658 );
8659 }
8660 } else {
8661 printf(
8662 '<div class="mxchat-question-row">
8663 <input type="text" name="additional_popular_questions[]"
8664 value=""
8665 placeholder="%s"
8666 class="regular-text mxchat-question-input"
8667 data-question-index="0" />
8668 <button type="button" class="button mxchat-remove-question"
8669 aria-label="%s">%s</button>
8670 </div>',
8671 esc_attr(__('Enter Additional Quick Question 4', 'mxchat')),
8672 esc_attr(__('Remove question', 'mxchat')),
8673 esc_html__('Remove', 'mxchat')
8674 );
8675 }
8676 echo '</div>';
8677 printf(
8678 '<button type="button" class="button mxchat-add-question" aria-label="%s">%s</button>',
8679 esc_attr(__('Add question', 'mxchat')),
8680 esc_html__('Add Question', 'mxchat')
8681 );
8682 }
8683
8684 public function mxchat_brave_api_key_callback() {
8685 $brave_api_key = isset($this->options['brave_api_key']) ? esc_attr($this->options['brave_api_key']) : '';
8686 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8687
8688 echo '<div class="api-key-wrapper">';
8689 echo sprintf(
8690 '<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" />',
8691 $brave_api_key,
8692 $nonce
8693 );
8694 echo '<button type="button" id="toggleBraveApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8695 echo '</div>';
8696 echo '<p class="description">' . __('Required for Brave Search integration. Get your API key from Brave Search API.', 'mxchat') . '</p>';
8697 }
8698
8699 public function mxchat_brave_image_count_callback() {
8700 $brave_image_count = isset($this->options['brave_image_count'])
8701 ? intval($this->options['brave_image_count'])
8702 : 4;
8703 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8704
8705 echo '<div class="mxchat-field-wrapper">';
8706 echo sprintf(
8707 '<input type="number" id="brave_image_count" name="brave_image_count"
8708 value="%d" min="1" max="6" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8709 $brave_image_count,
8710 $nonce
8711 );
8712 echo '</div>';
8713 echo '<p class="description">' . __('Select the number of images to return (1-6).', 'mxchat') . '</p>';
8714 }
8715
8716 public function mxchat_brave_safe_search_callback() {
8717 $brave_safe_search = isset($this->options['brave_safe_search'])
8718 ? esc_attr($this->options['brave_safe_search'])
8719 : 'strict';
8720 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8721
8722 echo '<div class="mxchat-field-wrapper">';
8723 echo '<select id="brave_safe_search" name="brave_safe_search" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
8724 echo sprintf(
8725 '<option value="strict" %s>%s</option>',
8726 selected($brave_safe_search, 'strict', false),
8727 __('Strict', 'mxchat')
8728 );
8729 echo sprintf(
8730 '<option value="off" %s>%s</option>',
8731 selected($brave_safe_search, 'off', false),
8732 __('Off', 'mxchat')
8733 );
8734 echo '</select>';
8735 echo '</div>';
8736 echo '<p class="description">' .
8737 esc_html__('Set the Safe Search level for image searches. Brave Search only supports "Strict" and "Off" options.', 'mxchat') .
8738 '</p>';
8739 }
8740
8741 public function mxchat_brave_news_count_callback() {
8742 $brave_news_count = isset($this->options['brave_news_count'])
8743 ? intval($this->options['brave_news_count'])
8744 : 3;
8745 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8746
8747 echo '<div class="mxchat-field-wrapper">';
8748 echo sprintf(
8749 '<input type="number" id="brave_news_count" name="brave_news_count"
8750 value="%d" min="1" max="10" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8751 $brave_news_count,
8752 $nonce
8753 );
8754 echo '</div>';
8755 echo '<p class="description">' . esc_html__('Select the number of news articles to retrieve (1-10).', 'mxchat') . '</p>';
8756 }
8757
8758 public function mxchat_brave_country_callback() {
8759 $brave_country = isset($this->options['brave_country'])
8760 ? esc_attr($this->options['brave_country'])
8761 : 'us';
8762 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8763
8764 echo '<div class="mxchat-field-wrapper">';
8765 echo sprintf(
8766 '<input type="text" id="brave_country" name="brave_country"
8767 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8768 $brave_country,
8769 $nonce
8770 );
8771 echo '</div>';
8772 echo '<p class="description">' . esc_html__('Enter the country code (e.g., "us" for United States).', 'mxchat') . '</p>';
8773 }
8774
8775 public function mxchat_brave_language_callback() {
8776 $brave_language = isset($this->options['brave_language'])
8777 ? esc_attr($this->options['brave_language'])
8778 : 'en';
8779 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8780
8781 echo '<div class="mxchat-field-wrapper">';
8782 echo sprintf(
8783 '<input type="text" id="brave_language" name="brave_language"
8784 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
8785 $brave_language,
8786 $nonce
8787 );
8788 echo '</div>';
8789 echo '<p class="description">' . esc_html__('Enter the language code (e.g., "en" for English).', 'mxchat') . '</p>';
8790 }
8791
8792
8793
8794
8795
8796 // Section Callback
8797 public function mxchat_pdf_intent_section_callback() {
8798 echo '<p>' . esc_html__('Configure the intent settings for the Chat with PDF feature.', 'mxchat') . '</p>';
8799 }
8800
8801 public function mxchat_chat_toolbar_toggle_callback() {
8802 // Get chat toolbar toggle value with fallback
8803 $chat_toolbar_toggle = isset($this->options['chat_toolbar_toggle']) ? $this->options['chat_toolbar_toggle'] : 'off';
8804 $checked = ($chat_toolbar_toggle === 'on') ? 'checked' : '';
8805
8806 // Output the toggle switch
8807 echo '<label class="toggle-switch">';
8808 echo sprintf(
8809 '<input type="checkbox" id="chat_toolbar_toggle" name="chat_toolbar_toggle" value="on" %s />',
8810 esc_attr($checked)
8811 );
8812 echo '<span class="slider"></span>';
8813 echo '</label>';
8814
8815 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>';
8816 }
8817
8818 /**
8819 * Callback for PDF upload button toggle setting
8820 */
8821 public function mxchat_show_pdf_upload_button_callback() {
8822 // Get toggle value with fallback
8823 $show_pdf_button = isset($this->options['show_pdf_upload_button']) ? $this->options['show_pdf_upload_button'] : 'on';
8824 $checked = ($show_pdf_button === 'on') ? 'checked' : '';
8825
8826 // Output the toggle switch
8827 echo '<label class="toggle-switch">';
8828 echo sprintf(
8829 '<input type="checkbox" id="show_pdf_upload_button" name="show_pdf_upload_button" value="on" %s />',
8830 esc_attr($checked)
8831 );
8832 echo '<span class="slider"></span>';
8833 echo '</label>';
8834
8835 echo '<p class="description">' . esc_html__('Enable to show the PDF upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
8836 }
8837
8838 /**
8839 * Callback for Word upload button toggle setting
8840 */
8841 public function mxchat_show_word_upload_button_callback() {
8842 // Get toggle value with fallback
8843 $show_word_button = isset($this->options['show_word_upload_button']) ? $this->options['show_word_upload_button'] : 'on';
8844 $checked = ($show_word_button === 'on') ? 'checked' : '';
8845
8846 // Output the toggle switch
8847 echo '<label class="toggle-switch">';
8848 echo sprintf(
8849 '<input type="checkbox" id="show_word_upload_button" name="show_word_upload_button" value="on" %s />',
8850 esc_attr($checked)
8851 );
8852 echo '<span class="slider"></span>';
8853 echo '</label>';
8854
8855 echo '<p class="description">' . esc_html__('Enable to show the Word document upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
8856 }
8857
8858 public function mxchat_pdf_intent_trigger_text_callback() {
8859 $default_text = __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
8860
8861 echo sprintf(
8862 '<textarea id="pdf_intent_trigger_text"
8863 name="pdf_intent_trigger_text"
8864 rows="3"
8865 cols="50"
8866 placeholder="%s">%s</textarea>',
8867 esc_attr__('Enter trigger text', 'mxchat'),
8868 isset($this->options['pdf_intent_trigger_text'])
8869 ? esc_textarea($this->options['pdf_intent_trigger_text'])
8870 : esc_textarea($default_text)
8871 );
8872 echo '<p class="description">' . esc_html__('Text displayed when the intent is triggered.', 'mxchat') . '</p>';
8873 }
8874
8875 public function mxchat_pdf_intent_success_text_callback() {
8876 $default_text = __("I've processed the PDF. What questions do you have about it?", 'mxchat');
8877
8878 echo sprintf(
8879 '<textarea id="pdf_intent_success_text"
8880 name="pdf_intent_success_text"
8881 rows="3"
8882 cols="50"
8883 placeholder="%s">%s</textarea>',
8884 esc_attr__('Enter success text', 'mxchat'),
8885 isset($this->options['pdf_intent_success_text'])
8886 ? esc_textarea($this->options['pdf_intent_success_text'])
8887 : esc_textarea($default_text)
8888 );
8889 echo '<p class="description">' . esc_html__('Text displayed when the intent is successful.', 'mxchat') . '</p>';
8890 }
8891
8892 public function mxchat_pdf_intent_error_text_callback() {
8893 $default_text = __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
8894
8895 echo sprintf(
8896 '<textarea id="pdf_intent_error_text"
8897 name="pdf_intent_error_text"
8898 rows="3"
8899 cols="50"
8900 placeholder="%s">%s</textarea>',
8901 esc_attr__('Enter error text', 'mxchat'),
8902 isset($this->options['pdf_intent_error_text'])
8903 ? esc_textarea($this->options['pdf_intent_error_text'])
8904 : esc_textarea($default_text)
8905 );
8906 echo '<p class="description">' . esc_html__('Text displayed when an error occurs during the intent.', 'mxchat') . '</p>';
8907 }
8908
8909 public function mxchat_pdf_max_pages_callback() {
8910 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
8911
8912 echo sprintf(
8913 '<input type="range"
8914 id="pdf_max_pages"
8915 name="pdf_max_pages"
8916 min="1"
8917 max="69"
8918 value="%d"
8919 class="range-slider" />',
8920 esc_attr($max_pages)
8921 );
8922 echo '<span id="pdf_max_pages_output">' . esc_html($max_pages) . '</span>';
8923 echo '<p class="description">' . esc_html__('Set the maximum number of document pages users can upload for processing. (1-69 pages)', 'mxchat') . '</p>';
8924 }
8925
8926 public function mxchat_live_agent_status_callback() {
8927 // Always get fresh options instead of using cached $this->options
8928 $fresh_options = get_option('mxchat_options');
8929 $status = isset($fresh_options['live_agent_status']) ? $fresh_options['live_agent_status'] : 'off';
8930
8931 echo '<label class="toggle-switch">';
8932 echo sprintf(
8933 '<input type="checkbox" id="live_agent_status" name="live_agent_status" value="on" %s />',
8934 checked($status, 'on', false)
8935 );
8936 echo '<span class="slider"></span>';
8937 echo '</label>';
8938 echo '<label for="live_agent_status" class="mxchat-status-label">';
8939 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
8940 echo '</label>';
8941 }
8942
8943 /**
8944 * Availability-schedule editor (plans 8ccaa2 + 99d7a4).
8945 *
8946 * ONE callback, parameterized by channel ('slack' | 'telegram' via the
8947 * add_settings_field $args) — the markup and CSS are shared, only the bound
8948 * option and the helper text differ. Each channel's editor renders under its
8949 * own Integrations tab and governs ONLY that channel's handoff. While the
8950 * master toggle is off the day grid is inert and handoff availability stays
8951 * governed solely by that channel's manual status toggle.
8952 *
8953 * The day inputs carry NO name attribute and are marked .mxchat-la-field so the
8954 * generic autosave skips them; the editor JS folds them into the channel's
8955 * hidden live_agent_schedule_<channel> input and fires one change, reusing the
8956 * existing autosave transport rather than growing a second one. All hooks the
8957 * JS needs are CLASSES scoped inside .mxchat-la-schedule, never ids — the
8958 * markup exists twice on the page.
8959 */
8960 public function mxchat_live_agent_schedule_callback($args = array()) {
8961 if (!class_exists('MxChat_Live_Agent_Schedule')) {
8962 return;
8963 }
8964 $channel = (isset($args['channel']) && $args['channel'] === 'telegram') ? 'telegram' : 'slack';
8965 $field = 'live_agent_schedule_' . $channel;
8966 $sched = MxChat_Live_Agent_Schedule::get($channel);
8967 $labels = MxChat_Live_Agent_Schedule::day_labels();
8968 $tz = MxChat_Live_Agent_Schedule::timezone_label();
8969 $enabled = !empty($sched['enabled']);
8970 $channel_label = ($channel === 'telegram') ? __('Telegram', 'mxchat') : __('Slack', 'mxchat');
8971 ?>
8972 <div class="mxchat-la-schedule<?php echo $enabled ? ' is-active' : ''; ?>"
8973 id="mxchat-la-schedule-<?php echo esc_attr($channel); ?>"
8974 data-channel="<?php echo esc_attr($channel); ?>">
8975 <label class="toggle-switch">
8976 <input type="checkbox" id="<?php echo esc_attr($field); ?>_enabled"
8977 class="mxchat-la-field mxchat-la-enabled" <?php checked($enabled); ?> />
8978 <span class="slider"></span>
8979 </label>
8980 <label for="<?php echo esc_attr($field); ?>_enabled" class="mxchat-status-label">
8981 <span class="status-text mxchat-la-status-text">
8982 <?php echo $enabled
8983 ? esc_html__('Scheduled hours', 'mxchat')
8984 : esc_html__('Always available', 'mxchat'); ?>
8985 </span>
8986 </label>
8987
8988 <div class="mxchat-la-days" aria-hidden="<?php echo $enabled ? 'false' : 'true'; ?>">
8989 <?php foreach ($labels as $n => $label) :
8990 $day = $sched['days'][$n];
8991 $on = !empty($day['enabled']);
8992 ?>
8993 <div class="mxchat-la-day<?php echo $on ? ' is-on' : ''; ?>" data-day="<?php echo esc_attr($n); ?>">
8994 <label class="mxchat-la-day-label">
8995 <input type="checkbox" class="mxchat-la-field mxchat-la-day-enabled"
8996 data-day="<?php echo esc_attr($n); ?>" <?php checked($on); ?> />
8997 <span class="mxchat-la-day-name"><?php echo esc_html($label); ?></span>
8998 </label>
8999 <div class="mxchat-la-times">
9000 <input type="time" class="mxchat-la-field mxchat-la-start"
9001 data-day="<?php echo esc_attr($n); ?>"
9002 value="<?php echo esc_attr($day['start']); ?>"
9003 aria-label="<?php echo esc_attr(sprintf(__('%s start time', 'mxchat'), $label)); ?>" />
9004 <span class="mxchat-la-dash">&ndash;</span>
9005 <input type="time" class="mxchat-la-field mxchat-la-end"
9006 data-day="<?php echo esc_attr($n); ?>"
9007 value="<?php echo esc_attr($day['end']); ?>"
9008 aria-label="<?php echo esc_attr(sprintf(__('%s end time', 'mxchat'), $label)); ?>" />
9009 </div>
9010 </div>
9011 <?php endforeach; ?>
9012 </div>
9013
9014 <input type="hidden" name="<?php echo esc_attr($field); ?>" id="<?php echo esc_attr($field); ?>"
9015 class="mxchat-la-hidden" value="<?php echo esc_attr(wp_json_encode($sched)); ?>" />
9016 </div>
9017 <p class="description">
9018 <?php printf(
9019 /* translators: 1: the handoff channel, e.g. Slack or Telegram; 2: the site's timezone, e.g. America/New_York */
9020 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'),
9021 '<strong>' . esc_html($channel_label) . '</strong>',
9022 '<strong>' . esc_html($tz) . '</strong>'
9023 ); ?>
9024 </p>
9025 <?php
9026 }
9027
9028 public function mxchat_live_agent_away_message_callback() {
9029 $message = isset($this->options['live_agent_away_message'])
9030 ? $this->options['live_agent_away_message']
9031 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9032
9033 printf(
9034 '<textarea id="live_agent_away_message" name="live_agent_away_message" rows="3" cols="50">%s</textarea>',
9035 esc_textarea($message)
9036 );
9037 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9038 }
9039
9040 public function mxchat_live_agent_notification_message_callback() {
9041 $message = isset($this->options['live_agent_notification_message'])
9042 ? $this->options['live_agent_notification_message']
9043 : __('Live agent has been notified.', 'mxchat');
9044
9045 printf(
9046 '<textarea id="live_agent_notification_message" name="live_agent_notification_message" rows="3" cols="50">%s</textarea>',
9047 esc_textarea($message)
9048 );
9049 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9050 }
9051
9052 public function mxchat_live_agent_webhook_url_callback() {
9053 $webhook_url = isset($this->options['live_agent_webhook_url'])
9054 ? esc_url($this->options['live_agent_webhook_url'])
9055 : esc_url(get_option('live_agent_webhook_url', ''));
9056
9057 printf(
9058 '<input type="password" id="live_agent_webhook_url" name="live_agent_webhook_url" value="%s" class="regular-text" />',
9059 $webhook_url
9060 );
9061 echo '<button type="button" id="toggleWebhookUrlVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9062 echo '<p class="description">' . esc_html__('Enter your Slack webhook URL for live agent notifications.', 'mxchat') . '</p>';
9063 }
9064
9065 public function mxchat_live_agent_secret_key_callback() {
9066 printf(
9067 '<input type="password" id="live_agent_secret_key" name="live_agent_secret_key" value="%s" class="regular-text" />',
9068 isset($this->options['live_agent_secret_key']) ? esc_attr($this->options['live_agent_secret_key']) : ''
9069 );
9070 echo '<button type="button" id="toggleSecretKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9071 echo '<p class="description">' . esc_html__('Secret key for validating Slack requests. Keep this secure.', 'mxchat') . '</p>';
9072 }
9073
9074 public function mxchat_live_agent_bot_token_callback() {
9075 printf(
9076 '<input type="password" id="live_agent_bot_token" name="live_agent_bot_token" value="%s" class="regular-text" />',
9077 isset($this->options['live_agent_bot_token']) ? esc_attr($this->options['live_agent_bot_token']) : ''
9078 );
9079 echo '<button type="button" id="toggleBotTokenVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9080 echo '<p class="description">' . esc_html__('Your Slack Bot OAuth Token (starts with xoxb-). Keep this secure.', 'mxchat') . '</p>';
9081 }
9082
9083 public function mxchat_live_agent_user_ids_callback() {
9084 $user_ids = isset($this->options['live_agent_user_ids'])
9085 ? esc_textarea($this->options['live_agent_user_ids'])
9086 : '';
9087
9088 printf(
9089 '<textarea id="live_agent_user_ids" name="live_agent_user_ids" rows="4" class="large-text">%s</textarea>',
9090 $user_ids
9091 );
9092 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>';
9093 }
9094
9095 public function mxchat_live_agent_shared_channel_callback() {
9096 $value = isset($this->options['live_agent_shared_channel']) ? $this->options['live_agent_shared_channel'] : '';
9097 printf(
9098 '<input type="text" id="live_agent_shared_channel" name="live_agent_shared_channel" value="%s" class="regular-text" placeholder="#support or C0123456789" />',
9099 esc_attr($value)
9100 );
9101 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>';
9102
9103 // Surface the last failed handoff so a wrong name / missing invite is
9104 // visible right where it gets fixed. A successful handoff clears this.
9105 $shared_error = get_option('mxchat_slack_shared_channel_error');
9106 if (!empty($shared_error['error']) && trim((string) $value) !== '' && ($shared_error['configured'] ?? '') === trim((string) $value)) {
9107 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this channel', 'mxchat') . '</strong> — '
9108 . esc_html(sprintf(
9109 /* translators: %s: Slack API error code */
9110 __('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'),
9111 $shared_error['error']
9112 )) . '</p>';
9113 }
9114 }
9115
9116 public function mxchat_live_agent_archive_on_end_callback() {
9117 $value = isset($this->options['live_agent_archive_on_end_toggle']) ? $this->options['live_agent_archive_on_end_toggle'] : 'off';
9118 printf(
9119 '<input type="checkbox" id="live_agent_archive_on_end_toggle" name="live_agent_archive_on_end_toggle" value="on" %s />',
9120 checked('on', $value, false)
9121 );
9122 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>';
9123 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>';
9124 }
9125
9126 /**
9127 * Telegram Integration Callbacks
9128 */
9129 public function mxchat_telegram_section_callback() {
9130 echo '<p>' . esc_html__('Configure Telegram integration for live agent support.', 'mxchat') . '</p>';
9131 }
9132
9133 public function mxchat_telegram_status_callback() {
9134 $fresh_options = get_option('mxchat_options');
9135 $status = isset($fresh_options['telegram_status']) ? $fresh_options['telegram_status'] : 'off';
9136
9137 echo '<label class="toggle-switch">';
9138 echo sprintf(
9139 '<input type="checkbox" id="telegram_status" name="telegram_status" value="on" %s />',
9140 checked($status, 'on', false)
9141 );
9142 echo '<span class="slider"></span>';
9143 echo '</label>';
9144 echo '<label for="telegram_status" class="mxchat-status-label">';
9145 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9146 echo '</label>';
9147 }
9148
9149 public function mxchat_telegram_notification_message_callback() {
9150 $message = isset($this->options['telegram_notification_message'])
9151 ? $this->options['telegram_notification_message']
9152 : __("I've notified a support agent. Please allow a moment for them to respond.", 'mxchat');
9153
9154 printf(
9155 '<textarea id="telegram_notification_message" name="telegram_notification_message" rows="3" cols="50">%s</textarea>',
9156 esc_textarea($message)
9157 );
9158 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9159 }
9160
9161 public function mxchat_telegram_away_message_callback() {
9162 $message = isset($this->options['telegram_away_message'])
9163 ? $this->options['telegram_away_message']
9164 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9165
9166 printf(
9167 '<textarea id="telegram_away_message" name="telegram_away_message" rows="3" cols="50">%s</textarea>',
9168 esc_textarea($message)
9169 );
9170 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9171 }
9172
9173 public function mxchat_telegram_bot_token_callback() {
9174 printf(
9175 '<input type="password" id="telegram_bot_token" name="telegram_bot_token" value="%s" class="regular-text" />',
9176 isset($this->options['telegram_bot_token']) ? esc_attr($this->options['telegram_bot_token']) : ''
9177 );
9178 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>';
9179 echo '<p class="description">' . esc_html__('Your Telegram Bot Token from @BotFather (e.g., 123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ).', 'mxchat') . '</p>';
9180 }
9181
9182 public function mxchat_telegram_group_id_callback() {
9183 printf(
9184 '<input type="text" id="telegram_group_id" name="telegram_group_id" value="%s" class="regular-text" />',
9185 isset($this->options['telegram_group_id']) ? esc_attr($this->options['telegram_group_id']) : ''
9186 );
9187 echo '<p class="description">' . esc_html__('Your Telegram supergroup ID (starts with -100). The group must have forum topics enabled.', 'mxchat') . '</p>';
9188 }
9189
9190 public function mxchat_telegram_webhook_secret_callback() {
9191 $secret = isset($this->options['telegram_webhook_secret']) ? $this->options['telegram_webhook_secret'] : '';
9192
9193 // Auto-generate secret if empty
9194 if (empty($secret)) {
9195 $secret = wp_generate_password(64, false, false);
9196 $options = get_option('mxchat_options', []);
9197 $options['telegram_webhook_secret'] = $secret;
9198 update_option('mxchat_options', $options);
9199 $this->options['telegram_webhook_secret'] = $secret;
9200 }
9201
9202 printf(
9203 '<input type="password" id="telegram_webhook_secret" name="telegram_webhook_secret" value="%s" class="regular-text" />',
9204 esc_attr($secret)
9205 );
9206 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>';
9207 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>';
9208 }
9209
9210 public function mxchat_similarity_threshold_callback() {
9211 // Load from mxchat_options array
9212 $options = get_option('mxchat_options', []);
9213
9214 // Get value from options array with default of 35
9215 $threshold = isset($options['similarity_threshold']) ? $options['similarity_threshold'] : 35;
9216
9217 echo '<div class="slider-container">';
9218 echo sprintf(
9219 '<input type="range"
9220 id="similarity_threshold"
9221 name="similarity_threshold"
9222 min="20"
9223 max="85"
9224 step="1"
9225 value="%s"
9226 class="range-slider" />',
9227 esc_attr($threshold)
9228 );
9229 echo sprintf(
9230 '<span id="threshold_value" class="range-value">%s</span>',
9231 esc_html($threshold)
9232 );
9233 echo '</div>';
9234 }
9235
9236 public function mxchat_max_input_length_callback() {
9237 // Load from mxchat_options array (plan a3fae2 part C).
9238 $options = get_option('mxchat_options', []);
9239
9240 // 0 = unlimited (default — preserves current behavior, no cap).
9241 $max_input_length = isset($options['max_input_length']) ? intval($options['max_input_length']) : 0;
9242
9243 echo sprintf(
9244 '<input type="number"
9245 id="max_input_length"
9246 name="max_input_length"
9247 min="0"
9248 max="100000"
9249 step="1"
9250 value="%s"
9251 placeholder="0"
9252 class="mxch-input mxch-input-sm" />',
9253 esc_attr($max_input_length)
9254 );
9255 }
9256
9257 public function mxchat_rag_sources_limit_callback() {
9258 // Load from mxchat_options array
9259 $options = get_option('mxchat_options', []);
9260
9261 // Get value from options array with default of 3
9262 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
9263
9264 echo '<div class="slider-container">';
9265 echo sprintf(
9266 '<input type="range"
9267 id="rag_sources_limit"
9268 name="rag_sources_limit"
9269 min="3"
9270 max="10"
9271 step="1"
9272 value="%s"
9273 class="range-slider" />',
9274 esc_attr($rag_sources_limit)
9275 );
9276 echo sprintf(
9277 '<span id="rag_sources_limit_value" class="range-value">%s</span>',
9278 esc_html($rag_sources_limit)
9279 );
9280 echo '</div>';
9281 }
9282
9283 public function mxchat_rag_chunks_limit_callback() {
9284 // Load from mxchat_options array
9285 $options = get_option('mxchat_options', []);
9286
9287 // Get value from options array with default of 15
9288 $rag_chunks_limit = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
9289
9290 echo '<div class="slider-container">';
9291 echo sprintf(
9292 '<input type="range"
9293 id="rag_chunks_limit"
9294 name="rag_chunks_limit"
9295 min="8"
9296 max="20"
9297 step="1"
9298 value="%s"
9299 class="range-slider" />',
9300 esc_attr($rag_chunks_limit)
9301 );
9302 echo sprintf(
9303 '<span id="rag_chunks_limit_value" class="range-value">%s</span>',
9304 esc_html($rag_chunks_limit)
9305 );
9306 echo '</div>';
9307 }
9308
9309 /**
9310 * Add body class on the Onboarding wizard page so CSS-only chrome surgery
9311 * (collapsing the WP admin sidebar) only applies on this page.
9312 * Plan: plan-mxchat-20260527-905439.
9313 */
9314 public function mxchat_add_onboarding_body_class($classes) {
9315 if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
9316 $classes .= ' mxchat-onboarding-focused';
9317 }
9318 return $classes;
9319 }
9320
9321 public function mxchat_enqueue_admin_assets($hook_suffix = '') {
9322 // Authorization gate (plan-mxchat-20260731-c63fb6). Previously the ONLY
9323 // guard here was the strpos() on $_GET['page'] below — which is
9324 // attacker-controlled — so ANY logged-in user (Subscriber included) could
9325 // load a page they are legitimately allowed to see, e.g.
9326 // /wp-admin/profile.php?page=mxchat, and receive every nonce this method
9327 // localizes. Three of the handlers behind those nonces verified the nonce
9328 // but checked no capability. Every MxChat menu page is registered
9329 // 'manage_options' (see mxchat_add_admin_menu), so this is a no-op for
9330 // legitimate users.
9331 if (!current_user_can('manage_options')) {
9332 return;
9333 }
9334
9335 // Get plugin version
9336 $version = MXCHAT_VERSION;
9337
9338 // Use file modification time for development (remove in production)
9339 if (defined('WP_DEBUG') && WP_DEBUG) {
9340 $version = filemtime(plugin_dir_path(__FILE__) . '../mxchat-basic.php');
9341 }
9342
9343 $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
9344 $plugin_url = plugin_dir_url(__FILE__) . '../';
9345
9346 // Only load on MxChat pages. Prefer the server-derived $hook_suffix that
9347 // admin_enqueue_scripts passes us — unlike $_GET['page'] it cannot be
9348 // forged onto a screen MxChat does not own. Falls back to the legacy
9349 // $_GET check only when the hook is unavailable (direct/legacy callers).
9350 if ($hook_suffix !== '') {
9351 if (strpos($hook_suffix, 'mxchat') === false) {
9352 return;
9353 }
9354 } elseif (strpos($current_page, 'mxchat') === false) {
9355 return;
9356 }
9357
9358 // Always load these on all MxChat pages
9359 $this->enqueue_core_admin_assets($plugin_url, $version);
9360 $this->enqueue_page_specific_assets($current_page, $plugin_url, $version);
9361 $this->localize_admin_scripts($current_page);
9362 }
9363 private function enqueue_core_admin_assets($plugin_url, $version) {
9364 // Core admin styles
9365 wp_enqueue_style('mxchat-admin-css', $plugin_url . 'css/admin-style.css', array(), $version);
9366 wp_enqueue_style('mxchat-knowledge-css', $plugin_url . 'css/knowledge-style.css', array(), $version);
9367
9368 // New sidebar navigation styles (for main settings page)
9369 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9370
9371 // Core admin scripts
9372 wp_enqueue_script('mxchat-admin-js', $plugin_url . 'js/mxchat-admin.js', array('jquery'), $version, true);
9373 }
9374 private function enqueue_page_specific_assets($current_page, $plugin_url, $version) {
9375 switch ($current_page) {
9376 case 'mxchat-prompts':
9377 // Knowledge processing page assets
9378 wp_enqueue_style('mxchat-content-selector-css', $plugin_url . 'css/content-selector.css', array(), $version);
9379 wp_enqueue_script('mxchat-content-selector-js', $plugin_url . 'js/content-selector.js', array('jquery'), $version, true);
9380 // Add the knowledge processing script (common script needed for WordPress dismiss functionality)
9381 wp_enqueue_script('mxchat-knowledge-processing', $plugin_url . 'js/knowledge-processing.js', array('jquery', 'common'), $version, true);
9382 // Per-entry "View indexed content" inspector (plan-d8cb4b) reuses the
9383 // Testing tab's match-card / chunk-detail components, which are scoped
9384 // under .mxch-testing-results. Load that stylesheet here so the inspector
9385 // modal renders identically to the Testing tab.
9386 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css'), $version);
9387 break;
9388
9389 case 'mxchat-transcripts':
9390 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9391 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9392 // Load transcripts-specific styles
9393 wp_enqueue_style('mxchat-chat-transcripts-css', $plugin_url . 'css/chat-transcripts.css', array('mxchat-admin-sidebar-css'), $version);
9394 wp_enqueue_script('mxchat-transcripts-js', $plugin_url . 'js/mxchat_transcripts.js', array('jquery'), $version, true);
9395 break;
9396
9397 case 'mxchat-actions':
9398 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9399 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9400 // Load actions-specific styles
9401 wp_enqueue_style('mxchat-actions-css', $plugin_url . 'css/actions.css', array('mxchat-admin-sidebar-css'), $version);
9402 wp_enqueue_script('mxchat-actions-js', $plugin_url . 'js/mxchat_actions.js', array('jquery'), $version, true);
9403
9404 // Localize script data for actions page
9405 $is_activated = get_option('mxchat_pro_license_status') === 'active';
9406 wp_localize_script('mxchat-actions-js', 'mxchActionsData', array(
9407 'ajaxUrl' => admin_url('admin-ajax.php'),
9408 'nonce' => wp_create_nonce('mxchat_actions_nonce'),
9409 'addNonce' => wp_create_nonce('mxchat_add_intent_nonce'),
9410 'editNonce' => wp_create_nonce('mxchat_edit_intent'),
9411 'deleteNonce' => wp_create_nonce('mxchat_delete_intent_nonce'),
9412 'toggleNonce' => wp_create_nonce('mxchat_actions_nonce'),
9413 'addPhraseNonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
9414 'deletePhraseNonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
9415 'getPhrasesNonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
9416 'deleteLegacyNonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
9417 'isActivated' => $is_activated,
9418 'i18n' => array(
9419 'confirmDelete' => __('Are you sure you want to delete this trigger phrase?', 'mxchat'),
9420 'confirmBulkDelete' => __('Are you sure you want to delete the selected trigger phrases?', 'mxchat'),
9421 'saving' => __('Saving...', 'mxchat'),
9422 'saved' => __('Saved successfully!', 'mxchat'),
9423 'error' => __('An error occurred. Please try again.', 'mxchat'),
9424 'proRequired' => __('This feature requires MxChat Pro.', 'mxchat'),
9425 'addonRequired' => __('This feature requires an add-on.', 'mxchat')
9426 )
9427 ));
9428 break;
9429
9430 case 'mxchat-activation':
9431 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9432 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9433 // Load pro page-specific styles
9434 wp_enqueue_style('mxchat-pro-css', $plugin_url . 'css/admin-pro.css', array('mxchat-admin-sidebar-css'), $version);
9435 // Load pro page JavaScript
9436 wp_enqueue_script('mxchat-pro-js', $plugin_url . 'js/mxchat_pro.js', array('jquery'), $version, true);
9437 // Load activation script for license activation/deactivation
9438 wp_enqueue_script('mxchat-activation-js', $plugin_url . 'js/activation-script.js', array('jquery'), $version, true);
9439 break;
9440
9441 case 'mxchat-content':
9442 // Load admin sidebar CSS (shared styles for sidebar navigation)
9443 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9444 // Load content page-specific styles
9445 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
9446 // Load content page JavaScript
9447 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
9448 break;
9449
9450 case 'mxchat-api-access':
9451 // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
9452 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9453 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9454 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9455 'copied' => __('Copied', 'mxchat'),
9456 ));
9457 break;
9458
9459 case 'mxchat-max':
9460 case 'mxchat-onboarding':
9461 // Onboarding page — uses the shared admin shell PLUS the wizard
9462 // overlay (plan-905439). admin-onboarding-wizard.css scopes the
9463 // WP-chrome surgery to body.mxchat-onboarding-focused so it only
9464 // applies on THIS page. The body class is added below via the
9465 // admin_body_class filter.
9466 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9467 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9468 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9469 'copied' => __('Copied', 'mxchat'),
9470 ));
9471 // admin-style.css provides the .mxchat-instructions-modal-* classes
9472 // the new Behavior step's "View Sample Instructions" modal needs.
9473 // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
9474 // so the wizard's own rules win where they collide. plan-a2e4d6.
9475 wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
9476 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);
9477 wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
9478 break;
9479 default:
9480 // Settings page: load the shared shell JS for #hash deep-linking
9481 // to tabs (plan 4ede16). The page's own inline nav script owns
9482 // clicks/accordion behaviour; the shell script adds load +
9483 // hashchange activation and replaceState so plain href="#tab"
9484 // anchors and shared URLs land on the right tab. Kept inside the
9485 // default case because the settings page also needs everything
9486 // below (Testing tab chatbot + streaming assets).
9487 if ($current_page === 'mxchat-settings') {
9488 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9489 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9490 'copied' => __('Copied', 'mxchat'),
9491 ));
9492 }
9493
9494 wp_enqueue_script(
9495 'mxchat-test-streaming-js',
9496 $plugin_url . 'js/mxchat-test-streaming.js',
9497 ['jquery'],
9498 $version,
9499 true
9500 );
9501
9502 wp_localize_script('mxchat-test-streaming-js', 'mxchatTestStreamingAjax', [
9503 'ajax_url' => admin_url('admin-ajax.php'),
9504 'nonce' => wp_create_nonce('mxchat_test_streaming_nonce'),
9505 'settings_nonce' => wp_create_nonce('mxchat_save_setting_nonce')
9506 ]);
9507
9508 // Testing Tab: Load chatbot assets for the embedded testing chatbot
9509 wp_enqueue_style('mxchat-chat-css', $plugin_url . 'css/chat-style.css', array(), $version);
9510 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css', 'mxchat-chat-css'), $version);
9511
9512 wp_enqueue_script('mxchat-chat-js', $plugin_url . 'js/chat-script.js', array('jquery'), $version, true);
9513 wp_enqueue_script('mxchat-admin-testing-js', $plugin_url . 'js/admin-testing-tab.js', array('jquery', 'mxchat-chat-js'), $version, true);
9514
9515 // Allow add-ons to enqueue their public CSS/JS for the testing chatbot
9516 do_action('mxchat_enqueue_testing_tab_assets');
9517
9518 // Localize mxchatChat for the chatbot JS (same settings as frontend)
9519 $options = get_option('mxchat_options', array());
9520 $prompts_options = get_option('mxchat_prompts_options', array());
9521 $theme_options = get_option('mxchat_theme_options', array());
9522 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9523 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9524 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9525
9526 wp_localize_script('mxchat-chat-js', 'mxchatChat', array(
9527 'ajax_url' => admin_url('admin-ajax.php'),
9528 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9529 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
9530 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
9531 'contextual_awareness_toggle' => isset($options['contextual_awareness_toggle']) ? $options['contextual_awareness_toggle'] : 'off',
9532 'link_target_toggle' => $options['link_target_toggle'] ?? 'off',
9533 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9534 'complianz_toggle' => isset($options['complianz_toggle']) && $options['complianz_toggle'] === 'on',
9535 'user_message_bg_color' => $options['user_message_bg_color'] ?? '#fff',
9536 'user_message_font_color' => $options['user_message_font_color'] ?? '#212121',
9537 'bot_message_bg_color' => $options['bot_message_bg_color'] ?? '#212121',
9538 'bot_message_font_color' => $options['bot_message_font_color'] ?? '#fff',
9539 'top_bar_bg_color' => $options['top_bar_bg_color'] ?? '#212121',
9540 'send_button_font_color' => $options['send_button_font_color'] ?? '#212121',
9541 'close_button_color' => $options['close_button_color'] ?? '#fff',
9542 'chatbot_background_color' => $options['chatbot_background_color'] ?? '#212121',
9543 'chatbot_bg_color' => $options['chatbot_bg_color'] ?? '#fff',
9544 'icon_color' => $options['icon_color'] ?? '#fff',
9545 'chat_input_font_color' => $options['chat_input_font_color'] ?? '#212121',
9546 'chat_persistence_toggle' => 'off', // Always off for testing chatbot
9547 'appendWidgetToBody' => 'off',
9548 'live_agent_message_bg_color' => $options['live_agent_message_bg_color'] ?? '#ffffff',
9549 'live_agent_message_font_color' => $options['live_agent_message_font_color'] ?? '#333333',
9550 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
9551 'mode_indicator_bg_color' => $options['mode_indicator_bg_color'] ?? '#767676',
9552 'mode_indicator_font_color' => $options['mode_indicator_font_color'] ?? '#ffffff',
9553 'toolbar_icon_color' => $options['toolbar_icon_color'] ?? '#212121',
9554 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9555 'email_collection_enabled' => 'off', // No email collection in testing
9556 'initial_email_state' => null,
9557 'skip_email_check' => true,
9558 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9559 'skip_inline_colors' => $skip_inline_colors,
9560 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9561 ));
9562
9563 // Localize testing tab script data
9564 wp_localize_script('mxchat-admin-testing-js', 'mxchatAdminTestData', array(
9565 'ajaxUrl' => admin_url('admin-ajax.php'),
9566 'nonce' => wp_create_nonce('mxchat_test_nonce'),
9567 'isAdmin' => true,
9568 'testingEnabled' => true
9569 ));
9570
9571 // Add testing enabled flag for the chatbot to return debug data
9572 add_action('admin_footer', function() {
9573 echo '<script>window.mxchatTestingEnabled = true;</script>';
9574 });
9575
9576 break;
9577 }
9578 }
9579 private function localize_admin_scripts($current_page) {
9580 // Base localization data for main admin script
9581 $base_data = array(
9582 'ajax_url' => admin_url('admin-ajax.php'),
9583 'nonce' => wp_create_nonce('mxchat_admin_nonce'),
9584 'admin_url' => admin_url(),
9585 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce'),
9586 'inline_edit_nonce' => wp_create_nonce('mxchat_save_inline_nonce'),
9587 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9588 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
9589 'actions_nonce' => wp_create_nonce('mxchat_actions_nonce'),
9590 'add_intent_nonce' => wp_create_nonce('mxchat_add_intent_nonce'),
9591 'edit_intent_nonce' => wp_create_nonce('mxchat_edit_intent'),
9592 'add_phrase_nonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
9593 'delete_phrase_nonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
9594 'get_phrases_nonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
9595 'delete_legacy_nonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
9596 'toggle_action_nonce' => wp_create_nonce('mxchat_actions_nonce'),
9597 'fetch_openrouter_models_nonce' => wp_create_nonce('mxchat_fetch_openrouter_models'),
9598 'is_activated' => $this->is_activated ? '1' : '0',
9599 'status_refresh_interval' => 5000,
9600 'discard_changes_confirm' => __('Discard your unsaved changes?', 'mxchat'),
9601 // Live-agent schedule status text (plan 8ccaa2).
9602 'i18n_scheduled_hours' => __('Scheduled hours', 'mxchat'),
9603 'i18n_always_available' => __('Always available', 'mxchat'),
9604 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9605 'ajaxurl' => admin_url('admin-ajax.php')
9606 );
9607
9608 // Localize main admin script with base data
9609 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
9610
9611 // Canonical chat-model catalog for the modal picker grid
9612 // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
9613 // automatically appears here.
9614 if (!class_exists('MxChat_Model_Catalog')) {
9615 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9616 }
9617 wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
9618
9619 // Page-specific localizations
9620 $this->localize_page_specific_scripts($current_page);
9621 }
9622 private function localize_page_specific_scripts($current_page) {
9623 switch ($current_page) {
9624 case 'mxchat-prompts':
9625 // Status updater localization
9626 wp_localize_script('mxchat-status-updater', 'mxchat_status_data', array(
9627 'ajax_url' => admin_url('admin-ajax.php'),
9628 'nonce' => wp_create_nonce('mxchat_status_nonce')
9629 ));
9630
9631 // Content selector localization
9632 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
9633 'ajaxurl' => admin_url('admin-ajax.php'),
9634 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
9635 'i18n' => array(
9636 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
9637 'selectAll' => __('Select All', 'mxchat'),
9638 'process' => __('Process Selected', 'mxchat'),
9639 'cancel' => __('Cancel', 'mxchat'),
9640 'noResults' => __('No content found.', 'mxchat'),
9641 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
9642 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
9643 )
9644 ));
9645
9646 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array(
9647 'ajax_url' => admin_url('admin-ajax.php'),
9648 'status_nonce' => wp_create_nonce('mxchat_status_nonce'),
9649 'queue_nonce' => wp_create_nonce('mxchat_queue_nonce'),
9650 'stop_nonce' => wp_create_nonce('mxchat_stop_processing_action'),
9651 'settings_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9652 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9653 'entries_nonce' => wp_create_nonce('mxchat_entries_nonce'),
9654 'admin_url' => admin_url(),
9655 'ajaxurl' => admin_url('admin-ajax.php'),
9656 'status_refresh_interval' => 2000,
9657 'bot_id' => isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default'
9658 ));
9659 break;
9660
9661 case 'mxchat-activation':
9662 wp_localize_script('mxchat-activation-js', 'mxchatAdmin', array(
9663 'ajax_url' => admin_url('admin-ajax.php'),
9664 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce')
9665 ));
9666 break;
9667
9668 case 'mxchat-content':
9669 wp_localize_script('mxchat-content-js', 'mxchatContent', array(
9670 'ajaxUrl' => admin_url('admin-ajax.php'),
9671 'nonce' => wp_create_nonce('mxchat_content_nonce'),
9672 'settingNonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9673 'previewUrl' => home_url('/?p='),
9674 'isActivated' => $this->is_activated(),
9675 'hasAdvancedContent' => apply_filters('mxchat_content_pro_feature', false, 'seo_readability'),
9676 'hasGSC' => apply_filters('mxchat_content_pro_feature', false, 'gsc_integration'),
9677 // Whether Search Console is actually LINKED — distinct from hasGSC
9678 // (add-on active). Reads the same option the add-on's settings
9679 // card keys off; harmless false when the add-on is absent.
9680 'gscLinked' => (bool) get_option('mxchat_gsc_connected', false),
9681 'seoOptimize' => array(
9682 'meta_description' => ($options['seo_optimize_meta_desc'] ?? 'on') === 'on',
9683 'seo_title' => ($options['seo_optimize_seo_title'] ?? 'on') === 'on',
9684 'slug' => ($options['seo_optimize_slug'] ?? 'on') === 'on',
9685 'readability' => ($options['seo_optimize_readability'] ?? 'on') === 'on',
9686 'internal_links' => ($options['seo_optimize_internal_links'] ?? 'on') === 'on',
9687 'img_alt' => ($options['seo_optimize_img_alt'] ?? 'on') === 'on',
9688 'featured_img' => ($options['seo_optimize_featured_img'] ?? 'on') === 'on',
9689 ),
9690 'i18n' => array(
9691 'generating' => __('Generating...', 'mxchat'),
9692 'planning' => __('Planning content structure...', 'mxchat'),
9693 'images' => __('Generating images...', 'mxchat'),
9694 'writing' => __('Writing full content...', 'mxchat'),
9695 'creating' => __('Creating WordPress post...', 'mxchat'),
9696 'done' => __('Content generated successfully!', 'mxchat'),
9697 'error' => __('An error occurred. Please try again.', 'mxchat'),
9698 'editSuccess' => __('Content updated successfully.', 'mxchat'),
9699 'promptEmpty' => __('Please enter a prompt.', 'mxchat'),
9700 )
9701 ));
9702 break;
9703
9704 case 'mxchat-transcripts':
9705 // Get chart data for localization
9706 $chart_data = $this->get_transcripts_chart_data();
9707
9708 wp_localize_script('mxchat-transcripts-js', 'mxchatAdmin', array(
9709 'ajax_url' => admin_url('admin-ajax.php'),
9710 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
9711 'delete_nonce' => wp_create_nonce('mxchat_delete_chat_history'),
9712 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
9713 'translate_nonce' => wp_create_nonce('mxchat_translate_messages')
9714 ));
9715
9716 // Localize chart data separately - use array_values to ensure proper JSON array encoding
9717 wp_localize_script('mxchat-transcripts-js', 'mxchatChartData', array(
9718 'labels' => array_values($chart_data['labels']),
9719 'chats' => array_values($chart_data['chats']),
9720 'messages' => array_values($chart_data['messages'])
9721 ));
9722 break;
9723
9724 case 'mxchat-settings':
9725 default:
9726 // The 'mxchatStyleSettings' localize that used to live here was
9727 // REMOVED by plan-mxchat-20260731-c63fb6.
9728 //
9729 // It targeted the script handle 'mxchat-color-picker', which is
9730 // registered/enqueued NOWHERE in the plugin — so wp_localize_script()
9731 // returned false and printed nothing. That accident was the only
9732 // thing keeping it from being a live secret disclosure: the payload
9733 // carried loops_api_key, live_agent_secret_key and
9734 // live_agent_bot_token in full, and this whole method ran for any
9735 // logged-in user (see the capability gate added to
9736 // mxchat_enqueue_admin_assets). One future
9737 // wp_enqueue_script('mxchat-color-picker', ...) would have printed
9738 // three secrets into page HTML for Subscribers.
9739 //
9740 // Nothing consumed the object: a tree-wide grep for
9741 // 'mxchatStyleSettings' returns only this call site, and js/ names
9742 // loops_api_key only at mxchat-admin.js:572, as a field-NAME string
9743 // in an autosave allowlist — not a value read. Removed entirely
9744 // rather than deleting just the three secret keys, so there is no
9745 // dead payload left for someone to re-arm.
9746 break;
9747 }
9748
9749 // Additional localization that was in the original code
9750 wp_localize_script('mxchat-admin-js', 'mxchatPromptsAdmin', array(
9751 'ajax_url' => admin_url('admin-ajax.php'),
9752 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
9753 ));
9754 }
9755
9756 public function mxchat_sanitize($input) {
9757 $new_input = array();
9758
9759 if (isset($input['api_key'])) {
9760 $new_input['api_key'] = sanitize_text_field($input['api_key']);
9761 }
9762
9763 if (isset($input['similarity_threshold'])) {
9764 $new_input['similarity_threshold'] = absint($input['similarity_threshold']); // Ensure it's an integer
9765 $new_input['similarity_threshold'] = min(max($new_input['similarity_threshold'], 20), 85); // Enforce range
9766 }
9767
9768 if (isset($input['rag_sources_limit'])) {
9769 $new_input['rag_sources_limit'] = absint($input['rag_sources_limit']); // Ensure it's an integer
9770 $new_input['rag_sources_limit'] = min(max($new_input['rag_sources_limit'], 3), 10); // Enforce range 3-10
9771 }
9772
9773 if (isset($input['rag_chunks_limit'])) {
9774 $new_input['rag_chunks_limit'] = absint($input['rag_chunks_limit']); // Ensure it's an integer
9775 $new_input['rag_chunks_limit'] = min(max($new_input['rag_chunks_limit'], 8), 20); // Enforce range 8-20
9776 }
9777
9778 if (isset($input['xai_api_key'])) {
9779 $new_input['xai_api_key'] = sanitize_text_field($input['xai_api_key']);
9780 }
9781
9782 if (isset($input['claude_api_key'])) {
9783 $new_input['claude_api_key'] = sanitize_text_field($input['claude_api_key']);
9784 }
9785
9786 if (isset($input['enable_streaming_toggle'])) {
9787 $new_input['enable_streaming_toggle'] = ($input['enable_streaming_toggle'] === 'on') ? 'on' : 'off';
9788 } else {
9789 // If checkbox not checked, it won't be in $input, so set to 'off'
9790 $new_input['enable_streaming_toggle'] = 'off';
9791 }
9792
9793 if (isset($input['enable_web_search'])) {
9794 $new_input['enable_web_search'] = ($input['enable_web_search'] === 'on') ? 'on' : 'off';
9795 } else {
9796 // If checkbox not checked, it won't be in $input, so set to 'off'
9797 $new_input['enable_web_search'] = 'off';
9798 }
9799
9800 if (isset($input['deepseek_api_key'])) {
9801 $new_input['deepseek_api_key'] = sanitize_text_field($input['deepseek_api_key']);
9802 }
9803
9804 if (isset($input['gemini_api_key'])) {
9805 $new_input['gemini_api_key'] = sanitize_text_field($input['gemini_api_key']);
9806 }
9807
9808 if (isset($input['enable_woocommerce_integration'])) {
9809 $new_input['enable_woocommerce_integration'] = $input['enable_woocommerce_integration'] === 'on' ? 'on' : 'off';
9810 }
9811
9812 if (isset($input['privacy_toggle'])) {
9813 $new_input['privacy_toggle'] = $input['privacy_toggle'];
9814 }
9815
9816 if (isset($input['complianz_toggle'])) {
9817 $new_input['complianz_toggle'] = $input['complianz_toggle'];
9818 }
9819
9820 // Handle custom privacy text input
9821 if (isset($input['privacy_text'])) {
9822 // Allow basic HTML for links
9823 $new_input['privacy_text'] = wp_kses_post($input['privacy_text']);
9824 }
9825
9826 if (isset($input['system_prompt_instructions'])) {
9827 $new_input['system_prompt_instructions'] = sanitize_textarea_field($input['system_prompt_instructions']);
9828 }
9829
9830 if (isset($input['mxchat_pro_email'])) {
9831 $new_input['mxchat_pro_email'] = sanitize_email($input['mxchat_pro_email']);
9832 }
9833
9834 if (isset($input['mxchat_activation_key'])) {
9835 $new_input['mxchat_activation_key'] = sanitize_text_field($input['mxchat_activation_key']);
9836 }
9837
9838 if (isset($input['append_to_body'])) {
9839 $new_input['append_to_body'] = $input['append_to_body'] === 'on' ? 'on' : 'off';
9840 }
9841
9842 // Post type visibility settings
9843 if (isset($input['post_type_visibility_mode'])) {
9844 $allowed_modes = array('all', 'include', 'exclude');
9845 $new_input['post_type_visibility_mode'] = in_array($input['post_type_visibility_mode'], $allowed_modes)
9846 ? $input['post_type_visibility_mode']
9847 : 'all';
9848 }
9849
9850 if (isset($input['post_type_visibility_list'])) {
9851 if (is_array($input['post_type_visibility_list'])) {
9852 $new_input['post_type_visibility_list'] = array_map('sanitize_key', $input['post_type_visibility_list']);
9853 } else {
9854 $new_input['post_type_visibility_list'] = array();
9855 }
9856 }
9857
9858 if (isset($input['contextual_awareness_toggle'])) {
9859 $new_input['contextual_awareness_toggle'] = $input['contextual_awareness_toggle'] === 'on' ? 'on' : 'off';
9860 }
9861
9862 if (isset($input['citation_links_toggle'])) {
9863 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
9864 }
9865
9866 // Satisfaction rating prompt — defaults ON when unchecked (so first save
9867 // doesn't accidentally disable it). The form posts 'on' when checked;
9868 // unchecked checkboxes don't post a value at all, so we infer 'off' only
9869 // when the autosave/PHP request explicitly clears it via empty string.
9870 if (array_key_exists('satisfaction_rating_enabled', $input)) {
9871 $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
9872 }
9873
9874 // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
9875 // the 4 text strings are sanitized + capped server-side. Blank values are
9876 // preserved so the integrator falls back to translated defaults.
9877 if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
9878 $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
9879 }
9880
9881 // Max input length in characters (plan a3fae2 part C). 0 = unlimited (default,
9882 // preserves current behavior). Clamp to a sane ceiling so a typo can't lock out
9883 // all input. REQUIRED here — mxchat_sanitize() rebuilds the array from a whitelist,
9884 // so without this isset-handler the key is stripped on the next save of ANY field
9885 // (the 2c02ea global-rate-limit footgun).
9886 if (array_key_exists('max_input_length', $input)) {
9887 $new_input['max_input_length'] = max(0, min(100000, intval($input['max_input_length'])));
9888 }
9889 if (array_key_exists('satisfaction_rating_question', $input)) {
9890 $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
9891 }
9892 if (array_key_exists('satisfaction_rating_thanks', $input)) {
9893 $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
9894 }
9895 if (array_key_exists('satisfaction_rating_placeholder', $input)) {
9896 $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
9897 }
9898 if (array_key_exists('satisfaction_rating_saved', $input)) {
9899 $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
9900 }
9901
9902 if (isset($input['top_bar_title'])) {
9903 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
9904 }
9905
9906 if (isset($input['ai_agent_text'])) {
9907 $new_input['ai_agent_text'] = sanitize_text_field($input['ai_agent_text']);
9908 }
9909
9910 if (isset($input['enable_email_block'])) {
9911 $new_input['enable_email_block'] = sanitize_text_field($input['enable_email_block']);
9912 }
9913
9914 if (isset($input['email_blocker_header_content'])) {
9915 // wp_kses_post() allows standard HTML tags permitted by WordPress
9916 $new_input['email_blocker_header_content'] = wp_kses_post($input['email_blocker_header_content']);
9917 }
9918 if (isset($input['email_blocker_button_text'])) {
9919 $new_input['email_blocker_button_text'] = sanitize_text_field($input['email_blocker_button_text']);
9920 }
9921 // Sanitize name field toggle
9922 if (isset($input['enable_name_field'])) {
9923 $new_input['enable_name_field'] = ($input['enable_name_field'] === 'on') ? 'on' : 'off';
9924 } else {
9925 $new_input['enable_name_field'] = 'off';
9926 }
9927 // Sanitize name field placeholder
9928 if (isset($input['name_field_placeholder'])) {
9929 $new_input['name_field_placeholder'] = sanitize_text_field($input['name_field_placeholder']);
9930 }
9931 if (isset($input['intro_message'])) {
9932 $new_input['intro_message'] = wp_kses_post($input['intro_message']); // Use wp_kses_post instead
9933 }
9934
9935 if (isset($input['input_copy'])) {
9936 $new_input['input_copy'] = sanitize_text_field($input['input_copy']);
9937 }
9938
9939 if (isset($input['rate_limit_message'])) {
9940 $new_input['rate_limit_message'] = sanitize_text_field($input['rate_limit_message']);
9941 }
9942
9943 // Handle the new rate limits format
9944 if (isset($input['rate_limits']) && is_array($input['rate_limits'])) {
9945 $new_input['rate_limits'] = array();
9946 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
9947 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
9948
9949 foreach ($input['rate_limits'] as $role_id => $settings) {
9950 $new_input['rate_limits'][$role_id] = array();
9951
9952 // Sanitize limit
9953 if (isset($settings['limit'])) {
9954 $limit = sanitize_text_field($settings['limit']);
9955 // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
9956 // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
9957 // the custom path the per-role custom input was dropped and reset to the role
9958 // default on every save (plan-7e23e7).
9959 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
9960 $new_input['rate_limits'][$role_id]['limit'] = $limit;
9961 } else {
9962 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
9963 }
9964 }
9965
9966 // Preserve the per-role custom value (mirrors the global branch's limit_custom).
9967 if (isset($settings['limit_custom'])) {
9968 $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
9969 }
9970
9971 // Sanitize timeframe
9972 if (isset($settings['timeframe'])) {
9973 $timeframe = sanitize_text_field($settings['timeframe']);
9974 if (in_array($timeframe, $allowed_timeframes, true)) {
9975 $new_input['rate_limits'][$role_id]['timeframe'] = $timeframe;
9976 } else {
9977 $new_input['rate_limits'][$role_id]['timeframe'] = 'daily'; // Default
9978 }
9979 }
9980
9981 // Sanitize message
9982 if (isset($settings['message'])) {
9983 $new_input['rate_limits'][$role_id]['message'] = sanitize_textarea_field($settings['message']);
9984 }
9985 }
9986 }
9987
9988 // Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
9989 // Mirrors the per-role block above, adapted to the single global shape. Without
9990 // this branch the whitelist-rebuild dropped rate_limits_global entirely on every
9991 // save, so the global cap silently fell back to its 'unlimited' default.
9992 // MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
9993 if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
9994 $g = $input['rate_limits_global'];
9995 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
9996 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
9997 $global_out = array();
9998
9999 if (isset($g['limit'])) {
10000 $limit = sanitize_text_field($g['limit']);
10001 // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
10002 // autosave handler / renderer), OR any positive integer (custom value).
10003 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10004 $global_out['limit'] = $limit;
10005 } else {
10006 $global_out['limit'] = 'unlimited';
10007 }
10008 }
10009
10010 if (isset($g['limit_custom'])) {
10011 $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
10012 }
10013
10014 if (isset($g['timeframe'])) {
10015 $timeframe = sanitize_text_field($g['timeframe']);
10016 $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
10017 }
10018
10019 if (isset($g['message'])) {
10020 $global_out['message'] = sanitize_textarea_field($g['message']);
10021 }
10022
10023 $new_input['rate_limits_global'] = $global_out;
10024 }
10025
10026 if (isset($input['pre_chat_message'])) {
10027 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
10028 }
10029
10030 if (isset($input['voyage_api_key'])) {
10031 $new_input['voyage_api_key'] = sanitize_text_field($input['voyage_api_key']);
10032 }
10033
10034 // Add to your sanitize function
10035 if (isset($input['embedding_model'])) {
10036 // Catalog refactor (plan-d14e89): allowlist derived from the canonical
10037 // catalog in includes/class-mxchat-model-catalog.php.
10038 if (!class_exists('MxChat_Model_Catalog')) {
10039 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10040 }
10041 $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
10042 if (in_array($input['embedding_model'], $allowed_models)) {
10043 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
10044 }
10045 }
10046
10047 if (isset($input['model'])) {
10048 if ($input['model'] === 'openrouter') {
10049 $new_input['model'] = 'openrouter';
10050 } else {
10051 if (!class_exists('MxChat_Model_Catalog')) {
10052 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10053 }
10054 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
10055
10056 if (in_array($input['model'], $allowed_models)) {
10057 $new_input['model'] = sanitize_text_field($input['model']);
10058 } else {
10059 // Fallback for any deprecated model
10060 $new_input['model'] = 'gpt-5.6-sol';
10061 }
10062 }
10063 }
10064
10065 if (isset($input['openrouter_selected_model'])) {
10066 // Just sanitize it, don't validate against a whitelist
10067 $new_input['openrouter_selected_model'] = sanitize_text_field($input['openrouter_selected_model']);
10068 }
10069
10070 // ADD THIS:
10071 if (isset($input['openrouter_selected_model_name'])) {
10072 $new_input['openrouter_selected_model_name'] = sanitize_text_field($input['openrouter_selected_model_name']);
10073 }
10074
10075 if (isset($input['openrouter_api_key'])) {
10076 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
10077 }
10078
10079 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
10080 if (isset($input['custom_provider_base_url'])) {
10081 $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
10082 }
10083 if (isset($input['custom_provider_api_key'])) {
10084 $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
10085 }
10086 if (isset($input['custom_provider_model'])) {
10087 $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
10088 }
10089 if (isset($input['custom_provider_auth_scheme'])) {
10090 $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
10091 $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
10092 }
10093 if (isset($input['custom_provider_for_embeddings'])) {
10094 $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
10095 }
10096 if (isset($input['custom_provider_for_images'])) {
10097 $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
10098 }
10099 if (isset($input['custom_provider_embedding_model'])) {
10100 $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
10101 }
10102 if (isset($input['custom_provider_api_version'])) {
10103 $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
10104 }
10105
10106
10107
10108 if (isset($input['woocommerce_consumer_key'])) {
10109 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
10110 }
10111
10112 if (isset($input['woocommerce_consumer_secret'])) {
10113 $new_input['woocommerce_consumer_secret'] = sanitize_text_field($input['woocommerce_consumer_secret']);
10114 }
10115
10116
10117 // Sanitize link_target_toggle
10118 if (isset($input['link_target_toggle'])) {
10119 $new_input['link_target_toggle'] = $input['link_target_toggle'] === 'on' ? 'on' : 'off';
10120 }
10121
10122 // Sanitize Loops API Key
10123 if (isset($input['loops_api_key'])) {
10124 $new_input['loops_api_key'] = sanitize_text_field($input['loops_api_key']);
10125 }
10126
10127 if (isset($input['chat_persistence_toggle'])) {
10128 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
10129 }
10130
10131 // No else clause: an absent key stays absent, so the front-end default ('on') applies
10132 // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
10133 if (isset($input['print_button_enabled'])) {
10134 $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
10135 }
10136
10137 // plan ac2e81 — "Start new chat" toggle (default OFF) + editable label.
10138 // No else on the toggle: an absent key stays absent so the front-end '?? off'
10139 // default applies, and the full-array autosave rebuild preserves a saved value.
10140 if (isset($input['reset_chat_enabled'])) {
10141 $new_input['reset_chat_enabled'] = $input['reset_chat_enabled'] === 'on' ? 'on' : 'off';
10142 }
10143
10144 if (isset($input['reset_chat_label'])) {
10145 $new_input['reset_chat_label'] = sanitize_text_field($input['reset_chat_label']);
10146 }
10147
10148 if (isset($input['popular_question_1'])) {
10149 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
10150 }
10151
10152 if (isset($input['popular_question_2'])) {
10153 $new_input['popular_question_2'] = sanitize_text_field($input['popular_question_2']);
10154 }
10155
10156 if (isset($input['popular_question_3'])) {
10157 $new_input['popular_question_3'] = sanitize_text_field($input['popular_question_3']);
10158 }
10159
10160 if (isset($input['additional_popular_questions']) && is_array($input['additional_popular_questions'])) {
10161 $new_input['additional_popular_questions'] = array_map('sanitize_text_field', $input['additional_popular_questions']);
10162 }
10163
10164 // Sanitize Loops Mailing List
10165 if (isset($input['loops_mailing_list'])) {
10166 $new_input['loops_mailing_list'] = sanitize_text_field($input['loops_mailing_list']);
10167 }
10168
10169 // Sanitize Triggered Phrase Response
10170 if (isset($input['triggered_phrase_response'])) {
10171 $new_input['triggered_phrase_response'] = wp_kses_post($input['triggered_phrase_response']);
10172 }
10173 if (isset($input['email_capture_response'])) {
10174 $new_input['email_capture_response'] = wp_kses_post($input['email_capture_response']);
10175 }
10176
10177 // Sanitize Brave Search Settings
10178 if (isset($input['brave_api_key'])) {
10179 $new_input['brave_api_key'] = sanitize_text_field($input['brave_api_key']);
10180 }
10181
10182 if (isset($input['brave_image_count'])) {
10183 $image_count = intval($input['brave_image_count']);
10184 $new_input['brave_image_count'] = ($image_count >=1 && $image_count <=6) ? $image_count : 4;
10185 }
10186
10187 if (isset($input['brave_safe_search'])) {
10188 $allowed = array('strict', 'off');
10189 $new_input['brave_safe_search'] = in_array($input['brave_safe_search'], $allowed, true) ? $input['brave_safe_search'] : 'strict';
10190 }
10191
10192 if (isset($input['brave_news_count'])) {
10193 $news_count = intval($input['brave_news_count']);
10194 $new_input['brave_news_count'] = ($news_count >=1 && $news_count <=10) ? $news_count : 3;
10195 }
10196
10197 if (isset($input['brave_country'])) {
10198 $new_input['brave_country'] = sanitize_text_field($input['brave_country']);
10199 }
10200
10201 if (isset($input['brave_language'])) {
10202 $new_input['brave_language'] = sanitize_text_field($input['brave_language']);
10203 }
10204
10205 if (isset($input['chat_toolbar_toggle'])) {
10206 $new_input['chat_toolbar_toggle'] = $input['chat_toolbar_toggle'] === 'on' ? 'on' : 'off';
10207 }
10208
10209 // Sanitize PDF upload button toggle
10210 if (isset($input['show_pdf_upload_button'])) {
10211 $new_input['show_pdf_upload_button'] = $input['show_pdf_upload_button'] === 'on' ? 'on' : 'off';
10212 } else {
10213 $new_input['show_pdf_upload_button'] = 'off'; // If checkbox is unchecked
10214 }
10215
10216 // Sanitize Word upload button toggle
10217 if (isset($input['show_word_upload_button'])) {
10218 $new_input['show_word_upload_button'] = $input['show_word_upload_button'] === 'on' ? 'on' : 'off';
10219 } else {
10220 $new_input['show_word_upload_button'] = 'off'; // If checkbox is unchecked
10221 }
10222
10223 if (isset($input['pdf_intent_trigger_text'])) {
10224 $new_input['pdf_intent_trigger_text'] = sanitize_text_field($input['pdf_intent_trigger_text']);
10225 }
10226
10227 if (isset($input['pdf_intent_success_text'])) {
10228 $new_input['pdf_intent_success_text'] = sanitize_text_field($input['pdf_intent_success_text']);
10229 }
10230
10231 if (isset($input['pdf_intent_error_text'])) {
10232 $new_input['pdf_intent_error_text'] = sanitize_text_field($input['pdf_intent_error_text']);
10233 }
10234
10235 if (isset($input['pdf_max_pages'])) {
10236 $new_input['pdf_max_pages'] = intval($input['pdf_max_pages']);
10237 if ($new_input['pdf_max_pages'] < 1 || $new_input['pdf_max_pages'] > 69) {
10238 $new_input['pdf_max_pages'] = 69; // Default to 69 if out of range
10239 }
10240 }
10241
10242 if (isset($input['live_agent_webhook_url'])) {
10243 $new_input['live_agent_webhook_url'] = esc_url_raw($input['live_agent_webhook_url']);
10244 }
10245 if (isset($input['live_agent_secret_key'])) {
10246 $new_input['live_agent_secret_key'] = sanitize_text_field($input['live_agent_secret_key']);
10247 }
10248
10249 // Live Agent Integration
10250 if (isset($input['live_agent_bot_token'])) {
10251 $new_input['live_agent_bot_token'] = sanitize_text_field($input['live_agent_bot_token']);
10252 }
10253
10254 if (isset($input['live_agent_shared_channel'])) {
10255 $new_input['live_agent_shared_channel'] = sanitize_text_field($input['live_agent_shared_channel']);
10256 }
10257
10258 // Default-OFF toggle: absent key stays absent (unchecked box = off).
10259 if (isset($input['live_agent_archive_on_end_toggle'])) {
10260 $new_input['live_agent_archive_on_end_toggle'] = ($input['live_agent_archive_on_end_toggle'] === 'on') ? 'on' : 'off';
10261 }
10262
10263 if (isset($input['live_agent_user_ids'])) {
10264 $new_input['live_agent_user_ids'] = sanitize_textarea_field($input['live_agent_user_ids']);
10265 }
10266
10267 if (isset($input['live_agent_status'])) {
10268 $new_input['live_agent_status'] = ($input['live_agent_status'] === 'on') ? 'on' : 'off';
10269 }
10270 if (isset($input['live_agent_away_message'])) {
10271 $new_input['live_agent_away_message'] = sanitize_textarea_field($input['live_agent_away_message']);
10272 }
10273 if (isset($input['live_agent_notification_message'])) {
10274 $new_input['live_agent_notification_message'] = sanitize_textarea_field($input['live_agent_notification_message']);
10275 }
10276
10277 // Telegram Integration
10278 if (isset($input['telegram_status'])) {
10279 $new_input['telegram_status'] = ($input['telegram_status'] === 'on') ? 'on' : 'off';
10280 }
10281 if (isset($input['telegram_bot_token'])) {
10282 $new_input['telegram_bot_token'] = sanitize_text_field($input['telegram_bot_token']);
10283 }
10284 if (isset($input['telegram_group_id'])) {
10285 $new_input['telegram_group_id'] = sanitize_text_field($input['telegram_group_id']);
10286 }
10287 if (isset($input['telegram_webhook_secret'])) {
10288 $new_input['telegram_webhook_secret'] = sanitize_text_field($input['telegram_webhook_secret']);
10289 }
10290 if (isset($input['telegram_notification_message'])) {
10291 $new_input['telegram_notification_message'] = sanitize_textarea_field($input['telegram_notification_message']);
10292 }
10293 if (isset($input['telegram_away_message'])) {
10294 $new_input['telegram_away_message'] = sanitize_textarea_field($input['telegram_away_message']);
10295 }
10296
10297 // Sanitize script loading strategy
10298 if (isset($input['script_loading_strategy'])) {
10299 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
10300 $new_input['script_loading_strategy'] = in_array($input['script_loading_strategy'], $allowed_strategies)
10301 ? $input['script_loading_strategy']
10302 : 'default';
10303 }
10304
10305 // Sanitize debug mode
10306 if (isset($input['debug_mode'])) {
10307 $new_input['debug_mode'] = ($input['debug_mode'] === 'on') ? 'on' : 'off';
10308 }
10309
10310 // Content Generator Settings
10311 if (isset($input['content_model'])) {
10312 $new_input['content_model'] = sanitize_text_field($input['content_model']);
10313 }
10314 if (isset($input['content_image_model'])) {
10315 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
10316 }
10317 if (isset($input['content_image_quality'])) {
10318 $q = sanitize_text_field($input['content_image_quality']);
10319 $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
10320 }
10321 if (isset($input['content_enable_images'])) {
10322 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
10323 }
10324 if (isset($input['content_use_placeholders'])) {
10325 $new_input['content_use_placeholders'] = ($input['content_use_placeholders'] === 'on') ? 'on' : 'off';
10326 }
10327 if (isset($input['content_internal_linking'])) {
10328 $new_input['content_internal_linking'] = ($input['content_internal_linking'] === 'on') ? 'on' : 'off';
10329 }
10330 if (isset($input['content_tool_use'])) {
10331 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
10332 }
10333 if (isset($input['content_image_count'])) {
10334 $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
10335 }
10336
10337 // SEO Optimize toggle fields
10338 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) {
10339 if (isset($input[$seo_key])) {
10340 $new_input[$seo_key] = ($input[$seo_key] === 'on') ? 'on' : 'off';
10341 }
10342 }
10343
10344 // Preserve content generator settings when saving from main settings page
10345 // (where content fields are not in the form submission)
10346 $existing = get_option('mxchat_options', array());
10347 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) {
10348 if (!isset($new_input[$key]) && isset($existing[$key])) {
10349 $new_input[$key] = $existing[$key];
10350 }
10351 }
10352
10353 return $new_input;
10354 }
10355
10356 /**
10357 * Log a debug message if debug mode is enabled
10358 *
10359 * @param string $type The type of log entry (settings_save, api_error, activation, etc.)
10360 * @param string $message The log message
10361 * @param array $data Optional additional data to log
10362 * @return bool Whether the message was logged
10363 */
10364 public static function mxchat_log_debug( $type, $message, $data = array() ) {
10365 $options = get_option( 'mxchat_options', array() );
10366
10367 // Check if debug mode is enabled
10368 if ( ! isset( $options['debug_mode'] ) || $options['debug_mode'] !== 'on' ) {
10369 return false;
10370 }
10371
10372 // Get current log
10373 $log = get_option( 'mxchat_debug_log', array() );
10374 if ( ! is_array( $log ) ) {
10375 $log = array();
10376 }
10377
10378 // Add new entry
10379 $entry = array(
10380 'time' => current_time( 'Y-m-d H:i:s' ),
10381 'type' => sanitize_key( $type ),
10382 'message' => sanitize_text_field( $message ),
10383 );
10384
10385 if ( ! empty( $data ) ) {
10386 $entry['data'] = $data;
10387 }
10388
10389 // Add to beginning of array (newest first)
10390 array_unshift( $log, $entry );
10391
10392 // Keep only last 100 entries
10393 if ( count( $log ) > 100 ) {
10394 $log = array_slice( $log, 0, 100 );
10395 }
10396
10397 // Save log
10398 update_option( 'mxchat_debug_log', $log, false );
10399
10400 return true;
10401 }
10402
10403 /**
10404 * Get the debug log entries
10405 *
10406 * @return array The debug log entries
10407 */
10408 public static function mxchat_get_debug_log() {
10409 $log = get_option( 'mxchat_debug_log', array() );
10410 return is_array( $log ) ? $log : array();
10411 }
10412
10413 /**
10414 * Clear the debug log
10415 *
10416 * @return bool Whether the log was cleared
10417 */
10418 public static function mxchat_clear_debug_log() {
10419 return delete_option( 'mxchat_debug_log' );
10420 }
10421
10422 /**
10423 * Export settings as JSON with masked API keys
10424 *
10425 * @return array The sanitized settings array
10426 */
10427 public static function mxchat_export_settings() {
10428 $options = get_option( 'mxchat_options', array() );
10429
10430 if ( ! is_array( $options ) ) {
10431 return array();
10432 }
10433
10434 // List of API key fields to mask
10435 $api_key_fields = array(
10436 'api_key',
10437 'xai_api_key',
10438 'claude_api_key',
10439 'deepseek_api_key',
10440 'voyage_api_key',
10441 'gemini_api_key',
10442 'openrouter_api_key',
10443 'loops_api_key',
10444 'brave_api_key',
10445 'live_agent_secret_key',
10446 'live_agent_bot_token',
10447 'telegram_bot_token',
10448 'telegram_webhook_secret',
10449 'woocommerce_consumer_key',
10450 'woocommerce_consumer_secret',
10451 );
10452
10453 // Mask API keys (show only last 4 characters)
10454 foreach ( $api_key_fields as $field ) {
10455 if ( isset( $options[ $field ] ) && ! empty( $options[ $field ] ) ) {
10456 $value = $options[ $field ];
10457 if ( strlen( $value ) > 4 ) {
10458 $options[ $field ] = str_repeat( '*', strlen( $value ) - 4 ) . substr( $value, -4 );
10459 } else {
10460 $options[ $field ] = '****';
10461 }
10462 }
10463 }
10464
10465 // Add metadata
10466 $export = array(
10467 'plugin_version' => defined( 'MXCHAT_VERSION' ) ? MXCHAT_VERSION : 'unknown',
10468 'export_date' => current_time( 'Y-m-d H:i:s' ),
10469 'wordpress_version' => get_bloginfo( 'version' ),
10470 'php_version' => phpversion(),
10471 'settings' => $options,
10472 );
10473
10474 return $export;
10475 }
10476
10477 /**
10478 * Reset all settings to defaults
10479 *
10480 * @return bool Whether the reset was successful
10481 */
10482 public static function mxchat_reset_all_settings() {
10483 // Delete main options
10484 $deleted = delete_option( 'mxchat_options' );
10485
10486 // Also clear the debug log
10487 delete_option( 'mxchat_debug_log' );
10488
10489 // Log the reset (will create new log since we just cleared it)
10490 // We need to temporarily enable debug mode to log this
10491 $temp_options = array( 'debug_mode' => 'on' );
10492 update_option( 'mxchat_options', $temp_options );
10493
10494 self::mxchat_log_debug( 'reset', 'All settings have been reset to defaults' );
10495
10496 // Now delete again to trigger re-initialization
10497 delete_option( 'mxchat_options' );
10498
10499 return $deleted;
10500 }
10501
10502 // Method to append the chatbot to the body
10503 public function mxchat_append_chatbot_to_body() {
10504 $options = get_option('mxchat_options');
10505 if (isset($options['append_to_body']) && $options['append_to_body'] === 'on') {
10506 echo do_shortcode('[mxchat_chatbot floating="yes"]');
10507 }
10508 }
10509
10510
10511
10512
10513
10514 private function mxchat_fetch_loops_mailing_lists($api_key) {
10515 $url = 'https://app.loops.so/api/v1/lists';
10516 $response = wp_remote_get($url, array(
10517 'headers' => array(
10518 'Authorization' => 'Bearer ' . $api_key,
10519 'Content-Type' => 'application/json'
10520 )
10521 ));
10522
10523 if (is_wp_error($response)) {
10524 return array();
10525 }
10526
10527 $body = wp_remote_retrieve_body($response);
10528 $lists = json_decode($body, true);
10529
10530 return isset($lists) && is_array($lists) ? $lists : array();
10531 }
10532
10533 function mxchat_calculate_cosine_similarity($vec1, $vec2) {
10534 if (empty($vec1) || empty($vec2)) {
10535 return 0.0;
10536 }
10537
10538 $dot_product = 0.0;
10539 $norm_a = 0.0;
10540 $norm_b = 0.0;
10541
10542 for ($i = 0; $i < count($vec1); $i++) {
10543 $dot_product += $vec1[$i] * $vec2[$i];
10544 $norm_a += pow($vec1[$i], 2);
10545 $norm_b += pow($vec2[$i], 2);
10546 }
10547
10548 if ($norm_a == 0.0 || $norm_b == 0.0) {
10549 return 0.0;
10550 } else {
10551 return $dot_product / (sqrt($norm_a) * sqrt($norm_b));
10552 }
10553 }
10554
10555 /**
10556 * Validates nonce and deletes all chat prompts
10557 */
10558 public function mxchat_handle_delete_all_prompts() {
10559 //error_log('=== DELETE ALL DEBUG START ===');
10560
10561 // Verify nonce
10562 if (!isset($_POST['mxchat_delete_all_prompts_nonce']) || !wp_verify_nonce($_POST['mxchat_delete_all_prompts_nonce'], 'mxchat_delete_all_prompts_action')) {
10563 //error_log('DEBUG: Nonce verification failed');
10564 wp_die(__('Nonce verification failed.', 'mxchat'));
10565 }
10566
10567 // Check permissions
10568 if (!current_user_can('manage_options')) {
10569 //error_log('DEBUG: Permission check failed');
10570 wp_die(__('You do not have sufficient permissions to delete all prompts.', 'mxchat'));
10571 }
10572
10573 // Get bot_id and content type filter from POST data
10574 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
10575 $content_type_filter = isset($_POST['content_type_filter']) ? sanitize_text_field($_POST['content_type_filter']) : '';
10576
10577 $success = true;
10578 $error_messages = array();
10579
10580 // Get bot-specific Pinecone configuration
10581 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
10582 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
10583
10584 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10585
10586 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10587 // Delete from Pinecone (with optional content type filter)
10588 $result = $pinecone_manager->mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter);
10589
10590 if (!$result['success']) {
10591 $success = false;
10592 $error_messages[] = $result['message'];
10593 }
10594
10595 } else {
10596 // Delete from WordPress database
10597 global $wpdb;
10598 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10599
10600 // Build WHERE conditions
10601 $where_clauses = array();
10602 $where_values = array();
10603
10604 // Bot filter
10605 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
10606 $where_clauses[] = 'bot_id = %s';
10607 $where_values[] = $bot_id;
10608 }
10609
10610 // Content type filter
10611 if (!empty($content_type_filter)) {
10612 $where_clauses[] = 'content_type = %s';
10613 $where_values[] = $content_type_filter;
10614 }
10615
10616 if (!empty($where_clauses)) {
10617 $where_sql = implode(' AND ', $where_clauses);
10618 $result = $wpdb->query($wpdb->prepare("DELETE FROM {$table_name} WHERE {$where_sql}", $where_values));
10619 } else {
10620 // No filters — delete all
10621 $result = $wpdb->query("DELETE FROM {$table_name}");
10622 }
10623
10624 if ($result === false) {
10625 $success = false;
10626 $error_messages[] = 'Failed to delete from WordPress database';
10627 }
10628 }
10629
10630 // Redirect back with a success message and bot_id
10631 $redirect_url = add_query_arg(array(
10632 'page' => 'mxchat-prompts',
10633 'bot_id' => $bot_id,
10634 'all_deleted' => $success ? 'true' : 'false'
10635 ), admin_url('admin.php'));
10636
10637 wp_safe_redirect($redirect_url);
10638 exit;
10639 }
10640
10641
10642
10643 /**
10644 * Handles deletion prompt with nonce validation
10645 */
10646 public function mxchat_handle_delete_prompt() {
10647 // Sanitize and validate nonce
10648 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
10649 if (empty($nonce) || !wp_verify_nonce($nonce, 'mxchat_delete_prompt_nonce')) {
10650 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
10651 }
10652
10653 // Check permissions
10654 if (!current_user_can('manage_options')) {
10655 wp_die(esc_html__('You do not have sufficient permissions to delete prompts.', 'mxchat'));
10656 }
10657
10658 // Get ID and source parameters
10659 $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : '';
10660 $source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : '';
10661
10662 if (empty($id)) {
10663 wp_die(esc_html__('Invalid prompt ID.', 'mxchat'));
10664 }
10665
10666 $success = false;
10667 $error_message = '';
10668
10669 // Check if Pinecone is enabled and determine source automatically if not specified
10670 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10671 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10672
10673 // If source is not specified, determine based on Pinecone configuration
10674 if (empty($source)) {
10675 $source = $use_pinecone ? 'pinecone' : 'wordpress';
10676 }
10677
10678 if ($source === 'pinecone' || $use_pinecone) {
10679 //error_log('[MXCHAT-DELETE] Deleting from Pinecone, ID: ' . $id);
10680
10681 // Handle Pinecone deletion
10682 if (empty($pinecone_options['mxchat_pinecone_host']) ||
10683 empty($pinecone_options['mxchat_pinecone_api_key'])) {
10684 wp_die(esc_html__('Pinecone configuration is missing.', 'mxchat'));
10685 }
10686
10687 // Delete from Pinecone using the vector ID directly
10688 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
10689 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
10690 $id,
10691 $pinecone_options['mxchat_pinecone_api_key'],
10692 $pinecone_options['mxchat_pinecone_host']
10693 );
10694 if ($result['success']) {
10695 $success = true;
10696
10697 // Remove from vector cache
10698 $pinecone_manager->mxchat_remove_from_pinecone_vector_cache($id);
10699 $pinecone_manager->mxchat_remove_from_processed_content_caches($id);
10700
10701 set_transient('mxchat_admin_notice_success',
10702 esc_html__('Vector deleted successfully from Pinecone.', 'mxchat'), 30);
10703 } else {
10704 $error_message = $result['message'];
10705 set_transient('mxchat_admin_notice_error',
10706 esc_html__('Failed to delete from Pinecone: ', 'mxchat') . esc_html($error_message), 30);
10707 }
10708 } else {
10709 //error_log('[MXCHAT-DELETE] Deleting from WordPress database, ID: ' . $id);
10710
10711 // Handle WordPress database deletion
10712 global $wpdb;
10713 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10714
10715 // Clear cache and delete prompt
10716 wp_cache_delete('prompt_' . $id, 'mxchat_prompts');
10717
10718 $result = $wpdb->delete(
10719 $table_name,
10720 array('id' => intval($id)),
10721 array('%d')
10722 );
10723
10724 if ($result !== false) {
10725 $success = true;
10726 set_transient('mxchat_admin_notice_success',
10727 esc_html__('Entry deleted successfully.', 'mxchat'), 30);
10728 } else {
10729 set_transient('mxchat_admin_notice_error',
10730 esc_html__('Failed to delete entry from database.', 'mxchat'), 30);
10731 }
10732 }
10733
10734 // Redirect back to the prompts page
10735 wp_safe_redirect(add_query_arg(
10736 array(
10737 'page' => 'mxchat-prompts',
10738 'deleted' => $success ? 'true' : 'false'
10739 ),
10740 admin_url('admin.php')
10741 ));
10742 exit;
10743 }
10744
10745
10746
10747
10748 /**
10749 * Averages multiple vectors into a single vector
10750 */
10751 private function mxchat_average_vectors($vectors) {
10752 $vector_length = count($vectors[0]);
10753 $sum_vector = array_fill(0, $vector_length, 0);
10754
10755 foreach ($vectors as $vector) {
10756 for ($i = 0; $i < $vector_length; $i++) {
10757 $sum_vector[$i] += $vector[$i];
10758 }
10759 }
10760
10761 // Divide each component by the number of vectors to get the average
10762 $num_vectors = count($vectors);
10763 for ($i = 0; $i < $vector_length; $i++) {
10764 $sum_vector[$i] /= $num_vectors;
10765 }
10766
10767 return $sum_vector;
10768 }
10769
10770
10771
10772
10773 /**
10774 * Generates embeddings from input text for MXChat
10775 */
10776 public function mxchat_generate_embedding($text) {
10777 // Enable detailed logging for debugging
10778 //error_log('[MXCHAT-EMBED] Starting embedding generation. Text length: ' . strlen($text) . ' bytes');
10779 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
10780
10781 $options = get_option('mxchat_options');
10782 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
10783 //error_log('[MXCHAT-EMBED] Selected embedding model: ' . $selected_model);
10784
10785 // Determine provider and endpoint
10786 if (strpos($selected_model, 'voyage') === 0) {
10787 $api_key = $options['voyage_api_key'] ?? '';
10788 $endpoint = 'https://api.voyageai.com/v1/embeddings';
10789 $provider_name = 'Voyage AI';
10790 //error_log('[MXCHAT-EMBED] Using Voyage AI API');
10791 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
10792 $api_key = $options['gemini_api_key'] ?? '';
10793 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
10794 $provider_name = 'Google Gemini';
10795 //error_log('[MXCHAT-EMBED] Using Google Gemini API');
10796 } else {
10797 $api_key = $options['api_key'] ?? '';
10798 $endpoint = 'https://api.openai.com/v1/embeddings';
10799 $provider_name = 'OpenAI';
10800 //error_log('[MXCHAT-EMBED] Using OpenAI API');
10801 }
10802
10803 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
10804
10805 if (empty($api_key)) {
10806 $error_message = sprintf('Missing %s API key. Please configure your API key in the MxChat settings.', $provider_name);
10807 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
10808 return $error_message;
10809 }
10810
10811 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
10812 $estimated_tokens = ceil(str_word_count($text) / 0.75);
10813 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
10814
10815 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
10816 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
10817 // Consider truncating text here
10818 }
10819
10820 // Prepare request body based on provider
10821 if (strpos($selected_model, 'gemini-embedding') === 0) {
10822 // Gemini API format
10823 $request_body = array(
10824 'model' => 'models/' . $selected_model,
10825 'content' => array(
10826 'parts' => array(
10827 array('text' => $text)
10828 )
10829 )
10830 );
10831
10832 // Set output dimensionality to 1536 for consistency with other models
10833 $request_body['outputDimensionality'] = 1536;
10834 } else {
10835 // OpenAI/Voyage API format
10836 $request_body = array(
10837 'model' => $selected_model,
10838 'input' => $text
10839 );
10840
10841 // Add output_dimension for voyage-3-large model
10842 if ($selected_model === 'voyage-3-large') {
10843 $request_body['output_dimension'] = 2048;
10844 }
10845 }
10846
10847 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
10848
10849 // Prepare headers based on provider
10850 if (strpos($selected_model, 'gemini-embedding') === 0) {
10851 // Gemini uses API key as query parameter
10852 $endpoint .= '?key=' . $api_key;
10853 $headers = array(
10854 'Content-Type' => 'application/json'
10855 );
10856 } else {
10857 // OpenAI/Voyage use Bearer token
10858 $headers = array(
10859 'Authorization' => 'Bearer ' . $api_key,
10860 'Content-Type' => 'application/json'
10861 );
10862 }
10863
10864 // Make API request
10865 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
10866 $response = wp_remote_post($endpoint, array(
10867 'body' => wp_json_encode($request_body),
10868 'headers' => $headers,
10869 'timeout' => 60 // Increased timeout for large inputs
10870 ));
10871
10872 // Handle wp_remote_post errors
10873 if (is_wp_error($response)) {
10874 $error_message = $response->get_error_message();
10875 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
10876 return 'Connection error: ' . $error_message;
10877 }
10878
10879 // Get and check HTTP response code
10880 $http_code = wp_remote_retrieve_response_code($response);
10881 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
10882
10883 if ($http_code !== 200) {
10884 $error_body = wp_remote_retrieve_body($response);
10885 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
10886
10887 // Try to parse error for more details
10888 $error_json = json_decode($error_body, true);
10889 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
10890 $error_type = $error_json['error']['type'] ?? 'unknown';
10891 $error_message = $error_json['error']['message'] ?? 'No message';
10892 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
10893 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
10894
10895 // Customize error message for common API errors
10896 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
10897 $error_message = sprintf('Invalid %s API key. Please check your API key in the MxChat settings.', $provider_name);
10898 } elseif ($error_type === 'authentication_error') {
10899 $error_message = sprintf('%s authentication failed. Please verify your API key in the MxChat settings.', $provider_name);
10900 }
10901
10902 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
10903 return $error_message;
10904 }
10905
10906 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding", $http_code);
10907 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
10908 return $error_message;
10909 }
10910
10911 // Parse response body
10912 $response_body = wp_remote_retrieve_body($response);
10913 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
10914
10915 $response_data = json_decode($response_body, true);
10916
10917 if (json_last_error() !== JSON_ERROR_NONE) {
10918 $error = json_last_error_msg();
10919 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
10920 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
10921 return "Failed to parse API response: $error";
10922 }
10923
10924 // Handle different response formats based on provider
10925 if (strpos($selected_model, 'gemini-embedding') === 0) {
10926 // Gemini API response format
10927 if (isset($response_data['embedding']['values'])) {
10928 $embedding_dimensions = count($response_data['embedding']['values']);
10929 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
10930
10931 // Check if embedding dimensions are as expected (should be 1536)
10932 if ($embedding_dimensions !== 1536) {
10933 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
10934 }
10935
10936 MxChat_Utils::stamp_active_embedding_model($selected_model);
10937 return $response_data['embedding']['values'];
10938 } else {
10939 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
10940 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
10941
10942 if (isset($response_data['error'])) {
10943 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
10944 //error_log('[MXCHAT-EMBED] ' . $error_message);
10945 return $error_message;
10946 }
10947
10948 $error_message = "Invalid Gemini API response format: No embedding found";
10949 //error_log('[MXCHAT-EMBED] ' . $error_message);
10950 return $error_message;
10951 }
10952 } else {
10953 // OpenAI/Voyage API response format
10954 if (isset($response_data['data'][0]['embedding'])) {
10955 $embedding_dimensions = count($response_data['data'][0]['embedding']);
10956 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
10957
10958 // Check if embedding dimensions are as expected
10959 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
10960 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
10961 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
10962 }
10963
10964 MxChat_Utils::stamp_active_embedding_model($selected_model);
10965 return $response_data['data'][0]['embedding'];
10966 } else {
10967 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
10968 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
10969
10970 if (isset($response_data['error'])) {
10971 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
10972 //error_log('[MXCHAT-EMBED] ' . $error_message);
10973 return $error_message;
10974 }
10975
10976 $error_message = "Invalid API response format: No embedding found";
10977 //error_log('[MXCHAT-EMBED] ' . $error_message);
10978 return $error_message;
10979 }
10980 }
10981 }
10982
10983
10984
10985 }
10986 ?>
10987